+23
.github/workflows/test.yml
+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
+201
LICENSE
+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
+75
README.md
···
1
+
# 🐦⬛ Birdie - snapshot testing in Gleam
2
+
3
+
[](https://hex.pm/packages/birdie)
4
+
[](https://hexdocs.pm/birdie/)
5
+

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
+

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
birdie.gif
This is a binary file and will not be displayed.
+27
birdie.tape
+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
+5
birdie_snapshots/my_favourite_number_wrapped_in_a_result.accepted
+5
birdie_snapshots/my_first_snapshot.accepted
+5
birdie_snapshots/my_first_snapshot.accepted
+5
birdie_snapshots/snapping_a_list_of_numbers.accepted
+5
birdie_snapshots/snapping_a_list_of_numbers.accepted
+20
gleam.toml
+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
+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
+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
+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
+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
+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
+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
+
}