🐦‍⬛ Snapshot testing in Gleam

hello, Joe!

+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v3 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "26.0.2" 18 + gleam-version: "0.34.1" 19 + rebar3-version: "3" 20 + # elixir-version: "1.15.4" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+5
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## v1.0.0 - 2024-01-27 4 + 5 + - 🎉 First release!
+201
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + APPENDIX: How to apply the Apache License to your work. 179 + 180 + To apply the Apache License to your work, attach the following 181 + boilerplate notice, with the fields enclosed by brackets "[]" 182 + replaced with your own identifying information. (Don't include 183 + the brackets!) The text should be enclosed in the appropriate 184 + comment syntax for the file format. We also recommend that a 185 + file or class name and description of purpose be included on the 186 + same "printed page" as the copyright notice for easier 187 + identification within third-party archives. 188 + 189 + Copyright [yyyy] [name of copyright owner] 190 + 191 + Licensed under the Apache License, Version 2.0 (the "License"); 192 + you may not use this file except in compliance with the License. 193 + You may obtain a copy of the License at 194 + 195 + http://www.apache.org/licenses/LICENSE-2.0 196 + 197 + Unless required by applicable law or agreed to in writing, software 198 + distributed under the License is distributed on an "AS IS" BASIS, 199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 + See the License for the specific language governing permissions and 201 + limitations under the License.
+75
README.md
··· 1 + # 🐦‍⬛ Birdie - snapshot testing in Gleam 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/birdie)](https://hex.pm/packages/birdie) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/birdie/) 5 + ![Supported targets](https://img.shields.io/badge/supports-all_targets-ffaff3) 6 + 7 + Snapshot testing allows you to perform assertions without having to write the 8 + expectation yourself. Birdie will store a snapshot of the expected value and 9 + compare future runs of the same test against it. Imagine doing a 10 + `should.equal(expected, got)` where you don't have to take care of writing the 11 + expected output. 12 + 13 + ## Writing snapshot tests with Birdie 14 + 15 + First you'll want to add the package to your dependencies: 16 + 17 + ```sh 18 + gleam add --dev birdie 19 + ``` 20 + 21 + To write snapshot tests you can import the `birdie` module and use the 22 + [`snap`](https://hexdocs.pm/birdie/birdie.html#snap) function: 23 + 24 + ```gleam 25 + import gleeunit 26 + import birdie 27 + 28 + pub fn main() { 29 + gleeunit.main() 30 + } 31 + 32 + pub fn hello_birdie_test() { 33 + "🐦‍⬛ Smile for the birdie!" 34 + |> birdie.snap(title: "my first snapshot") 35 + } 36 + ``` 37 + 38 + This will record a new snapshot with the given title and content. A snapshot 39 + test will always fail on its first run until you review and accept it. 40 + Once you've reviewed and accepted a snapshot, the test will fail only if the 41 + snapshot's content changes; in that case you will be presented with a diff and 42 + asked to review it once again. 43 + 44 + A typical workflow will look like this: 45 + 46 + - Run your tests 47 + - If you have any new snapshots - or some of the snapshots have changed - some 48 + tests will fail 49 + - Review all the new snapshots deciding if you want to keep the new version or 50 + the previously accepted one 51 + - And don't forget to commit your snapshots! Those should be treated like 52 + code and checked with the vcs you're using 53 + 54 + ## Reviewing snapshots 55 + 56 + Birdie also provides a CLI tool to help you in the review process: run 57 + `gleam run -m birdie` in your project and birdie will help you interactively 58 + review all your new snapshots. 59 + 60 + > The CLI tool can also do more than just guide you through all your snapshots 61 + > one by one. To check all the available options you can run 62 + > `gleam run -m birdie help` 63 + 64 + ![image](https://github.com/giacomocavalieri/birdie/blob/main/birdie.gif?raw=true) 65 + 66 + ## References 67 + 68 + This package was heavily inspired by the excellent Rust library 69 + [`insta`](https://insta.rs), do check it out! 70 + 71 + ## Contributing 72 + 73 + If you think there's any way to improve this package, or if you spot a bug don't 74 + be afraid to open PRs, issues or requests of any kind! 75 + Any contribution is welcome 💜
birdie.gif

This is a binary file and will not be displayed.

+27
birdie.tape
··· 1 + Output birdie.gif 2 + 3 + Set Shell "bash" 4 + Set FontSize 24 5 + Set Width 900 6 + Set Height 700 7 + 8 + Type "gleam run -m birdie" 9 + Sleep 500ms 10 + Enter 11 + 12 + Sleep 5s 13 + Type "s" 14 + Sleep 500ms 15 + Enter 16 + 17 + Sleep 5s 18 + Type "s" 19 + Sleep 500ms 20 + Enter 21 + 22 + Sleep 5s 23 + Type "s" 24 + Sleep 500ms 25 + Enter 26 + 27 + Sleep 5s
+5
birdie_snapshots/my_favourite_number_wrapped_in_a_result.accepted
··· 1 + --- 2 + version: 1.0.0 3 + title: my favourite number wrapped in a result 4 + --- 5 + Ok(11)
+5
birdie_snapshots/my_first_snapshot.accepted
··· 1 + --- 2 + version: 1.0.0 3 + title: my first snapshot 4 + --- 5 + 🐦‍⬛ smile for the birdie!
+5
birdie_snapshots/snapping_a_list_of_numbers.accepted
··· 1 + --- 2 + version: 1.0.0 3 + title: snapping a list of numbers 4 + --- 5 + [ 1, 2, 3, 4 ]
+20
gleam.toml
··· 1 + name = "birdie" 2 + version = "1.0.0" 3 + 4 + description = "Snapshot testing in Gleam" 5 + licences = ["Apache-2.0"] 6 + repository = { type = "github", user = "giacomocavalieri", repo = "birdie" } 7 + target = "erlang" 8 + 9 + [dependencies] 10 + gleam_stdlib = "~> 0.34 or ~> 1.0" 11 + simplifile = "~> 1.2" 12 + gap = "~> 1.1" 13 + filepath = "~> 0.1" 14 + justin = "~> 1.0" 15 + gleam_community_ansi = "~> 1.4" 16 + glam = "~> 1.3" 17 + argv = "~> 1.0" 18 + gleam_erlang = "~> 0.24" 19 + rank = "~> 1.0" 20 + gleeunit = "~> 1.0"
+30
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" }, 6 + { name = "filepath", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "534E8161A0DE192A9A105EFEC34369E9FD5834BB58ED449B5ACAEE8704358588" }, 7 + { name = "gap", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_ansi"], otp_app = "gap", source = "hex", outer_checksum = "2EE1B0A17E85CF73A0C1D29DA315A2699117A8F549C8E8D89FA8261BE41EDEB1" }, 8 + { name = "glam", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "02E0311862B9669C3E8CE73FA5A95D8FA457C6ACB48D95FBE808ABFAE0A1CEB0" }, 9 + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_community_colour"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, 10 + { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" }, 11 + { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" }, 12 + { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, 13 + { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 14 + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 15 + { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 16 + { name = "simplifile", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "059AEB3632D1EBF4C943E8C231DBD8861A8BBF2984B78C1FE49159F28338A1FF" }, 17 + ] 18 + 19 + [requirements] 20 + argv = { version = "~> 1.0" } 21 + filepath = { version = "~> 0.1" } 22 + gap = { version = "~> 1.1" } 23 + glam = { version = "~> 1.3" } 24 + gleam_community_ansi = { version = "~> 1.4" } 25 + gleam_erlang = { version = "~> 0.24" } 26 + gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } 27 + gleeunit = { version = "~> 1.0" } 28 + justin = { version = "~> 1.0" } 29 + rank = { version = "~> 1.0" } 30 + simplifile = { version = "~> 1.2" }
+817
src/birdie.gleam
··· 1 + import gleam/bool 2 + import gleam/erlang 3 + import gleam/int 4 + import gleam/io 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 7 + import gleam/result 8 + import gleam/string 9 + import gleam_community/ansi 10 + import argv 11 + import birdie/internal/diff.{type DiffLine, DiffLine} 12 + import filepath 13 + import glam/doc 14 + import gleeunit/should 15 + import justin 16 + import rank 17 + import simplifile 18 + 19 + const birdie_version = "1.0.0" 20 + 21 + const birdie_snapshots_folder = "birdie_snapshots" 22 + 23 + const birdie_test_failed_message = "🐦‍⬛ Birdie snapshot test failed" 24 + 25 + const hint_review_message = "run `gleam run -m birdie` to review the snapshots" 26 + 27 + type Error { 28 + CannotCreateSnapshotsFolder(reason: simplifile.FileError) 29 + 30 + CannotReadAcceptedSnapshot(reason: simplifile.FileError, source: String) 31 + 32 + CannotReadNewSnapshot(reason: simplifile.FileError, source: String) 33 + 34 + CannotSaveNewSnapshot( 35 + reason: simplifile.FileError, 36 + title: String, 37 + destination: String, 38 + ) 39 + 40 + CannotReadSnapshots(reason: simplifile.FileError, folder: String) 41 + 42 + CannotRejectSnapshot(reason: simplifile.FileError, snapshot: String) 43 + 44 + CannotAcceptSnapshot(reason: simplifile.FileError, snapshot: String) 45 + 46 + CannotReadUserInput 47 + 48 + CorruptedSnapshot(source: String) 49 + 50 + CannotFindProjectRoot(reason: simplifile.FileError) 51 + } 52 + 53 + // --- THE SNAPSHOT TYPE ------------------------------------------------------- 54 + 55 + type New 56 + 57 + type Accepted 58 + 59 + type Snapshot(status) { 60 + Snapshot(title: String, content: String) 61 + } 62 + 63 + // --- SNAP -------------------------------------------------------------------- 64 + 65 + /// Performs a snapshot test with the given title, saving the content to a new 66 + /// snapshot file. All your snapshots will be stored in a folder called 67 + /// `birdie_snapshots` in the project's root. 68 + /// 69 + /// The test will fail if there already is an accepted snapshot with the same 70 + /// title and a different content. 71 + /// The test will also fail if there's no accepted snapshot with the same title 72 + /// to make sure you will review new snapshots as well. 73 + /// 74 + /// > To review all your snapshots interactively you can run 75 + /// > `gleam run -m birdie`. 76 + /// > 77 + /// > To get an help text and all the available options you can run 78 + /// > `gleam run -m birdie help`. 79 + /// 80 + pub fn snap(content content: String, title title: String) -> Nil { 81 + case do_snap(content, title) { 82 + Ok(Same) -> Nil 83 + 84 + Ok(NewSnapshotCreated(snapshot, destination: _)) -> { 85 + let hint_message = ansi.yellow(hint_review_message) 86 + let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint") 87 + let box = new_snapshot_box(snapshot, [hint]) 88 + 89 + io.println_error("\n\n" <> box <> "\n") 90 + io.println(birdie_test_failed_message) 91 + should.fail() 92 + } 93 + 94 + Ok(Different(accepted, new)) -> { 95 + let hint_message = ansi.yellow(hint_review_message) 96 + let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint") 97 + let box = diff_snapshot_box(accepted, new, [hint]) 98 + 99 + io.println_error("\n\n" <> box <> "\n") 100 + io.println(birdie_test_failed_message) 101 + should.fail() 102 + } 103 + 104 + Error(error) -> { 105 + explain(error) 106 + io.println(birdie_test_failed_message) 107 + should.fail() 108 + } 109 + } 110 + } 111 + 112 + type Outcome { 113 + NewSnapshotCreated(snapshot: Snapshot(New), destination: String) 114 + Different(accepted: Snapshot(Accepted), new: Snapshot(New)) 115 + Same 116 + } 117 + 118 + fn do_snap(content: String, title: String) -> Result(Outcome, Error) { 119 + // We have to find the snapshot folder since the `gleam test` command might 120 + // be run from any subfolder we can't just assume we're in the project's root. 121 + use folder <- result.try(find_snapshots_folder()) 122 + 123 + let new = Snapshot(title: title, content: content) 124 + let new_snapshot_path = new_destination(new, folder) 125 + let accepted_snapshot_path = to_accepted_path(new_snapshot_path) 126 + 127 + // Find an accepted snapshot with the same title to make a comparison. 128 + use accepted <- result.try(read_accepted(accepted_snapshot_path)) 129 + case accepted { 130 + // If there's no accepted snapshot then we save the new one as there's no 131 + // comparison to be made. 132 + None -> { 133 + use _ <- result.try(save(new, to: new_snapshot_path)) 134 + Ok(NewSnapshotCreated(snapshot: new, destination: new_snapshot_path)) 135 + } 136 + 137 + // If there's a corresponding accepted snapshot we compare it with the new 138 + // one. 139 + Some(accepted) -> { 140 + // If the new snapshot is the same as the old one then there's no need to 141 + // save it in a `.new` file: we can just say they are the same. 142 + use <- bool.guard(when: accepted.content == new.content, return: Ok(Same)) 143 + use _ <- result.try(save(new, to: new_snapshot_path)) 144 + Ok(Different(accepted, new)) 145 + } 146 + } 147 + } 148 + 149 + // --- SNAPSHOT CONTENT DIFFING ------------------------------------------------ 150 + 151 + fn to_diff_lines( 152 + accepted: Snapshot(Accepted), 153 + new: Snapshot(New), 154 + ) -> List(DiffLine) { 155 + let Snapshot(title: _, content: accepted_content) = accepted 156 + let Snapshot(title: _, content: new_content) = new 157 + diff.line_by_line(accepted_content, new_content) 158 + } 159 + 160 + // --- SNAPSHOT (DE)SERIALISATION ---------------------------------------------- 161 + 162 + fn deserialise(raw: String) -> Result(Snapshot(a), Nil) { 163 + // Check there's the opening `---` 164 + use #(open_line, rest) <- result.try(string.split_once(raw, "\n")) 165 + use <- bool.guard(when: open_line != "---", return: Error(Nil)) 166 + 167 + // For now I have no use of the version but it might come in handy in the 168 + // future if I decide to change the snapshots' metadata's format. 169 + use #(version_line, rest) <- result.try(string.split_once(rest, "\n")) 170 + use _version <- result.try(case version_line { 171 + "version: " <> version -> Ok(version) 172 + _ -> Error(Nil) 173 + }) 174 + 175 + // Get the title. 176 + use #(title_line, rest) <- result.try(string.split_once(rest, "\n")) 177 + use title <- result.try(case title_line { 178 + // We unescape the newlines 179 + "title: " <> title -> Ok(string.replace(title, each: "\\n", with: "\n")) 180 + _ -> Error(Nil) 181 + }) 182 + 183 + // Check there's the closing `---` 184 + use #(close_line, content) <- result.try(string.split_once(rest, "\n")) 185 + use <- bool.guard(when: close_line != "---", return: Error(Nil)) 186 + 187 + Ok(Snapshot(title: title, content: content)) 188 + } 189 + 190 + fn serialise(snapshot: Snapshot(New)) -> String { 191 + let Snapshot(title: title, content: content) = snapshot 192 + [ 193 + "---", 194 + "version: " <> birdie_version, 195 + // We escape the newlines in the title so that it fits on one line and it's 196 + // easier to parse. 197 + // Is this the best course of action? Probably not. 198 + // Does this make my life a lot easier? Absolutely! 😁 199 + "title: " <> string.replace(title, each: "\n", with: "\\n"), 200 + "---", 201 + content, 202 + ] 203 + |> string.join(with: "\n") 204 + } 205 + 206 + // --- FILE SYSTEM OPERATIONS -------------------------------------------------- 207 + 208 + /// Save a new snapshot to a given path. 209 + /// 210 + fn save(snapshot: Snapshot(New), to destination: String) -> Result(Nil, Error) { 211 + // Just to make sure I'm not messing up something anywhere else in the code 212 + // base: a new snapshot's destination MUST always end with a `.new` extension. 213 + // If it doesn't there's a fatal error in my code and I should fix it. 214 + case string.ends_with(destination, ".new") { 215 + False -> 216 + panic as "Looks like I've messed up something, all new snapshots should have the `.new` extension" 217 + 218 + True -> 219 + simplifile.write(to: destination, contents: serialise(snapshot)) 220 + |> result.map_error(CannotSaveNewSnapshot( 221 + reason: _, 222 + title: snapshot.title, 223 + destination: destination, 224 + )) 225 + } 226 + } 227 + 228 + /// Read an accepted snapshot which might be missing. 229 + /// 230 + fn read_accepted(source: String) -> Result(Option(Snapshot(Accepted)), Error) { 231 + case simplifile.read(source) { 232 + Ok(content) -> 233 + case deserialise(content) { 234 + Ok(snapshot) -> Ok(Some(snapshot)) 235 + Error(Nil) -> Error(CorruptedSnapshot(source)) 236 + } 237 + 238 + Error(simplifile.Enoent) -> Ok(None) 239 + Error(reason) -> 240 + Error(CannotReadAcceptedSnapshot(reason: reason, source: source)) 241 + } 242 + } 243 + 244 + /// Read a new snapshot. 245 + /// 246 + /// > ℹ️ Notice the different return type compared to `read_accepted`: when we 247 + /// > try to read a new snapshot we are sure it's there (because we've listed 248 + /// > the directory or something else) so if it's not present that's an error 249 + /// > and we don't return an `Ok(None)`. 250 + /// 251 + fn read_new(source: String) -> Result(Snapshot(New), Error) { 252 + case simplifile.read(source) { 253 + Ok(content) -> 254 + result.replace_error(deserialise(content), CorruptedSnapshot(source)) 255 + Error(reason) -> 256 + Error(CannotReadNewSnapshot(reason: reason, source: source)) 257 + } 258 + } 259 + 260 + /// List all the new snapshots in a folder. Every file is automatically 261 + /// prepended with the folder so you get the full path of each file. 262 + /// 263 + fn list_new_snapshots(in folder: String) -> Result(List(String), Error) { 264 + case simplifile.read_directory(folder) { 265 + Error(reason) -> Error(CannotReadSnapshots(reason: reason, folder: folder)) 266 + Ok(files) -> 267 + Ok({ 268 + use file <- list.filter_map(files) 269 + case filepath.extension(file) { 270 + // Only keep the files with the ".new" extension and join their name 271 + // with the folder's path. 272 + Ok("new") -> Ok(filepath.join(folder, file)) 273 + _ -> Error(Nil) 274 + } 275 + }) 276 + } 277 + } 278 + 279 + /// Finds the snapshots folder at the root of the project the command is run 280 + /// into. If it's not present the folder is created automatically. 281 + /// 282 + fn find_snapshots_folder() -> Result(String, Error) { 283 + let result = result.map_error(find_project_root("."), CannotFindProjectRoot) 284 + use project_root <- result.try(result) 285 + let snapshots_folder = filepath.join(project_root, birdie_snapshots_folder) 286 + 287 + case simplifile.create_directory(snapshots_folder) { 288 + Ok(Nil) | Error(simplifile.Eexist) -> Ok(snapshots_folder) 289 + Error(error) -> Error(CannotCreateSnapshotsFolder(error)) 290 + } 291 + } 292 + 293 + /// Returns the path to the project's root. 294 + /// 295 + /// > ⚠️ This assumes that this is only ever run inside a Gleam's project and 296 + /// > sooner or later it will reach a `gleam.toml` file. 297 + /// > Otherwise this will end up in an infinite loop, I think. 298 + /// 299 + fn find_project_root(path: String) -> Result(String, simplifile.FileError) { 300 + let manifest = filepath.join(path, "gleam.toml") 301 + case simplifile.verify_is_file(manifest) { 302 + Ok(True) -> Ok(path) 303 + Ok(False) -> find_project_root(filepath.join(path, "..")) 304 + Error(reason) -> Error(reason) 305 + } 306 + } 307 + 308 + fn accept_snapshot(new_snapshot_path: String) -> Result(Nil, Error) { 309 + let accepted_snapshot_path = to_accepted_path(new_snapshot_path) 310 + simplifile.rename_file(new_snapshot_path, accepted_snapshot_path) 311 + |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path)) 312 + } 313 + 314 + fn reject_snapshot(new_snapshot_path: String) -> Result(Nil, Error) { 315 + simplifile.delete(new_snapshot_path) 316 + |> result.map_error(CannotRejectSnapshot(_, new_snapshot_path)) 317 + } 318 + 319 + // --- UTILITIES --------------------------------------------------------------- 320 + 321 + /// Turns a snapshot's title into a file name stripping it of all dangerous 322 + /// characters (or at least those I could think ok 😁). 323 + /// 324 + fn file_name(title: String) -> String { 325 + string.replace(each: "/", with: " ", in: title) 326 + |> string.replace(each: "\\", with: " ") 327 + |> string.replace(each: "\n", with: " ") 328 + |> string.replace(each: "\t", with: " ") 329 + |> string.replace(each: "\r", with: " ") 330 + |> string.replace(each: ".", with: " ") 331 + |> string.replace(each: ":", with: " ") 332 + |> justin.snake_case 333 + } 334 + 335 + /// Returns the path where a new snapshot should be saved. 336 + /// 337 + fn new_destination(snapshot: Snapshot(New), folder: String) -> String { 338 + filepath.join(folder, file_name(snapshot.title)) <> ".new" 339 + } 340 + 341 + /// Strips the extension of a file (if it has one). 342 + /// 343 + fn strip_extension(file: String) -> String { 344 + case filepath.extension(file) { 345 + Ok(extension) -> string.drop_right(file, string.length(extension) + 1) 346 + Error(Nil) -> file 347 + } 348 + } 349 + 350 + /// Turns a new snapshot path into the path of the corresponding accepted 351 + /// snapshot. 352 + /// 353 + fn to_accepted_path(file: String) -> String { 354 + // This just replaces the `.new` extension with the `.accepted` one. 355 + strip_extension(file) <> ".accepted" 356 + } 357 + 358 + // --- PRETTY PRINTING --------------------------------------------------------- 359 + 360 + fn explain(error: Error) -> Nil { 361 + let heading = fn(reason) { "[" <> ansi.bold(string.inspect(reason)) <> "] " } 362 + let message = case error { 363 + CannotCreateSnapshotsFolder(reason: reason) -> 364 + heading(reason) <> "I couldn't create the snapshots folder" 365 + 366 + CannotReadAcceptedSnapshot(reason: reason, source: source) -> 367 + heading(reason) 368 + <> "I couldn't read the accepted snapshot from " 369 + <> ansi.italic("\"" <> source <> "\"\n") 370 + 371 + CannotReadNewSnapshot(reason: reason, source: source) -> 372 + heading(reason) 373 + <> "I couldn't read the new snapshot from " 374 + <> ansi.italic("\"" <> source <> "\"\n") 375 + 376 + CannotSaveNewSnapshot( 377 + reason: reason, 378 + title: title, 379 + destination: destination, 380 + ) -> 381 + heading(reason) 382 + <> "I couldn't save the snapshot " 383 + <> ansi.italic("\"" <> title <> "\" ") 384 + <> "to " 385 + <> ansi.italic("\"" <> destination <> "\"\n") 386 + 387 + CannotReadSnapshots(reason: reason, folder: _) -> 388 + heading(reason) <> "I couldn't read the snapshots directory's contents" 389 + 390 + CannotRejectSnapshot(reason: reason, snapshot: snapshot) -> 391 + heading(reason) 392 + <> "I couldn't reject the snapshot" 393 + <> ansi.italic("\"" <> snapshot <> "\" ") 394 + 395 + CannotAcceptSnapshot(reason: reason, snapshot: snapshot) -> 396 + heading(reason) 397 + <> "I couldn't accept the snapshot" 398 + <> ansi.italic("\"" <> snapshot <> "\" ") 399 + 400 + CannotReadUserInput -> "I couldn't read the user input" 401 + 402 + CorruptedSnapshot(source: source) -> 403 + "It looks like " 404 + <> ansi.italic("\"" <> source <> "\"\n") 405 + <> " is not a valid snapshot.\n" 406 + <> "This might happen when someone modifies its content.\n" 407 + <> "Try deleting the snapshot and recreating it." 408 + 409 + CannotFindProjectRoot(reason: reason) -> 410 + heading(reason) 411 + <> "I couldn't locate the project's root where the snapshot's" 412 + <> " folder should be." 413 + } 414 + 415 + io.println_error("❌ " <> ansi.red(message)) 416 + } 417 + 418 + type InfoLine { 419 + InfoLineWithTitle(content: String, split: Split, title: String) 420 + InfoLineWithNoTitle(content: String, split: Split) 421 + } 422 + 423 + type Split { 424 + DoNotSplit 425 + SplitWords 426 + Truncate 427 + } 428 + 429 + fn new_snapshot_box( 430 + snapshot: Snapshot(New), 431 + additional_info_lines: List(InfoLine), 432 + ) -> String { 433 + let Snapshot(title: title, content: content) = snapshot 434 + 435 + let content = 436 + string.split(content, on: "\n") 437 + |> list.index_map(fn(line, i) { 438 + DiffLine(number: i + 1, line: line, kind: diff.New) 439 + }) 440 + 441 + pretty_box("new snapshot", content, [ 442 + InfoLineWithTitle(title, SplitWords, "title"), 443 + ..additional_info_lines 444 + ]) 445 + } 446 + 447 + fn diff_snapshot_box( 448 + accepted: Snapshot(Accepted), 449 + new: Snapshot(New), 450 + additional_info_lines: List(InfoLine), 451 + ) -> String { 452 + pretty_box( 453 + "mismatched snapshots", 454 + to_diff_lines(accepted, new), 455 + [ 456 + [InfoLineWithTitle(new.title, SplitWords, "title")], 457 + additional_info_lines, 458 + [ 459 + InfoLineWithNoTitle("", DoNotSplit), 460 + InfoLineWithNoTitle(ansi.red("- old snapshot"), DoNotSplit), 461 + InfoLineWithNoTitle(ansi.green("+ new snapshot"), DoNotSplit), 462 + ], 463 + ] 464 + |> list.concat, 465 + ) 466 + } 467 + 468 + fn pretty_box( 469 + title: String, 470 + content_lines: List(DiffLine), 471 + info_lines: List(InfoLine), 472 + ) -> String { 473 + let width = terminal_width() 474 + let assert Ok(padding) = { 475 + let lines_count = list.length(content_lines) + 1 476 + use digits <- result.try(int.digits(lines_count, 10)) 477 + Ok(list.length(digits) * 2 + 5) 478 + } 479 + 480 + // Make the title line. 481 + let title_length = string.length(title) 482 + let title_line_right = string.repeat("─", width - 5 - title_length) 483 + let title_line = "── " <> title <> " ─" <> title_line_right 484 + 485 + // Make the pretty info lines. 486 + let info_lines = 487 + list.map(info_lines, pretty_info_line(_, width)) 488 + |> string.join("\n") 489 + 490 + // Add numbers to the content's lines. 491 + let content = 492 + list.map(content_lines, pretty_diff_line(_, padding)) 493 + |> string.join(with: "\n") 494 + 495 + // The open and closed delimiters for the box main content. 496 + let left_padding_line = string.repeat("─", padding) 497 + let right_padding_line = string.repeat("─", width - padding - 1) 498 + let open_line = left_padding_line <> "┬" <> right_padding_line 499 + let closed_line = left_padding_line <> "┴" <> right_padding_line 500 + 501 + // Assemble everything together with some empty lines to allow the content to 502 + // breath a little. 503 + [title_line, "", info_lines, "", open_line, content, closed_line] 504 + |> string.join(with: "\n") 505 + } 506 + 507 + fn pretty_info_line(line: InfoLine, width: Int) -> String { 508 + let title_length = case line { 509 + InfoLineWithNoTitle(..) -> 2 510 + InfoLineWithTitle(title: title, ..) -> string.length(title) 511 + } 512 + 513 + let line_doc = case line.split { 514 + DoNotSplit -> doc.from_string(line.content) 515 + SplitWords -> 516 + string.split(line.content, on: "\n") 517 + |> list.map(fn(line) { 518 + string.split(line, on: " ") 519 + |> list.map(doc.from_string) 520 + |> doc.join(with: doc.flex_space) 521 + }) 522 + |> doc.join(with: doc.line) 523 + |> doc.group 524 + |> doc.nest(by: title_length + 4) 525 + 526 + Truncate -> { 527 + let max_content_length = width - title_length - 6 528 + let content_length = string.length(line.content) 529 + case content_length > max_content_length { 530 + False -> doc.from_string(line.content) 531 + True -> 532 + string.to_graphemes(line.content) 533 + |> list.take(max_content_length - 3) 534 + |> string.join(with: "") 535 + |> string.append("...") 536 + |> doc.from_string 537 + } 538 + } 539 + } 540 + 541 + // This is an ugly hack that I need because `glam` currently doesn't take into 542 + // account color codes. 543 + // Those are invisible but still contribute to the length of a string, so I 544 + // have to artifically set the width to a higher limit to take into account 545 + // the length of the color codes added to the lines' titles. 546 + let ansi_code_len = 7 547 + 548 + case line { 549 + InfoLineWithNoTitle(..) -> doc.from_string(" ") 550 + InfoLineWithTitle(title: title, ..) -> 551 + doc.from_string(ansi.blue(" " <> title <> ": ")) 552 + } 553 + |> doc.append(line_doc) 554 + |> doc.to_string(width + ansi_code_len) 555 + } 556 + 557 + fn pretty_diff_line(diff_line: DiffLine, padding: Int) -> String { 558 + let DiffLine(number: number, line: line, kind: kind) = diff_line 559 + 560 + let #(pretty_number, pretty_line, separator) = case kind { 561 + diff.Shared -> #( 562 + int.to_string(number) 563 + |> string.pad_left(to: padding - 1, with: " ") 564 + |> ansi.dim, 565 + ansi.dim(line), 566 + " │ ", 567 + ) 568 + 569 + diff.New -> #( 570 + int.to_string(number) 571 + |> string.pad_left(to: padding - 1, with: " ") 572 + |> ansi.green 573 + |> ansi.bold, 574 + ansi.green(line), 575 + ansi.green(" + "), 576 + ) 577 + 578 + diff.Old -> { 579 + let number = 580 + { " " <> int.to_string(number) } 581 + |> string.pad_right(to: padding - 1, with: " ") 582 + #(ansi.red(number), ansi.red(line), ansi.red(" - ")) 583 + } 584 + } 585 + 586 + pretty_number <> separator <> pretty_line 587 + } 588 + 589 + // --- CLI COMMAND ------------------------------------------------------------- 590 + 591 + @deprecated("🚨 This is the entry point of the CLI tool. 592 + You should never call this function yourself, you should run `gleam run -m birdie` instead. 593 + Expect this function to disappear from the public API on future releases!") 594 + pub fn main() -> Nil { 595 + case argv.load().arguments { 596 + [] | ["review"] -> report_status(review()) 597 + ["accept-all"] | ["accept", "all"] -> report_status(accept_all()) 598 + ["reject-all"] | ["reject", "all"] -> report_status(reject_all()) 599 + ["help"] -> help() 600 + [subcommand] -> unexpected_subcommand(subcommand) 601 + subcommands -> more_than_one_command(subcommands) 602 + } 603 + } 604 + 605 + fn review() -> Result(Nil, Error) { 606 + use snapshots_folder <- result.try(find_snapshots_folder()) 607 + use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) 608 + case list.length(new_snapshots) { 609 + // If there's no snapshots to review, we're done! 610 + 0 -> { 611 + io.println("No new snapshots to review.") 612 + Ok(Nil) 613 + } 614 + // If there's snapshots to review start the interactive session. 615 + n -> { 616 + let result = do_review(new_snapshots, 1, n) 617 + // Despite the review process ending well or with an error, we want to 618 + // clear the screen of any garbage before showing the error explanation 619 + // or the happy completion string. 620 + // That's why we postpone the `result.try` step. 621 + clear() 622 + use _ <- result.try(result) 623 + // A nice message based on the number of snapshots :) 624 + io.println(case n { 625 + 1 -> "Reviewed one snapshot" 626 + n -> "Reviewed " <> int.to_string(n) <> " snapshots" 627 + }) 628 + Ok(Nil) 629 + } 630 + } 631 + } 632 + 633 + fn do_review( 634 + new_snapshot_paths: List(String), 635 + current: Int, 636 + out_of: Int, 637 + ) -> Result(Nil, Error) { 638 + case new_snapshot_paths { 639 + [] -> Ok(Nil) 640 + [new_snapshot_path, ..rest] -> { 641 + clear() 642 + // We try reading the new snapshot and the accepted one (which might be 643 + // missing). 644 + use new_snapshot <- result.try(read_new(new_snapshot_path)) 645 + let accepted_snapshot_path = to_accepted_path(new_snapshot_path) 646 + use accepted_snapshot <- result.try(read_accepted(accepted_snapshot_path)) 647 + 648 + let progress = 649 + ansi.dim("Reviewing ") 650 + <> ansi.bold(ansi.yellow(rank.ordinalise(current))) 651 + <> ansi.dim(" out of ") 652 + <> ansi.bold(ansi.yellow(int.to_string(out_of))) 653 + 654 + // If there's no accepted snapshot then we're just reviewing a new 655 + // snapshot. Otherwise we show a nice diff. 656 + let box = case accepted_snapshot { 657 + None -> new_snapshot_box(new_snapshot, []) 658 + Some(accepted_snapshot) -> 659 + diff_snapshot_box(accepted_snapshot, new_snapshot, []) 660 + } 661 + io.println(progress <> "\n\n" <> box <> "\n") 662 + 663 + // We ask the user what to do with this snapshot. 664 + use choice <- result.try(ask_choice()) 665 + use _ <- result.try(case choice { 666 + AcceptSnapshot -> accept_snapshot(new_snapshot_path) 667 + RejectSnapshot -> reject_snapshot(new_snapshot_path) 668 + SkipSnapshot -> Ok(Nil) 669 + }) 670 + 671 + // Let's keep going with the remaining snapshots. 672 + do_review(rest, current + 1, out_of) 673 + } 674 + } 675 + } 676 + 677 + /// The choice the user can make when reviewing a snapshot. 678 + /// 679 + type ReviewChoice { 680 + AcceptSnapshot 681 + RejectSnapshot 682 + SkipSnapshot 683 + } 684 + 685 + /// Asks the user to make a choice: it first prints a reminder of the options 686 + /// and waits for the user to choose one. 687 + /// Will prompt again if the choice is not amongst the possible options. 688 + /// 689 + fn ask_choice() -> Result(ReviewChoice, Error) { 690 + io.println( 691 + ansi.bold(ansi.green(" a")) 692 + <> " accept " 693 + <> ansi.dim("accept the new snapshot\n") 694 + <> ansi.bold(ansi.red(" r")) 695 + <> " reject " 696 + <> ansi.dim("reject the new snapshot\n") 697 + <> ansi.bold(ansi.yellow(" s")) 698 + <> " skip " 699 + <> ansi.dim("skip the snapshot for now\n"), 700 + ) 701 + // We clear the line of any possible garbage that might still be there from 702 + // a previous prompt of the same method. 703 + clear_line() 704 + case result.map(erlang.get_line("> "), string.trim) { 705 + Ok("a") -> Ok(AcceptSnapshot) 706 + Ok("r") -> Ok(RejectSnapshot) 707 + Ok("s") -> Ok(SkipSnapshot) 708 + // If the choice is not one of the proposed ones we move the cursor back to 709 + // the top of where it was and print everything once again, asking for a 710 + // valid option. 711 + Ok(_) -> { 712 + cursor_up(5) 713 + ask_choice() 714 + } 715 + Error(_) -> Error(CannotReadUserInput) 716 + } 717 + } 718 + 719 + fn accept_all() -> Result(Nil, Error) { 720 + io.println("Looking for new snapshots...") 721 + use snapshots_folder <- result.try(find_snapshots_folder()) 722 + use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) 723 + 724 + case list.length(new_snapshots) { 725 + 0 -> io.println("No new snapshots to accept.") 726 + 1 -> io.println("Accepting one new snapshot.") 727 + n -> io.println("Accepting " <> int.to_string(n) <> " new snapshots.") 728 + } 729 + 730 + list.try_each(new_snapshots, accept_snapshot) 731 + } 732 + 733 + fn reject_all() -> Result(Nil, Error) { 734 + io.println("Looking for new snapshots...") 735 + use snapshots_folder <- result.try(find_snapshots_folder()) 736 + use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) 737 + 738 + case list.length(new_snapshots) { 739 + 0 -> io.println("No new snapshots to reject.") 740 + 1 -> io.println("Rejecting one new snapshot.") 741 + n -> io.println("Rejecting " <> int.to_string(n) <> " new snapshots.") 742 + } 743 + 744 + list.try_each(new_snapshots, reject_snapshot) 745 + } 746 + 747 + fn help() -> Nil { 748 + let version = ansi.green("🐦‍⬛ birdie ") <> "v" <> birdie_version 749 + io.println(version <> "\n\n" <> help_text()) 750 + } 751 + 752 + fn help_text() -> String { 753 + ansi.yellow("USAGE:\n") 754 + <> " gleam run -m birdie [ <SUBCOMMAND> ]\n\n" 755 + <> ansi.yellow("SUBCOMMANDS:\n") 756 + <> ansi.green(" review ") 757 + <> "Review all new snapshots one by one\n" 758 + <> ansi.green(" accept-all ") 759 + <> "Accept all new snapshots\n" 760 + <> ansi.green(" reject-all ") 761 + <> "Reject all new snapshots\n" 762 + <> ansi.green(" help ") 763 + <> "Show this help text\n" 764 + } 765 + 766 + fn unexpected_subcommand(subcommand: String) -> Nil { 767 + let error_message = 768 + ansi.bold("Error: ") <> "\"" <> subcommand <> "\" isn't a valid subcommand." 769 + 770 + io.println(ansi.red(error_message) <> "\n\n" <> help_text()) 771 + } 772 + 773 + fn more_than_one_command(subcommands: List(String)) -> Nil { 774 + let error_message = 775 + ansi.bold("Error: ") 776 + <> "I can only run one subcommand at a time, but more than one were provided: " 777 + <> string.join(list.map(subcommands, fn(s) { "\"" <> s <> "\"" }), ", ") 778 + 779 + io.println(ansi.red(error_message) <> "\n\n" <> help_text()) 780 + } 781 + 782 + fn report_status(result: Result(Nil, Error)) -> Nil { 783 + case result { 784 + Ok(Nil) -> io.println(ansi.green("🐦‍⬛ Done!")) 785 + Error(error) -> explain(error) 786 + } 787 + } 788 + 789 + // --- FFI --------------------------------------------------------------------- 790 + 791 + /// Clear the screen. 792 + /// 793 + @external(erlang, "birdie_ffi_erl", "clear") 794 + fn clear() -> Nil 795 + 796 + /// Move the cursor up a given number of lines. 797 + /// 798 + @external(erlang, "birdie_ffi_erl", "cursor_up") 799 + fn cursor_up(n: Int) -> Nil 800 + 801 + /// Clear the line the cursor is currently on. 802 + /// 803 + @external(erlang, "birdie_ffi_erl", "clear_line") 804 + fn clear_line() -> Nil 805 + 806 + fn terminal_width() -> Int { 807 + result.unwrap(do_terminal_width(), or: 80) 808 + } 809 + 810 + @external(erlang, "birdie_ffi_erl", "terminal_width") 811 + @external(javascript, "./birdie_ffi_js.mjs", "terminal_width") 812 + fn do_terminal_width() -> Result(Int, Nil) { 813 + // We have a default implementation that will fail on all other targets so 814 + // that it can be unwrapped to a default value and we stay compatible with 815 + // all future Gleam's targets. 816 + Error(Nil) 817 + }
+74
src/birdie/internal/diff.gleam
··· 1 + import gleam/bool 2 + import gleam/list 3 + import gleam/string 4 + import gleam_community/ansi 5 + import gap 6 + import gap/styling 7 + 8 + pub type DiffLine { 9 + DiffLine(number: Int, line: String, kind: DiffLineKind) 10 + } 11 + 12 + pub type DiffLineKind { 13 + Old 14 + New 15 + Shared 16 + } 17 + 18 + pub fn line_by_line(old: String, new: String) -> List(DiffLine) { 19 + let old_lines = string.split(old, on: "\n") 20 + let new_lines = string.split(new, on: "\n") 21 + let #(diffs, rest_old, rest_new, line_number) = { 22 + use old_line, new_line, line <- map2_index(old_lines, new_lines) 23 + let equal_lines = old_line == new_line 24 + let shared_diff_line = DiffLine(line, old_line, Shared) 25 + use <- bool.guard(when: equal_lines, return: [shared_diff_line]) 26 + 27 + let comparison = 28 + gap.compare_strings(new_line, old_line) 29 + |> styling.from_comparison() 30 + |> styling.highlight(ansi.underline, ansi.underline, styling.no_highlight) 31 + |> styling.to_styled_comparison() 32 + 33 + [ 34 + DiffLine(line, comparison.second, Old), 35 + DiffLine(line, comparison.first, New), 36 + ] 37 + } 38 + 39 + list.concat(diffs) 40 + |> list.append( 41 + list.index_map(rest_old, fn(line, i) { 42 + DiffLine(line_number + i + 1, line, Old) 43 + }), 44 + ) 45 + |> list.append( 46 + list.index_map(rest_new, fn(line, i) { 47 + DiffLine(line_number + i + 1, line, New) 48 + }), 49 + ) 50 + } 51 + 52 + fn map2_index( 53 + one: List(a), 54 + other: List(b), 55 + with fun: fn(a, b, Int) -> c, 56 + ) -> #(List(c), List(a), List(b), Int) { 57 + do_map2_index(one, other, fun, [], 1) 58 + } 59 + 60 + fn do_map2_index( 61 + one: List(a), 62 + other: List(b), 63 + fun: fn(a, b, Int) -> c, 64 + acc: List(c), 65 + index: Int, 66 + ) -> #(List(c), List(a), List(b), Int) { 67 + case one, other { 68 + rest_one, [] -> #(list.reverse(acc), rest_one, [], index) 69 + [], rest_other -> #(list.reverse(acc), [], rest_other, index) 70 + [one, ..rest_one], [other, ..rest_other] -> 71 + [fun(one, other, index), ..acc] 72 + |> do_map2_index(rest_one, rest_other, fun, _, index + 1) 73 + } 74 + }
+18
src/birdie_ffi_erl.erl
··· 1 + -module(birdie_ffi_erl). 2 + -export([terminal_width/0, clear/0, cursor_up/1, clear_line/0]). 3 + 4 + terminal_width() -> 5 + case io:columns(user) of 6 + {ok, Width} -> {ok, Width}; 7 + {error, _} -> {error, nil} 8 + end. 9 + 10 + clear() -> 11 + io:format("\ec"), 12 + io:format("\e[H\e[J"). 13 + 14 + cursor_up(X) -> 15 + io:format("\x1b[" ++ integer_to_list(X) ++ "A"). 16 + 17 + clear_line() -> 18 + io:format("\x1b[2K").
+17
src/birdie_ffi_js.mjs
··· 1 + import { Error, Ok } from "./gleam.mjs"; 2 + 3 + export function terminal_width() { 4 + // Node 5 + try { 6 + const width = process.stdout.columns; 7 + if (width) return new Ok(width); 8 + } catch { } 9 + 10 + // Deno 11 + try { 12 + const { columns: width, rows: _ } = Deno.consoleSize(); 13 + return new Ok(width); 14 + } catch { } 15 + 16 + return new Error(null); 17 + }
+22
test/birdie_test.gleam
··· 1 + import gleam/string 2 + import gleeunit 3 + import birdie 4 + 5 + pub fn main() { 6 + gleeunit.main() 7 + } 8 + 9 + pub fn hello_birdie_test() { 10 + "🐦‍⬛ smile for the birdie!" 11 + |> birdie.snap(title: "my first snapshot") 12 + } 13 + 14 + pub fn a_result_test() { 15 + string.inspect(Ok(11)) 16 + |> birdie.snap(title: "my favourite number wrapped in a result") 17 + } 18 + 19 + pub fn list_test() { 20 + "[ 1, 2, 3, 4 ]" 21 + |> birdie.snap(title: "snapping a list of numbers") 22 + }