···11+ Apache License
22+ Version 2.0, January 2004
33+ http://www.apache.org/licenses/
44+55+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
66+77+ 1. Definitions.
88+99+ "License" shall mean the terms and conditions for use, reproduction,
1010+ and distribution as defined by Sections 1 through 9 of this document.
1111+1212+ "Licensor" shall mean the copyright owner or entity authorized by
1313+ the copyright owner that is granting the License.
1414+1515+ "Legal Entity" shall mean the union of the acting entity and all
1616+ other entities that control, are controlled by, or are under common
1717+ control with that entity. For the purposes of this definition,
1818+ "control" means (i) the power, direct or indirect, to cause the
1919+ direction or management of such entity, whether by contract or
2020+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
2121+ outstanding shares, or (iii) beneficial ownership of such entity.
2222+2323+ "You" (or "Your") shall mean an individual or Legal Entity
2424+ exercising permissions granted by this License.
2525+2626+ "Source" form shall mean the preferred form for making modifications,
2727+ including but not limited to software source code, documentation
2828+ source, and configuration files.
2929+3030+ "Object" form shall mean any form resulting from mechanical
3131+ transformation or translation of a Source form, including but
3232+ not limited to compiled object code, generated documentation,
3333+ and conversions to other media types.
3434+3535+ "Work" shall mean the work of authorship, whether in Source or
3636+ Object form, made available under the License, as indicated by a
3737+ copyright notice that is included in or attached to the work
3838+ (an example is provided in the Appendix below).
3939+4040+ "Derivative Works" shall mean any work, whether in Source or Object
4141+ form, that is based on (or derived from) the Work and for which the
4242+ editorial revisions, annotations, elaborations, or other modifications
4343+ represent, as a whole, an original work of authorship. For the purposes
4444+ of this License, Derivative Works shall not include works that remain
4545+ separable from, or merely link (or bind by name) to the interfaces of,
4646+ the Work and Derivative Works thereof.
4747+4848+ "Contribution" shall mean any work of authorship, including
4949+ the original version of the Work and any modifications or additions
5050+ to that Work or Derivative Works thereof, that is intentionally
5151+ submitted to Licensor for inclusion in the Work by the copyright owner
5252+ or by an individual or Legal Entity authorized to submit on behalf of
5353+ the copyright owner. For the purposes of this definition, "submitted"
5454+ means any form of electronic, verbal, or written communication sent
5555+ to the Licensor or its representatives, including but not limited to
5656+ communication on electronic mailing lists, source code control systems,
5757+ and issue tracking systems that are managed by, or on behalf of, the
5858+ Licensor for the purpose of discussing and improving the Work, but
5959+ excluding communication that is conspicuously marked or otherwise
6060+ designated in writing by the copyright owner as "Not a Contribution."
6161+6262+ "Contributor" shall mean Licensor and any individual or Legal Entity
6363+ on behalf of whom a Contribution has been received by Licensor and
6464+ subsequently incorporated within the Work.
6565+6666+ 2. Grant of Copyright License. Subject to the terms and conditions of
6767+ this License, each Contributor hereby grants to You a perpetual,
6868+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
6969+ copyright license to reproduce, prepare Derivative Works of,
7070+ publicly display, publicly perform, sublicense, and distribute the
7171+ Work and such Derivative Works in Source or Object form.
7272+7373+ 3. Grant of Patent License. Subject to the terms and conditions of
7474+ this License, each Contributor hereby grants to You a perpetual,
7575+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
7676+ (except as stated in this section) patent license to make, have made,
7777+ use, offer to sell, sell, import, and otherwise transfer the Work,
7878+ where such license applies only to those patent claims licensable
7979+ by such Contributor that are necessarily infringed by their
8080+ Contribution(s) alone or by combination of their Contribution(s)
8181+ with the Work to which such Contribution(s) was submitted. If You
8282+ institute patent litigation against any entity (including a
8383+ cross-claim or counterclaim in a lawsuit) alleging that the Work
8484+ or a Contribution incorporated within the Work constitutes direct
8585+ or contributory patent infringement, then any patent licenses
8686+ granted to You under this License for that Work shall terminate
8787+ as of the date such litigation is filed.
8888+8989+ 4. Redistribution. You may reproduce and distribute copies of the
9090+ Work or Derivative Works thereof in any medium, with or without
9191+ modifications, and in Source or Object form, provided that You
9292+ meet the following conditions:
9393+9494+ (a) You must give any other recipients of the Work or
9595+ Derivative Works a copy of this License; and
9696+9797+ (b) You must cause any modified files to carry prominent notices
9898+ stating that You changed the files; and
9999+100100+ (c) You must retain, in the Source form of any Derivative Works
101101+ that You distribute, all copyright, patent, trademark, and
102102+ attribution notices from the Source form of the Work,
103103+ excluding those notices that do not pertain to any part of
104104+ the Derivative Works; and
105105+106106+ (d) If the Work includes a "NOTICE" text file as part of its
107107+ distribution, then any Derivative Works that You distribute must
108108+ include a readable copy of the attribution notices contained
109109+ within such NOTICE file, excluding those notices that do not
110110+ pertain to any part of the Derivative Works, in at least one
111111+ of the following places: within a NOTICE text file distributed
112112+ as part of the Derivative Works; within the Source form or
113113+ documentation, if provided along with the Derivative Works; or,
114114+ within a display generated by the Derivative Works, if and
115115+ wherever such third-party notices normally appear. The contents
116116+ of the NOTICE file are for informational purposes only and
117117+ do not modify the License. You may add Your own attribution
118118+ notices within Derivative Works that You distribute, alongside
119119+ or as an addendum to the NOTICE text from the Work, provided
120120+ that such additional attribution notices cannot be construed
121121+ as modifying the License.
122122+123123+ You may add Your own copyright statement to Your modifications and
124124+ may provide additional or different license terms and conditions
125125+ for use, reproduction, or distribution of Your modifications, or
126126+ for any such Derivative Works as a whole, provided Your use,
127127+ reproduction, and distribution of the Work otherwise complies with
128128+ the conditions stated in this License.
129129+130130+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131131+ any Contribution intentionally submitted for inclusion in the Work
132132+ by You to the Licensor shall be under the terms and conditions of
133133+ this License, without any additional terms or conditions.
134134+ Notwithstanding the above, nothing herein shall supersede or modify
135135+ the terms of any separate license agreement you may have executed
136136+ with Licensor regarding such Contributions.
137137+138138+ 6. Trademarks. This License does not grant permission to use the trade
139139+ names, trademarks, service marks, or product names of the Licensor,
140140+ except as required for reasonable and customary use in describing the
141141+ origin of the Work and reproducing the content of the NOTICE file.
142142+143143+ 7. Disclaimer of Warranty. Unless required by applicable law or
144144+ agreed to in writing, Licensor provides the Work (and each
145145+ Contributor provides its Contributions) on an "AS IS" BASIS,
146146+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147147+ implied, including, without limitation, any warranties or conditions
148148+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149149+ PARTICULAR PURPOSE. You are solely responsible for determining the
150150+ appropriateness of using or redistributing the Work and assume any
151151+ risks associated with Your exercise of permissions under this License.
152152+153153+ 8. Limitation of Liability. In no event and under no legal theory,
154154+ whether in tort (including negligence), contract, or otherwise,
155155+ unless required by applicable law (such as deliberate and grossly
156156+ negligent acts) or agreed to in writing, shall any Contributor be
157157+ liable to You for damages, including any direct, indirect, special,
158158+ incidental, or consequential damages of any character arising as a
159159+ result of this License or out of the use or inability to use the
160160+ Work (including but not limited to damages for loss of goodwill,
161161+ work stoppage, computer failure or malfunction, or any and all
162162+ other commercial damages or losses), even if such Contributor
163163+ has been advised of the possibility of such damages.
164164+165165+ 9. Accepting Warranty or Additional Liability. While redistributing
166166+ the Work or Derivative Works thereof, You may choose to offer,
167167+ and charge a fee for, acceptance of support, warranty, indemnity,
168168+ or other liability obligations and/or rights consistent with this
169169+ License. However, in accepting such obligations, You may act only
170170+ on Your own behalf and on Your sole responsibility, not on behalf
171171+ of any other Contributor, and only if You agree to indemnify,
172172+ defend, and hold each Contributor harmless for any liability
173173+ incurred by, or claims asserted against, such Contributor by reason
174174+ of your accepting any such warranty or additional liability.
175175+176176+ END OF TERMS AND CONDITIONS
177177+178178+ APPENDIX: How to apply the Apache License to your work.
179179+180180+ To apply the Apache License to your work, attach the following
181181+ boilerplate notice, with the fields enclosed by brackets "[]"
182182+ replaced with your own identifying information. (Don't include
183183+ the brackets!) The text should be enclosed in the appropriate
184184+ comment syntax for the file format. We also recommend that a
185185+ file or class name and description of purpose be included on the
186186+ same "printed page" as the copyright notice for easier
187187+ identification within third-party archives.
188188+189189+ Copyright [yyyy] [name of copyright owner]
190190+191191+ Licensed under the Apache License, Version 2.0 (the "License");
192192+ you may not use this file except in compliance with the License.
193193+ You may obtain a copy of the License at
194194+195195+ http://www.apache.org/licenses/LICENSE-2.0
196196+197197+ Unless required by applicable law or agreed to in writing, software
198198+ distributed under the License is distributed on an "AS IS" BASIS,
199199+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200200+ See the License for the specific language governing permissions and
201201+ limitations under the License.
···11+# 🐦⬛ Birdie - snapshot testing in Gleam
22+33+[](https://hex.pm/packages/birdie)
44+[](https://hexdocs.pm/birdie/)
55+
66+77+Snapshot testing allows you to perform assertions without having to write the
88+expectation yourself. Birdie will store a snapshot of the expected value and
99+compare future runs of the same test against it. Imagine doing a
1010+`should.equal(expected, got)` where you don't have to take care of writing the
1111+expected output.
1212+1313+## Writing snapshot tests with Birdie
1414+1515+First you'll want to add the package to your dependencies:
1616+1717+```sh
1818+gleam add --dev birdie
1919+```
2020+2121+To write snapshot tests you can import the `birdie` module and use the
2222+[`snap`](https://hexdocs.pm/birdie/birdie.html#snap) function:
2323+2424+```gleam
2525+import gleeunit
2626+import birdie
2727+2828+pub fn main() {
2929+ gleeunit.main()
3030+}
3131+3232+pub fn hello_birdie_test() {
3333+ "🐦⬛ Smile for the birdie!"
3434+ |> birdie.snap(title: "my first snapshot")
3535+}
3636+```
3737+3838+This will record a new snapshot with the given title and content. A snapshot
3939+test will always fail on its first run until you review and accept it.
4040+Once you've reviewed and accepted a snapshot, the test will fail only if the
4141+snapshot's content changes; in that case you will be presented with a diff and
4242+asked to review it once again.
4343+4444+A typical workflow will look like this:
4545+4646+- Run your tests
4747+- If you have any new snapshots - or some of the snapshots have changed - some
4848+ tests will fail
4949+- Review all the new snapshots deciding if you want to keep the new version or
5050+ the previously accepted one
5151+- And don't forget to commit your snapshots! Those should be treated like
5252+ code and checked with the vcs you're using
5353+5454+## Reviewing snapshots
5555+5656+Birdie also provides a CLI tool to help you in the review process: run
5757+`gleam run -m birdie` in your project and birdie will help you interactively
5858+review all your new snapshots.
5959+6060+> The CLI tool can also do more than just guide you through all your snapshots
6161+> one by one. To check all the available options you can run
6262+> `gleam run -m birdie help`
6363+6464+
6565+6666+## References
6767+6868+This package was heavily inspired by the excellent Rust library
6969+[`insta`](https://insta.rs), do check it out!
7070+7171+## Contributing
7272+7373+If you think there's any way to improve this package, or if you spot a bug don't
7474+be afraid to open PRs, issues or requests of any kind!
7575+Any contribution is welcome 💜
···11+import gleam/bool
22+import gleam/erlang
33+import gleam/int
44+import gleam/io
55+import gleam/list
66+import gleam/option.{type Option, None, Some}
77+import gleam/result
88+import gleam/string
99+import gleam_community/ansi
1010+import argv
1111+import birdie/internal/diff.{type DiffLine, DiffLine}
1212+import filepath
1313+import glam/doc
1414+import gleeunit/should
1515+import justin
1616+import rank
1717+import simplifile
1818+1919+const birdie_version = "1.0.0"
2020+2121+const birdie_snapshots_folder = "birdie_snapshots"
2222+2323+const birdie_test_failed_message = "🐦⬛ Birdie snapshot test failed"
2424+2525+const hint_review_message = "run `gleam run -m birdie` to review the snapshots"
2626+2727+type Error {
2828+ CannotCreateSnapshotsFolder(reason: simplifile.FileError)
2929+3030+ CannotReadAcceptedSnapshot(reason: simplifile.FileError, source: String)
3131+3232+ CannotReadNewSnapshot(reason: simplifile.FileError, source: String)
3333+3434+ CannotSaveNewSnapshot(
3535+ reason: simplifile.FileError,
3636+ title: String,
3737+ destination: String,
3838+ )
3939+4040+ CannotReadSnapshots(reason: simplifile.FileError, folder: String)
4141+4242+ CannotRejectSnapshot(reason: simplifile.FileError, snapshot: String)
4343+4444+ CannotAcceptSnapshot(reason: simplifile.FileError, snapshot: String)
4545+4646+ CannotReadUserInput
4747+4848+ CorruptedSnapshot(source: String)
4949+5050+ CannotFindProjectRoot(reason: simplifile.FileError)
5151+}
5252+5353+// --- THE SNAPSHOT TYPE -------------------------------------------------------
5454+5555+type New
5656+5757+type Accepted
5858+5959+type Snapshot(status) {
6060+ Snapshot(title: String, content: String)
6161+}
6262+6363+// --- SNAP --------------------------------------------------------------------
6464+6565+/// Performs a snapshot test with the given title, saving the content to a new
6666+/// snapshot file. All your snapshots will be stored in a folder called
6767+/// `birdie_snapshots` in the project's root.
6868+///
6969+/// The test will fail if there already is an accepted snapshot with the same
7070+/// title and a different content.
7171+/// The test will also fail if there's no accepted snapshot with the same title
7272+/// to make sure you will review new snapshots as well.
7373+///
7474+/// > To review all your snapshots interactively you can run
7575+/// > `gleam run -m birdie`.
7676+/// >
7777+/// > To get an help text and all the available options you can run
7878+/// > `gleam run -m birdie help`.
7979+///
8080+pub fn snap(content content: String, title title: String) -> Nil {
8181+ case do_snap(content, title) {
8282+ Ok(Same) -> Nil
8383+8484+ Ok(NewSnapshotCreated(snapshot, destination: _)) -> {
8585+ let hint_message = ansi.yellow(hint_review_message)
8686+ let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint")
8787+ let box = new_snapshot_box(snapshot, [hint])
8888+8989+ io.println_error("\n\n" <> box <> "\n")
9090+ io.println(birdie_test_failed_message)
9191+ should.fail()
9292+ }
9393+9494+ Ok(Different(accepted, new)) -> {
9595+ let hint_message = ansi.yellow(hint_review_message)
9696+ let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint")
9797+ let box = diff_snapshot_box(accepted, new, [hint])
9898+9999+ io.println_error("\n\n" <> box <> "\n")
100100+ io.println(birdie_test_failed_message)
101101+ should.fail()
102102+ }
103103+104104+ Error(error) -> {
105105+ explain(error)
106106+ io.println(birdie_test_failed_message)
107107+ should.fail()
108108+ }
109109+ }
110110+}
111111+112112+type Outcome {
113113+ NewSnapshotCreated(snapshot: Snapshot(New), destination: String)
114114+ Different(accepted: Snapshot(Accepted), new: Snapshot(New))
115115+ Same
116116+}
117117+118118+fn do_snap(content: String, title: String) -> Result(Outcome, Error) {
119119+ // We have to find the snapshot folder since the `gleam test` command might
120120+ // be run from any subfolder we can't just assume we're in the project's root.
121121+ use folder <- result.try(find_snapshots_folder())
122122+123123+ let new = Snapshot(title: title, content: content)
124124+ let new_snapshot_path = new_destination(new, folder)
125125+ let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
126126+127127+ // Find an accepted snapshot with the same title to make a comparison.
128128+ use accepted <- result.try(read_accepted(accepted_snapshot_path))
129129+ case accepted {
130130+ // If there's no accepted snapshot then we save the new one as there's no
131131+ // comparison to be made.
132132+ None -> {
133133+ use _ <- result.try(save(new, to: new_snapshot_path))
134134+ Ok(NewSnapshotCreated(snapshot: new, destination: new_snapshot_path))
135135+ }
136136+137137+ // If there's a corresponding accepted snapshot we compare it with the new
138138+ // one.
139139+ Some(accepted) -> {
140140+ // If the new snapshot is the same as the old one then there's no need to
141141+ // save it in a `.new` file: we can just say they are the same.
142142+ use <- bool.guard(when: accepted.content == new.content, return: Ok(Same))
143143+ use _ <- result.try(save(new, to: new_snapshot_path))
144144+ Ok(Different(accepted, new))
145145+ }
146146+ }
147147+}
148148+149149+// --- SNAPSHOT CONTENT DIFFING ------------------------------------------------
150150+151151+fn to_diff_lines(
152152+ accepted: Snapshot(Accepted),
153153+ new: Snapshot(New),
154154+) -> List(DiffLine) {
155155+ let Snapshot(title: _, content: accepted_content) = accepted
156156+ let Snapshot(title: _, content: new_content) = new
157157+ diff.line_by_line(accepted_content, new_content)
158158+}
159159+160160+// --- SNAPSHOT (DE)SERIALISATION ----------------------------------------------
161161+162162+fn deserialise(raw: String) -> Result(Snapshot(a), Nil) {
163163+ // Check there's the opening `---`
164164+ use #(open_line, rest) <- result.try(string.split_once(raw, "\n"))
165165+ use <- bool.guard(when: open_line != "---", return: Error(Nil))
166166+167167+ // For now I have no use of the version but it might come in handy in the
168168+ // future if I decide to change the snapshots' metadata's format.
169169+ use #(version_line, rest) <- result.try(string.split_once(rest, "\n"))
170170+ use _version <- result.try(case version_line {
171171+ "version: " <> version -> Ok(version)
172172+ _ -> Error(Nil)
173173+ })
174174+175175+ // Get the title.
176176+ use #(title_line, rest) <- result.try(string.split_once(rest, "\n"))
177177+ use title <- result.try(case title_line {
178178+ // We unescape the newlines
179179+ "title: " <> title -> Ok(string.replace(title, each: "\\n", with: "\n"))
180180+ _ -> Error(Nil)
181181+ })
182182+183183+ // Check there's the closing `---`
184184+ use #(close_line, content) <- result.try(string.split_once(rest, "\n"))
185185+ use <- bool.guard(when: close_line != "---", return: Error(Nil))
186186+187187+ Ok(Snapshot(title: title, content: content))
188188+}
189189+190190+fn serialise(snapshot: Snapshot(New)) -> String {
191191+ let Snapshot(title: title, content: content) = snapshot
192192+ [
193193+ "---",
194194+ "version: " <> birdie_version,
195195+ // We escape the newlines in the title so that it fits on one line and it's
196196+ // easier to parse.
197197+ // Is this the best course of action? Probably not.
198198+ // Does this make my life a lot easier? Absolutely! 😁
199199+ "title: " <> string.replace(title, each: "\n", with: "\\n"),
200200+ "---",
201201+ content,
202202+ ]
203203+ |> string.join(with: "\n")
204204+}
205205+206206+// --- FILE SYSTEM OPERATIONS --------------------------------------------------
207207+208208+/// Save a new snapshot to a given path.
209209+///
210210+fn save(snapshot: Snapshot(New), to destination: String) -> Result(Nil, Error) {
211211+ // Just to make sure I'm not messing up something anywhere else in the code
212212+ // base: a new snapshot's destination MUST always end with a `.new` extension.
213213+ // If it doesn't there's a fatal error in my code and I should fix it.
214214+ case string.ends_with(destination, ".new") {
215215+ False ->
216216+ panic as "Looks like I've messed up something, all new snapshots should have the `.new` extension"
217217+218218+ True ->
219219+ simplifile.write(to: destination, contents: serialise(snapshot))
220220+ |> result.map_error(CannotSaveNewSnapshot(
221221+ reason: _,
222222+ title: snapshot.title,
223223+ destination: destination,
224224+ ))
225225+ }
226226+}
227227+228228+/// Read an accepted snapshot which might be missing.
229229+///
230230+fn read_accepted(source: String) -> Result(Option(Snapshot(Accepted)), Error) {
231231+ case simplifile.read(source) {
232232+ Ok(content) ->
233233+ case deserialise(content) {
234234+ Ok(snapshot) -> Ok(Some(snapshot))
235235+ Error(Nil) -> Error(CorruptedSnapshot(source))
236236+ }
237237+238238+ Error(simplifile.Enoent) -> Ok(None)
239239+ Error(reason) ->
240240+ Error(CannotReadAcceptedSnapshot(reason: reason, source: source))
241241+ }
242242+}
243243+244244+/// Read a new snapshot.
245245+///
246246+/// > ℹ️ Notice the different return type compared to `read_accepted`: when we
247247+/// > try to read a new snapshot we are sure it's there (because we've listed
248248+/// > the directory or something else) so if it's not present that's an error
249249+/// > and we don't return an `Ok(None)`.
250250+///
251251+fn read_new(source: String) -> Result(Snapshot(New), Error) {
252252+ case simplifile.read(source) {
253253+ Ok(content) ->
254254+ result.replace_error(deserialise(content), CorruptedSnapshot(source))
255255+ Error(reason) ->
256256+ Error(CannotReadNewSnapshot(reason: reason, source: source))
257257+ }
258258+}
259259+260260+/// List all the new snapshots in a folder. Every file is automatically
261261+/// prepended with the folder so you get the full path of each file.
262262+///
263263+fn list_new_snapshots(in folder: String) -> Result(List(String), Error) {
264264+ case simplifile.read_directory(folder) {
265265+ Error(reason) -> Error(CannotReadSnapshots(reason: reason, folder: folder))
266266+ Ok(files) ->
267267+ Ok({
268268+ use file <- list.filter_map(files)
269269+ case filepath.extension(file) {
270270+ // Only keep the files with the ".new" extension and join their name
271271+ // with the folder's path.
272272+ Ok("new") -> Ok(filepath.join(folder, file))
273273+ _ -> Error(Nil)
274274+ }
275275+ })
276276+ }
277277+}
278278+279279+/// Finds the snapshots folder at the root of the project the command is run
280280+/// into. If it's not present the folder is created automatically.
281281+///
282282+fn find_snapshots_folder() -> Result(String, Error) {
283283+ let result = result.map_error(find_project_root("."), CannotFindProjectRoot)
284284+ use project_root <- result.try(result)
285285+ let snapshots_folder = filepath.join(project_root, birdie_snapshots_folder)
286286+287287+ case simplifile.create_directory(snapshots_folder) {
288288+ Ok(Nil) | Error(simplifile.Eexist) -> Ok(snapshots_folder)
289289+ Error(error) -> Error(CannotCreateSnapshotsFolder(error))
290290+ }
291291+}
292292+293293+/// Returns the path to the project's root.
294294+///
295295+/// > ⚠️ This assumes that this is only ever run inside a Gleam's project and
296296+/// > sooner or later it will reach a `gleam.toml` file.
297297+/// > Otherwise this will end up in an infinite loop, I think.
298298+///
299299+fn find_project_root(path: String) -> Result(String, simplifile.FileError) {
300300+ let manifest = filepath.join(path, "gleam.toml")
301301+ case simplifile.verify_is_file(manifest) {
302302+ Ok(True) -> Ok(path)
303303+ Ok(False) -> find_project_root(filepath.join(path, ".."))
304304+ Error(reason) -> Error(reason)
305305+ }
306306+}
307307+308308+fn accept_snapshot(new_snapshot_path: String) -> Result(Nil, Error) {
309309+ let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
310310+ simplifile.rename_file(new_snapshot_path, accepted_snapshot_path)
311311+ |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path))
312312+}
313313+314314+fn reject_snapshot(new_snapshot_path: String) -> Result(Nil, Error) {
315315+ simplifile.delete(new_snapshot_path)
316316+ |> result.map_error(CannotRejectSnapshot(_, new_snapshot_path))
317317+}
318318+319319+// --- UTILITIES ---------------------------------------------------------------
320320+321321+/// Turns a snapshot's title into a file name stripping it of all dangerous
322322+/// characters (or at least those I could think ok 😁).
323323+///
324324+fn file_name(title: String) -> String {
325325+ string.replace(each: "/", with: " ", in: title)
326326+ |> string.replace(each: "\\", with: " ")
327327+ |> string.replace(each: "\n", with: " ")
328328+ |> string.replace(each: "\t", with: " ")
329329+ |> string.replace(each: "\r", with: " ")
330330+ |> string.replace(each: ".", with: " ")
331331+ |> string.replace(each: ":", with: " ")
332332+ |> justin.snake_case
333333+}
334334+335335+/// Returns the path where a new snapshot should be saved.
336336+///
337337+fn new_destination(snapshot: Snapshot(New), folder: String) -> String {
338338+ filepath.join(folder, file_name(snapshot.title)) <> ".new"
339339+}
340340+341341+/// Strips the extension of a file (if it has one).
342342+///
343343+fn strip_extension(file: String) -> String {
344344+ case filepath.extension(file) {
345345+ Ok(extension) -> string.drop_right(file, string.length(extension) + 1)
346346+ Error(Nil) -> file
347347+ }
348348+}
349349+350350+/// Turns a new snapshot path into the path of the corresponding accepted
351351+/// snapshot.
352352+///
353353+fn to_accepted_path(file: String) -> String {
354354+ // This just replaces the `.new` extension with the `.accepted` one.
355355+ strip_extension(file) <> ".accepted"
356356+}
357357+358358+// --- PRETTY PRINTING ---------------------------------------------------------
359359+360360+fn explain(error: Error) -> Nil {
361361+ let heading = fn(reason) { "[" <> ansi.bold(string.inspect(reason)) <> "] " }
362362+ let message = case error {
363363+ CannotCreateSnapshotsFolder(reason: reason) ->
364364+ heading(reason) <> "I couldn't create the snapshots folder"
365365+366366+ CannotReadAcceptedSnapshot(reason: reason, source: source) ->
367367+ heading(reason)
368368+ <> "I couldn't read the accepted snapshot from "
369369+ <> ansi.italic("\"" <> source <> "\"\n")
370370+371371+ CannotReadNewSnapshot(reason: reason, source: source) ->
372372+ heading(reason)
373373+ <> "I couldn't read the new snapshot from "
374374+ <> ansi.italic("\"" <> source <> "\"\n")
375375+376376+ CannotSaveNewSnapshot(
377377+ reason: reason,
378378+ title: title,
379379+ destination: destination,
380380+ ) ->
381381+ heading(reason)
382382+ <> "I couldn't save the snapshot "
383383+ <> ansi.italic("\"" <> title <> "\" ")
384384+ <> "to "
385385+ <> ansi.italic("\"" <> destination <> "\"\n")
386386+387387+ CannotReadSnapshots(reason: reason, folder: _) ->
388388+ heading(reason) <> "I couldn't read the snapshots directory's contents"
389389+390390+ CannotRejectSnapshot(reason: reason, snapshot: snapshot) ->
391391+ heading(reason)
392392+ <> "I couldn't reject the snapshot"
393393+ <> ansi.italic("\"" <> snapshot <> "\" ")
394394+395395+ CannotAcceptSnapshot(reason: reason, snapshot: snapshot) ->
396396+ heading(reason)
397397+ <> "I couldn't accept the snapshot"
398398+ <> ansi.italic("\"" <> snapshot <> "\" ")
399399+400400+ CannotReadUserInput -> "I couldn't read the user input"
401401+402402+ CorruptedSnapshot(source: source) ->
403403+ "It looks like "
404404+ <> ansi.italic("\"" <> source <> "\"\n")
405405+ <> " is not a valid snapshot.\n"
406406+ <> "This might happen when someone modifies its content.\n"
407407+ <> "Try deleting the snapshot and recreating it."
408408+409409+ CannotFindProjectRoot(reason: reason) ->
410410+ heading(reason)
411411+ <> "I couldn't locate the project's root where the snapshot's"
412412+ <> " folder should be."
413413+ }
414414+415415+ io.println_error("❌ " <> ansi.red(message))
416416+}
417417+418418+type InfoLine {
419419+ InfoLineWithTitle(content: String, split: Split, title: String)
420420+ InfoLineWithNoTitle(content: String, split: Split)
421421+}
422422+423423+type Split {
424424+ DoNotSplit
425425+ SplitWords
426426+ Truncate
427427+}
428428+429429+fn new_snapshot_box(
430430+ snapshot: Snapshot(New),
431431+ additional_info_lines: List(InfoLine),
432432+) -> String {
433433+ let Snapshot(title: title, content: content) = snapshot
434434+435435+ let content =
436436+ string.split(content, on: "\n")
437437+ |> list.index_map(fn(line, i) {
438438+ DiffLine(number: i + 1, line: line, kind: diff.New)
439439+ })
440440+441441+ pretty_box("new snapshot", content, [
442442+ InfoLineWithTitle(title, SplitWords, "title"),
443443+ ..additional_info_lines
444444+ ])
445445+}
446446+447447+fn diff_snapshot_box(
448448+ accepted: Snapshot(Accepted),
449449+ new: Snapshot(New),
450450+ additional_info_lines: List(InfoLine),
451451+) -> String {
452452+ pretty_box(
453453+ "mismatched snapshots",
454454+ to_diff_lines(accepted, new),
455455+ [
456456+ [InfoLineWithTitle(new.title, SplitWords, "title")],
457457+ additional_info_lines,
458458+ [
459459+ InfoLineWithNoTitle("", DoNotSplit),
460460+ InfoLineWithNoTitle(ansi.red("- old snapshot"), DoNotSplit),
461461+ InfoLineWithNoTitle(ansi.green("+ new snapshot"), DoNotSplit),
462462+ ],
463463+ ]
464464+ |> list.concat,
465465+ )
466466+}
467467+468468+fn pretty_box(
469469+ title: String,
470470+ content_lines: List(DiffLine),
471471+ info_lines: List(InfoLine),
472472+) -> String {
473473+ let width = terminal_width()
474474+ let assert Ok(padding) = {
475475+ let lines_count = list.length(content_lines) + 1
476476+ use digits <- result.try(int.digits(lines_count, 10))
477477+ Ok(list.length(digits) * 2 + 5)
478478+ }
479479+480480+ // Make the title line.
481481+ let title_length = string.length(title)
482482+ let title_line_right = string.repeat("─", width - 5 - title_length)
483483+ let title_line = "── " <> title <> " ─" <> title_line_right
484484+485485+ // Make the pretty info lines.
486486+ let info_lines =
487487+ list.map(info_lines, pretty_info_line(_, width))
488488+ |> string.join("\n")
489489+490490+ // Add numbers to the content's lines.
491491+ let content =
492492+ list.map(content_lines, pretty_diff_line(_, padding))
493493+ |> string.join(with: "\n")
494494+495495+ // The open and closed delimiters for the box main content.
496496+ let left_padding_line = string.repeat("─", padding)
497497+ let right_padding_line = string.repeat("─", width - padding - 1)
498498+ let open_line = left_padding_line <> "┬" <> right_padding_line
499499+ let closed_line = left_padding_line <> "┴" <> right_padding_line
500500+501501+ // Assemble everything together with some empty lines to allow the content to
502502+ // breath a little.
503503+ [title_line, "", info_lines, "", open_line, content, closed_line]
504504+ |> string.join(with: "\n")
505505+}
506506+507507+fn pretty_info_line(line: InfoLine, width: Int) -> String {
508508+ let title_length = case line {
509509+ InfoLineWithNoTitle(..) -> 2
510510+ InfoLineWithTitle(title: title, ..) -> string.length(title)
511511+ }
512512+513513+ let line_doc = case line.split {
514514+ DoNotSplit -> doc.from_string(line.content)
515515+ SplitWords ->
516516+ string.split(line.content, on: "\n")
517517+ |> list.map(fn(line) {
518518+ string.split(line, on: " ")
519519+ |> list.map(doc.from_string)
520520+ |> doc.join(with: doc.flex_space)
521521+ })
522522+ |> doc.join(with: doc.line)
523523+ |> doc.group
524524+ |> doc.nest(by: title_length + 4)
525525+526526+ Truncate -> {
527527+ let max_content_length = width - title_length - 6
528528+ let content_length = string.length(line.content)
529529+ case content_length > max_content_length {
530530+ False -> doc.from_string(line.content)
531531+ True ->
532532+ string.to_graphemes(line.content)
533533+ |> list.take(max_content_length - 3)
534534+ |> string.join(with: "")
535535+ |> string.append("...")
536536+ |> doc.from_string
537537+ }
538538+ }
539539+ }
540540+541541+ // This is an ugly hack that I need because `glam` currently doesn't take into
542542+ // account color codes.
543543+ // Those are invisible but still contribute to the length of a string, so I
544544+ // have to artifically set the width to a higher limit to take into account
545545+ // the length of the color codes added to the lines' titles.
546546+ let ansi_code_len = 7
547547+548548+ case line {
549549+ InfoLineWithNoTitle(..) -> doc.from_string(" ")
550550+ InfoLineWithTitle(title: title, ..) ->
551551+ doc.from_string(ansi.blue(" " <> title <> ": "))
552552+ }
553553+ |> doc.append(line_doc)
554554+ |> doc.to_string(width + ansi_code_len)
555555+}
556556+557557+fn pretty_diff_line(diff_line: DiffLine, padding: Int) -> String {
558558+ let DiffLine(number: number, line: line, kind: kind) = diff_line
559559+560560+ let #(pretty_number, pretty_line, separator) = case kind {
561561+ diff.Shared -> #(
562562+ int.to_string(number)
563563+ |> string.pad_left(to: padding - 1, with: " ")
564564+ |> ansi.dim,
565565+ ansi.dim(line),
566566+ " │ ",
567567+ )
568568+569569+ diff.New -> #(
570570+ int.to_string(number)
571571+ |> string.pad_left(to: padding - 1, with: " ")
572572+ |> ansi.green
573573+ |> ansi.bold,
574574+ ansi.green(line),
575575+ ansi.green(" + "),
576576+ )
577577+578578+ diff.Old -> {
579579+ let number =
580580+ { " " <> int.to_string(number) }
581581+ |> string.pad_right(to: padding - 1, with: " ")
582582+ #(ansi.red(number), ansi.red(line), ansi.red(" - "))
583583+ }
584584+ }
585585+586586+ pretty_number <> separator <> pretty_line
587587+}
588588+589589+// --- CLI COMMAND -------------------------------------------------------------
590590+591591+@deprecated("🚨 This is the entry point of the CLI tool.
592592+You should never call this function yourself, you should run `gleam run -m birdie` instead.
593593+Expect this function to disappear from the public API on future releases!")
594594+pub fn main() -> Nil {
595595+ case argv.load().arguments {
596596+ [] | ["review"] -> report_status(review())
597597+ ["accept-all"] | ["accept", "all"] -> report_status(accept_all())
598598+ ["reject-all"] | ["reject", "all"] -> report_status(reject_all())
599599+ ["help"] -> help()
600600+ [subcommand] -> unexpected_subcommand(subcommand)
601601+ subcommands -> more_than_one_command(subcommands)
602602+ }
603603+}
604604+605605+fn review() -> Result(Nil, Error) {
606606+ use snapshots_folder <- result.try(find_snapshots_folder())
607607+ use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))
608608+ case list.length(new_snapshots) {
609609+ // If there's no snapshots to review, we're done!
610610+ 0 -> {
611611+ io.println("No new snapshots to review.")
612612+ Ok(Nil)
613613+ }
614614+ // If there's snapshots to review start the interactive session.
615615+ n -> {
616616+ let result = do_review(new_snapshots, 1, n)
617617+ // Despite the review process ending well or with an error, we want to
618618+ // clear the screen of any garbage before showing the error explanation
619619+ // or the happy completion string.
620620+ // That's why we postpone the `result.try` step.
621621+ clear()
622622+ use _ <- result.try(result)
623623+ // A nice message based on the number of snapshots :)
624624+ io.println(case n {
625625+ 1 -> "Reviewed one snapshot"
626626+ n -> "Reviewed " <> int.to_string(n) <> " snapshots"
627627+ })
628628+ Ok(Nil)
629629+ }
630630+ }
631631+}
632632+633633+fn do_review(
634634+ new_snapshot_paths: List(String),
635635+ current: Int,
636636+ out_of: Int,
637637+) -> Result(Nil, Error) {
638638+ case new_snapshot_paths {
639639+ [] -> Ok(Nil)
640640+ [new_snapshot_path, ..rest] -> {
641641+ clear()
642642+ // We try reading the new snapshot and the accepted one (which might be
643643+ // missing).
644644+ use new_snapshot <- result.try(read_new(new_snapshot_path))
645645+ let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
646646+ use accepted_snapshot <- result.try(read_accepted(accepted_snapshot_path))
647647+648648+ let progress =
649649+ ansi.dim("Reviewing ")
650650+ <> ansi.bold(ansi.yellow(rank.ordinalise(current)))
651651+ <> ansi.dim(" out of ")
652652+ <> ansi.bold(ansi.yellow(int.to_string(out_of)))
653653+654654+ // If there's no accepted snapshot then we're just reviewing a new
655655+ // snapshot. Otherwise we show a nice diff.
656656+ let box = case accepted_snapshot {
657657+ None -> new_snapshot_box(new_snapshot, [])
658658+ Some(accepted_snapshot) ->
659659+ diff_snapshot_box(accepted_snapshot, new_snapshot, [])
660660+ }
661661+ io.println(progress <> "\n\n" <> box <> "\n")
662662+663663+ // We ask the user what to do with this snapshot.
664664+ use choice <- result.try(ask_choice())
665665+ use _ <- result.try(case choice {
666666+ AcceptSnapshot -> accept_snapshot(new_snapshot_path)
667667+ RejectSnapshot -> reject_snapshot(new_snapshot_path)
668668+ SkipSnapshot -> Ok(Nil)
669669+ })
670670+671671+ // Let's keep going with the remaining snapshots.
672672+ do_review(rest, current + 1, out_of)
673673+ }
674674+ }
675675+}
676676+677677+/// The choice the user can make when reviewing a snapshot.
678678+///
679679+type ReviewChoice {
680680+ AcceptSnapshot
681681+ RejectSnapshot
682682+ SkipSnapshot
683683+}
684684+685685+/// Asks the user to make a choice: it first prints a reminder of the options
686686+/// and waits for the user to choose one.
687687+/// Will prompt again if the choice is not amongst the possible options.
688688+///
689689+fn ask_choice() -> Result(ReviewChoice, Error) {
690690+ io.println(
691691+ ansi.bold(ansi.green(" a"))
692692+ <> " accept "
693693+ <> ansi.dim("accept the new snapshot\n")
694694+ <> ansi.bold(ansi.red(" r"))
695695+ <> " reject "
696696+ <> ansi.dim("reject the new snapshot\n")
697697+ <> ansi.bold(ansi.yellow(" s"))
698698+ <> " skip "
699699+ <> ansi.dim("skip the snapshot for now\n"),
700700+ )
701701+ // We clear the line of any possible garbage that might still be there from
702702+ // a previous prompt of the same method.
703703+ clear_line()
704704+ case result.map(erlang.get_line("> "), string.trim) {
705705+ Ok("a") -> Ok(AcceptSnapshot)
706706+ Ok("r") -> Ok(RejectSnapshot)
707707+ Ok("s") -> Ok(SkipSnapshot)
708708+ // If the choice is not one of the proposed ones we move the cursor back to
709709+ // the top of where it was and print everything once again, asking for a
710710+ // valid option.
711711+ Ok(_) -> {
712712+ cursor_up(5)
713713+ ask_choice()
714714+ }
715715+ Error(_) -> Error(CannotReadUserInput)
716716+ }
717717+}
718718+719719+fn accept_all() -> Result(Nil, Error) {
720720+ io.println("Looking for new snapshots...")
721721+ use snapshots_folder <- result.try(find_snapshots_folder())
722722+ use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))
723723+724724+ case list.length(new_snapshots) {
725725+ 0 -> io.println("No new snapshots to accept.")
726726+ 1 -> io.println("Accepting one new snapshot.")
727727+ n -> io.println("Accepting " <> int.to_string(n) <> " new snapshots.")
728728+ }
729729+730730+ list.try_each(new_snapshots, accept_snapshot)
731731+}
732732+733733+fn reject_all() -> Result(Nil, Error) {
734734+ io.println("Looking for new snapshots...")
735735+ use snapshots_folder <- result.try(find_snapshots_folder())
736736+ use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))
737737+738738+ case list.length(new_snapshots) {
739739+ 0 -> io.println("No new snapshots to reject.")
740740+ 1 -> io.println("Rejecting one new snapshot.")
741741+ n -> io.println("Rejecting " <> int.to_string(n) <> " new snapshots.")
742742+ }
743743+744744+ list.try_each(new_snapshots, reject_snapshot)
745745+}
746746+747747+fn help() -> Nil {
748748+ let version = ansi.green("🐦⬛ birdie ") <> "v" <> birdie_version
749749+ io.println(version <> "\n\n" <> help_text())
750750+}
751751+752752+fn help_text() -> String {
753753+ ansi.yellow("USAGE:\n")
754754+ <> " gleam run -m birdie [ <SUBCOMMAND> ]\n\n"
755755+ <> ansi.yellow("SUBCOMMANDS:\n")
756756+ <> ansi.green(" review ")
757757+ <> "Review all new snapshots one by one\n"
758758+ <> ansi.green(" accept-all ")
759759+ <> "Accept all new snapshots\n"
760760+ <> ansi.green(" reject-all ")
761761+ <> "Reject all new snapshots\n"
762762+ <> ansi.green(" help ")
763763+ <> "Show this help text\n"
764764+}
765765+766766+fn unexpected_subcommand(subcommand: String) -> Nil {
767767+ let error_message =
768768+ ansi.bold("Error: ") <> "\"" <> subcommand <> "\" isn't a valid subcommand."
769769+770770+ io.println(ansi.red(error_message) <> "\n\n" <> help_text())
771771+}
772772+773773+fn more_than_one_command(subcommands: List(String)) -> Nil {
774774+ let error_message =
775775+ ansi.bold("Error: ")
776776+ <> "I can only run one subcommand at a time, but more than one were provided: "
777777+ <> string.join(list.map(subcommands, fn(s) { "\"" <> s <> "\"" }), ", ")
778778+779779+ io.println(ansi.red(error_message) <> "\n\n" <> help_text())
780780+}
781781+782782+fn report_status(result: Result(Nil, Error)) -> Nil {
783783+ case result {
784784+ Ok(Nil) -> io.println(ansi.green("🐦⬛ Done!"))
785785+ Error(error) -> explain(error)
786786+ }
787787+}
788788+789789+// --- FFI ---------------------------------------------------------------------
790790+791791+/// Clear the screen.
792792+///
793793+@external(erlang, "birdie_ffi_erl", "clear")
794794+fn clear() -> Nil
795795+796796+/// Move the cursor up a given number of lines.
797797+///
798798+@external(erlang, "birdie_ffi_erl", "cursor_up")
799799+fn cursor_up(n: Int) -> Nil
800800+801801+/// Clear the line the cursor is currently on.
802802+///
803803+@external(erlang, "birdie_ffi_erl", "clear_line")
804804+fn clear_line() -> Nil
805805+806806+fn terminal_width() -> Int {
807807+ result.unwrap(do_terminal_width(), or: 80)
808808+}
809809+810810+@external(erlang, "birdie_ffi_erl", "terminal_width")
811811+@external(javascript, "./birdie_ffi_js.mjs", "terminal_width")
812812+fn do_terminal_width() -> Result(Int, Nil) {
813813+ // We have a default implementation that will fail on all other targets so
814814+ // that it can be unwrapped to a default value and we stay compatible with
815815+ // all future Gleam's targets.
816816+ Error(Nil)
817817+}