+4
CHANGELOG.md
+4
CHANGELOG.md
+5
README.md
+5
README.md
···
73
73
Also all snapshots _must_ have unique names so that birdie won't mix those up,
74
74
so be careful when naming snapshots to not repeat the same title twice!
75
75
76
+
> During the review process, Birdie will try to be helpful and show you an
77
+
> error message if it can spot two tests that happen to share the same exact
78
+
> title. It will only work for snapshots that have a literal string as a title
79
+
> but it can be really helpful to spot some of those confusing bugs!
80
+
76
81
### How big should the snapshot's content be?
77
82
78
83
My recommendation is strive to have small and cohesive snapshots. Each
+20
birdie_snapshots/can_find_literal_titles_when_calling_`birdie_snap`.accepted
+20
birdie_snapshots/can_find_literal_titles_when_calling_`birdie_snap`.accepted
···
1
+
---
2
+
version: 1.0.4
3
+
title: can find literal titles when calling `birdie.snap`
4
+
file: ./test/titles_test.gleam
5
+
test_name: can_find_literal_titles_when_calling_birdie_snap_test
6
+
---
7
+
--- LITERALS ---
8
+
with function capture and labels [in_pipeline_with_piped_title - my/module]
9
+
with function capture and no labels [in_pipeline_with_piped_title - my/module]
10
+
with function capture and some labels [in_pipeline_with_piped_title - my/module]
11
+
with just content label [direct_call - my/module]
12
+
with just title label [direct_call - my/module]
13
+
with label [in_pipeline_with_piped_content - my/module]
14
+
with labels [direct_call - my/module]
15
+
with no labels [direct_call - my/module]
16
+
with swapped labels [direct_call - my/module]
17
+
without function capture and labelled content [in_pipeline_with_piped_title - my/module]
18
+
without label [in_pipeline_with_piped_content - my/module]
19
+
20
+
--- PREFIXES ---
+20
birdie_snapshots/can_find_literal_titles_when_calling_`snap`.accepted
+20
birdie_snapshots/can_find_literal_titles_when_calling_`snap`.accepted
···
1
+
---
2
+
version: 1.0.4
3
+
title: can find literal titles when calling `snap`
4
+
file: ./test/titles_test.gleam
5
+
test_name: can_find_literal_titles_when_calling_snap_test
6
+
---
7
+
--- LITERALS ---
8
+
with function capture and labels [in_pipeline_with_piped_title - my/module]
9
+
with function capture and no labels [in_pipeline_with_piped_title - my/module]
10
+
with function capture and some labels [in_pipeline_with_piped_title - my/module]
11
+
with just content label [direct_call - my/module]
12
+
with just title label [direct_call - my/module]
13
+
with label [in_pipeline_with_piped_content - my/module]
14
+
with labels [direct_call - my/module]
15
+
with no labels [direct_call - my/module]
16
+
with swapped labels [direct_call - my/module]
17
+
without function capture and labelled content [in_pipeline_with_piped_title - my/module]
18
+
without label [in_pipeline_with_piped_content - my/module]
19
+
20
+
--- PREFIXES ---
+20
birdie_snapshots/can_find_literal_titles_when_calling_aliased_`b_snap`.accepted
+20
birdie_snapshots/can_find_literal_titles_when_calling_aliased_`b_snap`.accepted
···
1
+
---
2
+
version: 1.0.4
3
+
title: can find literal titles when calling aliased `b.snap`
4
+
file: ./test/titles_test.gleam
5
+
test_name: can_find_literal_titles_when_calling_aliased_birdie_snap_test
6
+
---
7
+
--- LITERALS ---
8
+
with function capture and labels [in_pipeline_with_piped_title - my/module]
9
+
with function capture and no labels [in_pipeline_with_piped_title - my/module]
10
+
with function capture and some labels [in_pipeline_with_piped_title - my/module]
11
+
with just content label [direct_call - my/module]
12
+
with just title label [direct_call - my/module]
13
+
with label [in_pipeline_with_piped_content - my/module]
14
+
with labels [direct_call - my/module]
15
+
with no labels [direct_call - my/module]
16
+
with swapped labels [direct_call - my/module]
17
+
without function capture and labelled content [in_pipeline_with_piped_title - my/module]
18
+
without label [in_pipeline_with_piped_content - my/module]
19
+
20
+
--- PREFIXES ---
+20
birdie_snapshots/can_find_literal_titles_when_calling_aliased_`s`.accepted
+20
birdie_snapshots/can_find_literal_titles_when_calling_aliased_`s`.accepted
···
1
+
---
2
+
version: 1.0.4
3
+
title: can find literal titles when calling aliased `s`
4
+
file: ./test/titles_test.gleam
5
+
test_name: can_find_literal_titles_when_calling_aliased_snap_test
6
+
---
7
+
--- LITERALS ---
8
+
with function capture and labels [in_pipeline_with_piped_title - my/module]
9
+
with function capture and no labels [in_pipeline_with_piped_title - my/module]
10
+
with function capture and some labels [in_pipeline_with_piped_title - my/module]
11
+
with just content label [direct_call - my/module]
12
+
with just title label [direct_call - my/module]
13
+
with label [in_pipeline_with_piped_content - my/module]
14
+
with labels [direct_call - my/module]
15
+
with no labels [direct_call - my/module]
16
+
with swapped labels [direct_call - my/module]
17
+
without function capture and labelled content [in_pipeline_with_piped_title - my/module]
18
+
without label [in_pipeline_with_piped_content - my/module]
19
+
20
+
--- PREFIXES ---
+18
birdie_snapshots/can_read_the_snap_titles_from_the_project_itself.accepted
+18
birdie_snapshots/can_read_the_snap_titles_from_the_project_itself.accepted
···
1
+
---
2
+
version: 1.0.4
3
+
title: can read the snap titles from the project itself
4
+
file: ./test/titles_test.gleam
5
+
test_name: can_read_the_snap_titles_from_the_project_itself_test
6
+
---
7
+
--- LITERALS ---
8
+
can find literal titles when calling `birdie.snap` [can_find_literal_titles_when_calling_birdie_snap_test - ./test/titles_test.gleam]
9
+
can find literal titles when calling `snap` [can_find_literal_titles_when_calling_snap_test - ./test/titles_test.gleam]
10
+
can find literal titles when calling aliased `b.snap` [can_find_literal_titles_when_calling_aliased_birdie_snap_test - ./test/titles_test.gleam]
11
+
can find literal titles when calling aliased `s` [can_find_literal_titles_when_calling_aliased_snap_test - ./test/titles_test.gleam]
12
+
can read the snap titles from the project itself [can_read_the_snap_titles_from_the_project_itself_test - ./test/titles_test.gleam]
13
+
diffing a case expression [complex_function_test - ./test/birdie_test.gleam]
14
+
my favourite number wrapped in a result [a_result_test - ./test/birdie_test.gleam]
15
+
my first snapshot [hello_birdie_test - ./test/birdie_test.gleam]
16
+
snapping a list of numbers [list_test - ./test/birdie_test.gleam]
17
+
18
+
--- PREFIXES ---
+3
-1
birdie_snapshots/diffing_a_case_expression.accepted
+3
-1
birdie_snapshots/diffing_a_case_expression.accepted
+3
-1
birdie_snapshots/my_favourite_number_wrapped_in_a_result.accepted
+3
-1
birdie_snapshots/my_favourite_number_wrapped_in_a_result.accepted
+3
-1
birdie_snapshots/my_first_snapshot.accepted
+3
-1
birdie_snapshots/my_first_snapshot.accepted
+5
birdie_snapshots/my_first_snapshot.new
+5
birdie_snapshots/my_first_snapshot.new
+3
-1
birdie_snapshots/snapping_a_list_of_numbers.accepted
+3
-1
birdie_snapshots/snapping_a_list_of_numbers.accepted
+2
gleam.toml
+2
gleam.toml
+8
-3
manifest.toml
+8
-3
manifest.toml
···
3
3
4
4
packages = [
5
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" },
6
+
{ name = "filepath", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "FC1B1B29438A5BA6C990F8047A011430BEC0C5BA638BFAA62718C4EAEFE00435" },
7
7
{ name = "gap", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib"], otp_app = "gap", source = "hex", outer_checksum = "2EE1B0A17E85CF73A0C1D29DA315A2699117A8F549C8E8D89FA8261BE41EDEB1" },
8
+
{ name = "glance", version = "0.8.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "ACF09457E8B564AD7A0D823DAFDD326F58263C01ACB0D432A9BEFDEDD1DA8E73" },
8
9
{ name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" },
9
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" },
10
11
{ name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
11
-
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
12
+
{ name = "gleam_stdlib", version = "0.36.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "C0D14D807FEC6F8A08A7C9EF8DFDE6AE5C10E40E21325B2B29365965D82EB3D4" },
12
13
{ name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" },
14
+
{ name = "glexer", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glexer", source = "hex", outer_checksum = "4484942A465482A0A100936E1E5F12314DB4B5AC0D87575A7B9E9062090B96BE" },
13
15
{ name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" },
14
16
{ name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" },
15
-
{ name = "simplifile", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "059AEB3632D1EBF4C943E8C231DBD8861A8BBF2984B78C1FE49159F28338A1FF" },
17
+
{ name = "simplifile", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "EB9AA8E65E5C1E3E0FDCFC81BC363FD433CB122D7D062750FFDF24DE4AC40116" },
18
+
{ name = "trie_again", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "5B19176F52B1BD98831B57FDC97BD1F88C8A403D6D8C63471407E78598E27184" },
16
19
]
17
20
18
21
[requirements]
19
22
argv = { version = "~> 1.0" }
20
23
filepath = { version = "~> 0.1" }
21
24
gap = { version = "~> 1.1" }
25
+
glance = { version = "~> 0.8" }
22
26
gleam_community_ansi = { version = "~> 1.4" }
23
27
gleam_erlang = { version = "~> 0.24" }
24
28
gleam_stdlib = { version = "~> 0.34 or ~> 1.0" }
···
26
30
justin = { version = "~> 1.0" }
27
31
rank = { version = "~> 1.0" }
28
32
simplifile = { version = "~> 1.2" }
33
+
trie_again = { version = "~> 1.1" }
+241
-96
src/birdie.gleam
+241
-96
src/birdie.gleam
···
9
9
import gleam_community/ansi
10
10
import argv
11
11
import birdie/internal/diff.{type DiffLine, DiffLine}
12
+
import birdie/internal/project
13
+
import birdie/internal/titles
12
14
import filepath
13
15
import gleeunit/should
14
16
import justin
···
47
49
CorruptedSnapshot(source: String)
48
50
49
51
CannotFindProjectRoot(reason: simplifile.FileError)
52
+
53
+
CannotGetTitles(reason: titles.Error)
50
54
}
51
55
52
56
// --- THE SNAPSHOT TYPE -------------------------------------------------------
···
56
60
type Accepted
57
61
58
62
type Snapshot(status) {
59
-
Snapshot(title: String, content: String)
63
+
Snapshot(title: String, content: String, info: Option(titles.TestInfo))
60
64
}
61
65
62
66
// --- SNAP --------------------------------------------------------------------
···
122
126
// be run from any subfolder we can't just assume we're in the project's root.
123
127
use folder <- result.try(find_snapshots_folder())
124
128
125
-
let new = Snapshot(title: title, content: content)
129
+
// 🚨 When snapping with the `snap` function we don't try and get the test
130
+
// info from the file it's defined in. That would require re-parsing the test
131
+
// directory every single time the `snap` function is called. We just put the
132
+
// `info` field to `None`.
133
+
//
134
+
// That additional data will be retrieved and updated during the review
135
+
// process where the parsing of the test directory can be done just once for
136
+
// all the tests.
137
+
//
138
+
// 💡 TODO: I could investigate using a shared cache or something but it
139
+
// sounds like a pain to implement and should have to work for both
140
+
// targets.
141
+
let new = Snapshot(title: title, content: content, info: None)
126
142
let new_snapshot_path = new_destination(new, folder)
127
143
let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
128
144
···
154
170
accepted: Snapshot(Accepted),
155
171
new: Snapshot(New),
156
172
) -> List(DiffLine) {
157
-
let Snapshot(title: _, content: accepted_content) = accepted
158
-
let Snapshot(title: _, content: new_content) = new
173
+
let Snapshot(title: _, content: accepted_content, info: _) = accepted
174
+
let Snapshot(title: _, content: new_content, info: _) = new
159
175
diff.histogram(accepted_content, new_content)
160
176
}
161
177
162
178
// --- SNAPSHOT (DE)SERIALISATION ----------------------------------------------
163
179
180
+
fn split_n(
181
+
string,
182
+
times n: Int,
183
+
on separator: String,
184
+
) -> Result(#(List(String), String), Nil) {
185
+
case n <= 0 {
186
+
True -> Ok(#([], string))
187
+
False -> {
188
+
use #(line, rest) <- result.try(string.split_once(string, on: separator))
189
+
use #(lines, rest) <- result.try(split_n(rest, n - 1, separator))
190
+
Ok(#([line, ..lines], rest))
191
+
}
192
+
}
193
+
}
194
+
164
195
fn deserialise(raw: String) -> Result(Snapshot(a), Nil) {
165
-
// Check there's the opening `---`
166
-
use #(open_line, rest) <- result.try(string.split_once(raw, "\n"))
167
-
use <- bool.guard(when: open_line != "---", return: Error(Nil))
168
-
169
-
// For now I have no use of the version but it might come in handy in the
170
-
// future if I decide to change the snapshots' metadata's format.
171
-
use #(version_line, rest) <- result.try(string.split_once(rest, "\n"))
172
-
use _version <- result.try(case version_line {
173
-
"version: " <> version -> Ok(version)
174
-
_ -> Error(Nil)
175
-
})
176
-
177
-
// Get the title.
178
-
use #(title_line, rest) <- result.try(string.split_once(rest, "\n"))
179
-
use title <- result.try(case title_line {
180
-
// We unescape the newlines
181
-
"title: " <> title -> Ok(string.replace(title, each: "\\n", with: "\n"))
182
-
_ -> Error(Nil)
183
-
})
184
-
185
-
// Check there's the closing `---`
186
-
use #(close_line, content) <- result.try(string.split_once(rest, "\n"))
187
-
use <- bool.guard(when: close_line != "---", return: Error(Nil))
188
-
189
-
Ok(Snapshot(title: title, content: content))
196
+
case split_n(raw, 4, "\n") {
197
+
Ok(#(["---", "version: " <> _, "title: " <> title, "---"], content)) ->
198
+
Ok(Snapshot(title: title, content: content, info: None))
199
+
Ok(_) | Error(_) ->
200
+
case split_n(raw, 6, "\n") {
201
+
Ok(#(
202
+
[
203
+
"---",
204
+
"version: " <> _,
205
+
"title: " <> title,
206
+
"file: " <> file,
207
+
"test_name: " <> test_name,
208
+
"---",
209
+
],
210
+
content,
211
+
)) ->
212
+
Ok(Snapshot(
213
+
title: title,
214
+
content: content,
215
+
info: Some(titles.TestInfo(file: file, test_name: test_name)),
216
+
))
217
+
Ok(_) | Error(_) -> Error(Nil)
218
+
}
219
+
}
190
220
}
191
221
192
222
fn serialise(snapshot: Snapshot(New)) -> String {
193
-
let Snapshot(title: title, content: content) = snapshot
223
+
let Snapshot(title: title, content: content, info: info) = snapshot
224
+
let info_lines = case info {
225
+
None -> []
226
+
Some(titles.TestInfo(file: file, test_name: test_name)) -> [
227
+
"file: " <> file,
228
+
"test_name: " <> test_name,
229
+
]
230
+
}
231
+
194
232
[
195
-
"---",
196
-
"version: " <> birdie_version,
197
-
// We escape the newlines in the title so that it fits on one line and it's
198
-
// easier to parse.
199
-
// Is this the best course of action? Probably not.
200
-
// Does this make my life a lot easier? Absolutely! 😁
201
-
"title: " <> string.replace(title, each: "\n", with: "\\n"),
202
-
"---",
203
-
content,
233
+
[
234
+
"---",
235
+
"version: " <> birdie_version,
236
+
// We escape the newlines in the title so that it fits on one line and it's
237
+
// easier to parse.
238
+
// Is this the best course of action? Probably not.
239
+
// Does this make my life a lot easier? Absolutely! 😁
240
+
"title: " <> string.replace(title, each: "\n", with: "\\n"),
241
+
],
242
+
info_lines,
243
+
["---", content],
204
244
]
245
+
|> list.concat
205
246
|> string.join(with: "\n")
206
247
}
207
248
···
282
323
/// into. If it's not present the folder is created automatically.
283
324
///
284
325
fn find_snapshots_folder() -> Result(String, Error) {
285
-
let result = result.map_error(find_project_root("."), CannotFindProjectRoot)
326
+
let result = result.map_error(project.find_root(), CannotFindProjectRoot)
286
327
use project_root <- result.try(result)
287
328
let snapshots_folder = filepath.join(project_root, birdie_snapshots_folder)
288
329
···
292
333
}
293
334
}
294
335
295
-
/// Returns the path to the project's root.
296
-
///
297
-
/// > ⚠️ This assumes that this is only ever run inside a Gleam's project and
298
-
/// > sooner or later it will reach a `gleam.toml` file.
299
-
/// > Otherwise this will end up in an infinite loop, I think.
300
-
///
301
-
fn find_project_root(path: String) -> Result(String, simplifile.FileError) {
302
-
let manifest = filepath.join(path, "gleam.toml")
303
-
case simplifile.verify_is_file(manifest) {
304
-
Ok(True) -> Ok(path)
305
-
Ok(False) -> find_project_root(filepath.join(path, ".."))
306
-
Error(reason) -> Error(reason)
307
-
}
308
-
}
336
+
fn accept_snapshot(
337
+
new_snapshot_path: String,
338
+
titles: titles.Titles,
339
+
) -> Result(Nil, Error) {
340
+
use snapshot <- result.try(read_new(new_snapshot_path))
341
+
let Snapshot(title: title, content: content, info: _) = snapshot
342
+
let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
309
343
310
-
fn accept_snapshot(new_snapshot_path: String) -> Result(Nil, Error) {
311
-
let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
312
-
simplifile.rename_file(new_snapshot_path, accepted_snapshot_path)
313
-
|> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path))
344
+
case titles.find(titles, title) {
345
+
// We could find additional info about the test so we add it to the snapshot
346
+
// before saving it! So we delete the `new` file and write an `accepted`
347
+
// one with all the new info we found.
348
+
Ok(titles.Literal(info)) | Ok(titles.Prefix(info, _)) -> {
349
+
let delete_new_snapshot =
350
+
simplifile.delete(new_snapshot_path)
351
+
|> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path))
352
+
use _ <- result.try(delete_new_snapshot)
353
+
354
+
Snapshot(title: title, content: content, info: Some(info))
355
+
|> serialise
356
+
|> simplifile.write(to: accepted_snapshot_path)
357
+
|> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot_path))
358
+
}
359
+
360
+
Error(_) ->
361
+
// Birdie couldn't find any additional info about the given test, so
362
+
// we can just move the `new` snapshot to the `accepted` one.
363
+
simplifile.rename_file(new_snapshot_path, accepted_snapshot_path)
364
+
|> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path))
365
+
}
314
366
}
315
367
316
368
fn reject_snapshot(new_snapshot_path: String) -> Result(Nil, Error) {
···
340
392
filepath.join(folder, file_name(snapshot.title)) <> ".new"
341
393
}
342
394
343
-
/// Strips the extension of a file (if it has one).
344
-
///
345
-
fn strip_extension(file: String) -> String {
346
-
case filepath.extension(file) {
347
-
Ok(extension) -> string.drop_right(file, string.length(extension) + 1)
348
-
Error(Nil) -> file
349
-
}
350
-
}
351
-
352
395
/// Turns a new snapshot path into the path of the corresponding accepted
353
396
/// snapshot.
354
397
///
355
398
fn to_accepted_path(file: String) -> String {
356
399
// This just replaces the `.new` extension with the `.accepted` one.
357
-
strip_extension(file) <> ".accepted"
400
+
filepath.strip_extension(file) <> ".accepted"
358
401
}
359
402
360
403
// --- PRETTY PRINTING ---------------------------------------------------------
···
363
406
let heading = fn(reason) { "[" <> ansi.bold(string.inspect(reason)) <> "] " }
364
407
let message = case error {
365
408
CannotCreateSnapshotsFolder(reason: reason) ->
366
-
heading(reason) <> "I couldn't create the snapshots folder"
409
+
heading(reason) <> "I couldn't create the snapshots folder."
367
410
368
411
CannotReadAcceptedSnapshot(reason: reason, source: source) ->
369
412
heading(reason)
370
413
<> "I couldn't read the accepted snapshot from "
371
-
<> ansi.italic("\"" <> source <> "\"\n")
414
+
<> ansi.italic("\"" <> source <> "\".")
372
415
373
416
CannotReadNewSnapshot(reason: reason, source: source) ->
374
417
heading(reason)
375
418
<> "I couldn't read the new snapshot from "
376
-
<> ansi.italic("\"" <> source <> "\"\n")
419
+
<> ansi.italic("\"" <> source <> "\".")
377
420
378
421
CannotSaveNewSnapshot(
379
422
reason: reason,
···
384
427
<> "I couldn't save the snapshot "
385
428
<> ansi.italic("\"" <> title <> "\" ")
386
429
<> "to "
387
-
<> ansi.italic("\"" <> destination <> "\"\n")
430
+
<> ansi.italic("\"" <> destination <> "\".")
388
431
389
432
CannotReadSnapshots(reason: reason, folder: _) ->
390
-
heading(reason) <> "I couldn't read the snapshots directory's contents"
433
+
heading(reason) <> "I couldn't read the snapshots folder's contents."
391
434
392
435
CannotRejectSnapshot(reason: reason, snapshot: snapshot) ->
393
436
heading(reason)
394
-
<> "I couldn't reject the snapshot"
395
-
<> ansi.italic("\"" <> snapshot <> "\" ")
437
+
<> "I couldn't reject the snapshot "
438
+
<> ansi.italic("\"" <> snapshot <> "\".")
396
439
397
440
CannotAcceptSnapshot(reason: reason, snapshot: snapshot) ->
398
441
heading(reason)
399
-
<> "I couldn't accept the snapshot"
400
-
<> ansi.italic("\"" <> snapshot <> "\" ")
442
+
<> "I couldn't accept the snapshot "
443
+
<> ansi.italic("\"" <> snapshot <> "\".")
401
444
402
-
CannotReadUserInput -> "I couldn't read the user input"
445
+
CannotReadUserInput -> "I couldn't read the user input."
403
446
404
447
CorruptedSnapshot(source: source) ->
405
448
"It looks like "
406
449
<> ansi.italic("\"" <> source <> "\"\n")
407
-
<> " is not a valid snapshot.\n"
450
+
<> "is not a valid snapshot.\n"
408
451
<> "This might happen when someone modifies its content.\n"
409
452
<> "Try deleting the snapshot and recreating it."
410
453
411
-
CannotFindProjectRoot(reason: reason) ->
454
+
CannotFindProjectRoot(reason: reason)
455
+
| CannotGetTitles(titles.CannotFindProjectRoot(reason: reason)) ->
412
456
heading(reason)
413
457
<> "I couldn't locate the project's root where the snapshot's"
414
458
<> " folder should be."
459
+
460
+
CannotGetTitles(titles.TestModuleIsNotCompiling(file: file)) ->
461
+
"The test file "
462
+
<> ansi.italic("\"" <> file <> "\"\n")
463
+
<> " is not compiling.\n"
464
+
<> "All your test should be compiling for Birdie to work!"
465
+
466
+
CannotGetTitles(titles.CannotReadTestDirectory(reason: reason)) ->
467
+
heading(reason) <> "I couldn't list the contents of the test folder."
468
+
469
+
CannotGetTitles(titles.CannotReadTestFile(reason: reason, file: file)) ->
470
+
heading(reason)
471
+
<> "I couldn't read the test file "
472
+
<> ansi.italic("\"" <> file <> "\"\n")
473
+
474
+
CannotGetTitles(titles.ParseError(reason: _reason)) ->
475
+
"I couldn't parse the content of your test modules.\n"
476
+
<> "This most likely is a bug in Birdie, it would be grand if you could"
477
+
<> "open an issue on GitHub:\n"
478
+
<> "\"https://github.com/giacomocavalieri/birdie/issues\""
479
+
480
+
CannotGetTitles(titles.DuplicateLiteralTitles(
481
+
title: title,
482
+
one: titles.TestInfo(file: one_file, test_name: one_test_name),
483
+
other: titles.TestInfo(file: other_file, test_name: other_test_name),
484
+
)) -> {
485
+
let same_file = one_file == other_file
486
+
let same_function = one_test_name == other_test_name
487
+
let location = case same_file, same_function {
488
+
True, True ->
489
+
"Both tests are defined in:\n\n "
490
+
<> ansi.italic(to_function_name(one_file, one_test_name))
491
+
_, _ ->
492
+
"One test is defined in:\n\n "
493
+
<> ansi.italic(to_function_name(one_file, one_test_name))
494
+
<> "\n\nWhile the other is defined in:\n\n "
495
+
<> ansi.italic(to_function_name(other_file, other_test_name))
496
+
}
497
+
498
+
"It looks like there's some snapshot tests sharing the same title:
499
+
500
+
" <> ansi.italic("\"" <> title <> "\"") <> "
501
+
502
+
Snapshot titles " <> ansi.bold("must be unique") <> " or you would run into strange diffs
503
+
when reviewing them, try changing one of those.
504
+
" <> location
505
+
}
506
+
507
+
CannotGetTitles(titles.OverlappingPrefixes(..)) ->
508
+
panic as "Prefixes are not implemented yet"
509
+
510
+
CannotGetTitles(titles.PrefixOverlappingWithLiteralTitle(..)) ->
511
+
panic as "Prefixes are not implemented yet"
415
512
}
416
513
417
-
io.println_error("❌ " <> ansi.red(message))
514
+
io.println_error("❌ " <> message)
515
+
}
516
+
517
+
fn to_function_name(file: String, function_name: String) -> String {
518
+
let module_name = case file {
519
+
"./test/" <> rest -> filepath.strip_extension(rest)
520
+
_ -> filepath.strip_extension(file)
521
+
}
522
+
523
+
module_name <> ".{" <> function_name <> "}"
418
524
}
419
525
420
526
type InfoLine {
···
428
534
Truncate
429
535
}
430
536
537
+
fn snapshot_default_lines(snapshot: Snapshot(status)) -> List(InfoLine) {
538
+
let Snapshot(title: title, content: _, info: info) = snapshot
539
+
case info {
540
+
None -> [InfoLineWithTitle(title, SplitWords, "title")]
541
+
Some(titles.TestInfo(file: file, test_name: test_name)) -> [
542
+
InfoLineWithTitle(title, SplitWords, "title"),
543
+
InfoLineWithTitle(file, Truncate, "file"),
544
+
InfoLineWithTitle(test_name, Truncate, "name"),
545
+
]
546
+
}
547
+
}
548
+
431
549
fn new_snapshot_box(
432
550
snapshot: Snapshot(New),
433
551
additional_info_lines: List(InfoLine),
434
552
) -> String {
435
-
let Snapshot(title: title, content: content) = snapshot
553
+
let Snapshot(title: _, content: content, info: _) = snapshot
436
554
437
555
let content =
438
556
string.split(content, on: "\n")
···
440
558
DiffLine(number: i + 1, line: line, kind: diff.New)
441
559
})
442
560
443
-
pretty_box("new snapshot", content, [
444
-
InfoLineWithTitle(title, SplitWords, "title"),
445
-
..additional_info_lines
446
-
])
561
+
pretty_box(
562
+
"new snapshot",
563
+
content,
564
+
list.concat([snapshot_default_lines(snapshot), additional_info_lines]),
565
+
)
447
566
}
448
567
449
568
fn diff_snapshot_box(
···
455
574
"mismatched snapshots",
456
575
to_diff_lines(accepted, new),
457
576
[
458
-
[InfoLineWithTitle(new.title, SplitWords, "title")],
577
+
snapshot_default_lines(accepted),
459
578
additional_info_lines,
460
579
[
461
580
InfoLineWithNoTitle("", DoNotSplit),
···
507
626
}
508
627
509
628
fn pretty_info_line(line: InfoLine, width: Int) -> String {
510
-
let prefix = case line {
511
-
InfoLineWithNoTitle(..) -> " "
512
-
InfoLineWithTitle(title: title, ..) -> " " <> title <> ": "
629
+
let #(prefix, prefix_length) = case line {
630
+
InfoLineWithNoTitle(..) -> #(" ", 2)
631
+
InfoLineWithTitle(title: title, ..) -> #(
632
+
" "
633
+
<> ansi.blue(title <> ": "),
634
+
string.length(title)
635
+
+ 4,
636
+
)
513
637
}
514
-
let prefix_length = string.length(prefix)
638
+
515
639
case line.split {
516
640
Truncate -> prefix <> truncate(line.content, width - prefix_length)
517
641
DoNotSplit -> prefix <> line.content
···
599
723
case new_line_length > max_length {
600
724
True -> do_to_lines([line, ..lines], "", 0, words, max_length)
601
725
False -> {
602
-
let new_line = line <> " " <> word
726
+
let new_line = case line {
727
+
"" -> word
728
+
_ -> line <> " " <> word
729
+
}
603
730
do_to_lines(lines, new_line, new_line_length, rest, max_length)
604
731
}
605
732
}
···
635
762
636
763
fn review() -> Result(Nil, Error) {
637
764
use snapshots_folder <- result.try(find_snapshots_folder())
765
+
766
+
let get_titles = titles.from_test_directory()
767
+
use titles <- result.try(result.map_error(get_titles, CannotGetTitles))
768
+
638
769
use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))
639
770
case list.length(new_snapshots) {
640
771
// If there's no snapshots to review, we're done!
···
644
775
}
645
776
// If there's snapshots to review start the interactive session.
646
777
n -> {
647
-
let result = do_review(new_snapshots, 1, n)
778
+
let result = do_review(new_snapshots, titles, 1, n)
648
779
// Despite the review process ending well or with an error, we want to
649
780
// clear the screen of any garbage before showing the error explanation
650
781
// or the happy completion string.
···
663
794
664
795
fn do_review(
665
796
new_snapshot_paths: List(String),
797
+
titles: titles.Titles,
666
798
current: Int,
667
799
out_of: Int,
668
800
) -> Result(Nil, Error) {
···
673
805
// We try reading the new snapshot and the accepted one (which might be
674
806
// missing).
675
807
use new_snapshot <- result.try(read_new(new_snapshot_path))
808
+
809
+
// We need to add to the new test info about its location and the function
810
+
// it's defined in.
811
+
let new_snapshot_info = case titles.find(titles, new_snapshot.title) {
812
+
Ok(titles.Prefix(info: info, ..)) | Ok(titles.Literal(info: info)) ->
813
+
Some(info)
814
+
Error(_) -> None
815
+
}
816
+
let new_snapshot = Snapshot(..new_snapshot, info: new_snapshot_info)
817
+
676
818
let accepted_snapshot_path = to_accepted_path(new_snapshot_path)
677
819
use accepted_snapshot <- result.try(read_accepted(accepted_snapshot_path))
678
820
···
694
836
// We ask the user what to do with this snapshot.
695
837
use choice <- result.try(ask_choice())
696
838
use _ <- result.try(case choice {
697
-
AcceptSnapshot -> accept_snapshot(new_snapshot_path)
839
+
AcceptSnapshot -> accept_snapshot(new_snapshot_path, titles)
698
840
RejectSnapshot -> reject_snapshot(new_snapshot_path)
699
841
SkipSnapshot -> Ok(Nil)
700
842
})
701
843
702
844
// Let's keep going with the remaining snapshots.
703
-
do_review(rest, current + 1, out_of)
845
+
do_review(rest, titles, current + 1, out_of)
704
846
}
705
847
}
706
848
}
···
752
894
use snapshots_folder <- result.try(find_snapshots_folder())
753
895
use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))
754
896
897
+
let get_titles = titles.from_test_directory()
898
+
use titles <- result.try(result.map_error(get_titles, CannotGetTitles))
899
+
755
900
case list.length(new_snapshots) {
756
901
0 -> io.println("No new snapshots to accept.")
757
902
1 -> io.println("Accepting one new snapshot.")
758
903
n -> io.println("Accepting " <> int.to_string(n) <> " new snapshots.")
759
904
}
760
905
761
-
list.try_each(new_snapshots, accept_snapshot)
906
+
list.try_each(new_snapshots, accept_snapshot(_, titles))
762
907
}
763
908
764
909
fn reject_all() -> Result(Nil, Error) {
+21
src/birdie/internal/project.gleam
+21
src/birdie/internal/project.gleam
···
1
+
import filepath
2
+
import simplifile
3
+
4
+
/// Returns the path to the project's root.
5
+
///
6
+
/// > ⚠️ This assumes that this is only ever run inside a Gleam's project and
7
+
/// > sooner or later it will reach a `gleam.toml` file.
8
+
/// > Otherwise this will end up in an infinite loop, I think.
9
+
///
10
+
pub fn find_root() -> Result(String, simplifile.FileError) {
11
+
do_find_root(".")
12
+
}
13
+
14
+
fn do_find_root(path: String) -> Result(String, simplifile.FileError) {
15
+
let manifest = filepath.join(path, "gleam.toml")
16
+
case simplifile.verify_is_file(manifest) {
17
+
Ok(True) -> Ok(path)
18
+
Ok(False) -> do_find_root(filepath.join(path, ".."))
19
+
Error(reason) -> Error(reason)
20
+
}
21
+
}
+491
src/birdie/internal/titles.gleam
+491
src/birdie/internal/titles.gleam
···
1
+
import gleam/bool
2
+
import gleam/dict.{type Dict}
3
+
import gleam/list
4
+
import gleam/option.{None, Some}
5
+
import gleam/result
6
+
import gleam/string
7
+
import filepath
8
+
import glance
9
+
import simplifile
10
+
import trie.{type Trie}
11
+
import birdie/internal/project
12
+
13
+
/// A data structure to hold info about all the titles gathered from a project's
14
+
/// test modules.
15
+
///
16
+
pub opaque type Titles {
17
+
Titles(
18
+
/// All the birdie literal titles defined in the module. For example:
19
+
/// `"This is a literal title"`
20
+
///
21
+
literals: Dict(String, TestInfo),
22
+
/// All the variable titles with a constant prefix. For example:
23
+
/// `"snapshot number " <> int.to_string(n)`
24
+
///
25
+
prefixes: Trie(String, TestInfo),
26
+
)
27
+
}
28
+
29
+
pub type TestInfo {
30
+
TestInfo(file: String, test_name: String)
31
+
}
32
+
33
+
/// A match you can get when looking for a title.
34
+
///
35
+
pub type Match {
36
+
Literal(info: TestInfo)
37
+
Prefix(info: TestInfo, prefix: String)
38
+
}
39
+
40
+
/// All the possible errors that can occur when gathering the titles from a
41
+
/// project's test modules.
42
+
///
43
+
pub type Error {
44
+
CannotFindProjectRoot(reason: simplifile.FileError)
45
+
TestModuleIsNotCompiling(file: String)
46
+
CannotReadTestDirectory(reason: simplifile.FileError)
47
+
CannotReadTestFile(reason: simplifile.FileError, file: String)
48
+
ParseError(reason: glance.Error)
49
+
DuplicateLiteralTitles(title: String, one: TestInfo, other: TestInfo)
50
+
OverlappingPrefixes(
51
+
prefix: String,
52
+
info: TestInfo,
53
+
other_prefix: String,
54
+
other_info: TestInfo,
55
+
)
56
+
PrefixOverlappingWithLiteralTitle(
57
+
prefix: String,
58
+
prefix_info: TestInfo,
59
+
title_info: TestInfo,
60
+
)
61
+
}
62
+
63
+
type BirdieImport {
64
+
Unqualified(module_alias: String)
65
+
Qualified(module_alias: String, snap_alias: String)
66
+
}
67
+
68
+
type SnapTitle {
69
+
LiteralTitle(String)
70
+
PrefixTitle(String)
71
+
}
72
+
73
+
// --- TITLES CREATION ---------------------------------------------------------
74
+
75
+
pub fn new() -> Titles {
76
+
Titles(literals: dict.new(), prefixes: trie.new())
77
+
}
78
+
79
+
fn add_literal_title(titles: Titles, title: String, info: TestInfo) -> Titles {
80
+
let literals = titles.literals
81
+
let new_literals = dict.insert(literals, title, info)
82
+
Titles(..titles, literals: new_literals)
83
+
}
84
+
85
+
// fn add_prefix_title(titles: Titles, prefix: String, info: TestInfo) -> Titles {
86
+
// let prefixes = titles.prefixes
87
+
// let new_prefixes = trie.insert(prefixes, string.to_graphemes(prefix), info)
88
+
// Titles(..titles, prefixes: new_prefixes)
89
+
// }
90
+
91
+
pub fn literals(titles: Titles) -> Dict(String, TestInfo) {
92
+
let Titles(literals: literals, prefixes: _) = titles
93
+
literals
94
+
}
95
+
96
+
pub fn prefixes(titles: Titles) -> Dict(String, TestInfo) {
97
+
let Titles(literals: _, prefixes: prefixes) = titles
98
+
prefixes
99
+
|> trie.to_list
100
+
|> list.map(fn(pair) {
101
+
let #(prefix, info) = pair
102
+
#(string.join(prefix, with: ""), info)
103
+
})
104
+
|> dict.from_list
105
+
}
106
+
107
+
// --- TITLE LOOKUP ------------------------------------------------------------
108
+
109
+
pub fn find(titles: Titles, title: String) -> Result(Match, Nil) {
110
+
// We first look for exact matches.
111
+
let literal_match = result.map(dict.get(titles.literals, title), Literal)
112
+
use <- result.lazy_or(literal_match)
113
+
114
+
// If we couldn't find an exact match we look for a matching prefix.
115
+
let letters = string.to_graphemes(title)
116
+
use matching_prefixes <- result.try(trie.subtrie(titles.prefixes, letters))
117
+
case trie.to_list(matching_prefixes) {
118
+
[#(prefix, info), ..] -> Ok(Prefix(info, string.join(prefix, with: "")))
119
+
_ -> Error(Nil)
120
+
}
121
+
}
122
+
123
+
// --- TITLE GATHERING ---------------------------------------------------------
124
+
125
+
pub fn from_test_directory() -> Result(Titles, Error) {
126
+
use root <- try(project.find_root(), CannotFindProjectRoot)
127
+
let test_directory = filepath.join(root, "test")
128
+
let get_files = simplifile.get_files(test_directory)
129
+
use files <- try(get_files, CannotReadTestDirectory)
130
+
131
+
use titles, file <- list.try_fold(over: files, from: new())
132
+
use raw_module <- try(simplifile.read(file), CannotReadTestFile(_, file))
133
+
134
+
let parse_module =
135
+
glance.module(raw_module)
136
+
|> result.replace_error(TestModuleIsNotCompiling(file))
137
+
use module <- result.try(parse_module)
138
+
139
+
from_module(titles, file, module)
140
+
}
141
+
142
+
pub fn from_module(
143
+
titles: Titles,
144
+
name: String,
145
+
module: glance.Module,
146
+
) -> Result(Titles, Error) {
147
+
use birdie_import <- try_or(birdie_import(module), return: Ok(titles))
148
+
use titles, function <- list.try_fold(over: module.functions, from: titles)
149
+
let body = function.definition.body
150
+
use titles, expression <- try_fold_statements(body, titles)
151
+
152
+
// We see if the expression is a call to the `birdie.snap` function.
153
+
case snap_call(birdie_import, expression) {
154
+
// We have found a call to `birdie.snap` where the title is a literal
155
+
// string.
156
+
Ok(LiteralTitle(title)) -> {
157
+
let info = TestInfo(file: name, test_name: function.definition.name)
158
+
case find(titles, title) {
159
+
Error(Nil) -> Ok(add_literal_title(titles, title, info))
160
+
Ok(Prefix(prefix_info, prefix)) ->
161
+
Error(PrefixOverlappingWithLiteralTitle(prefix, prefix_info, info))
162
+
Ok(Literal(other_info)) ->
163
+
Error(DuplicateLiteralTitles(title, info, other_info))
164
+
}
165
+
}
166
+
167
+
// We have found a call to `birdie.snap` where the title is a constant
168
+
// prefix followed by some variable part.
169
+
Ok(PrefixTitle(_prefix)) ->
170
+
// TODO: I should handle prefixes but I couldn't be bothered so for now we
171
+
// are ignoring those and returning the accumulator as is
172
+
//
173
+
// let info = TestInfo(file: name, test_name: function.definition.name)
174
+
// case find(titles, prefix) {
175
+
// Error(Nil) -> Ok(add_prefix_title(titles, prefix, info))
176
+
// Ok(Prefix(other_info, other)) ->
177
+
// Error(OverlappingPrefixes(prefix, info, other, other_info))
178
+
// Ok(Literal(title_info)) ->
179
+
// Error(PrefixOverlappingWithLiteralTitle(prefix, info, title_info))
180
+
// }
181
+
Ok(titles)
182
+
183
+
// The call is in a format we do not currently support.
184
+
Error(Nil) -> Ok(titles)
185
+
}
186
+
}
187
+
188
+
fn birdie_import(module: glance.Module) -> Result(BirdieImport, Nil) {
189
+
use nil, import_ <- list.fold_until(over: module.imports, from: Error(Nil))
190
+
case import_.definition {
191
+
glance.Import(
192
+
module: "birdie",
193
+
alias: birdie_alias,
194
+
unqualified_types: _,
195
+
unqualified_values: unqualified_values,
196
+
) -> {
197
+
let module_name = option.unwrap(birdie_alias, or: "birdie")
198
+
case imported_snap(unqualified_values) {
199
+
Ok(snap_alias) -> list.Stop(Ok(Qualified(module_name, snap_alias)))
200
+
Error(_) -> list.Stop(Ok(Unqualified(module_name)))
201
+
}
202
+
}
203
+
_ -> list.Continue(nil)
204
+
}
205
+
}
206
+
207
+
fn imported_snap(values: List(glance.UnqualifiedImport)) -> Result(String, Nil) {
208
+
use nil, value <- list.fold_until(over: values, from: Error(Nil))
209
+
case value {
210
+
glance.UnqualifiedImport(name: "snap", alias: None) -> list.Stop(Ok("snap"))
211
+
glance.UnqualifiedImport(name: "snap", alias: Some(alias)) ->
212
+
list.Stop(Ok(alias))
213
+
_ -> list.Continue(nil)
214
+
}
215
+
}
216
+
217
+
fn snap_call(
218
+
birdie_import: BirdieImport,
219
+
expression: glance.Expression,
220
+
) -> Result(SnapTitle, Nil) {
221
+
case expression {
222
+
// A direct function call to the `birdie.snap` function where the first
223
+
// argument is the unlabelled content. This means that the second argument
224
+
// must be the title - labelled or not.
225
+
glance.Call(
226
+
function: function,
227
+
arguments: [
228
+
glance.Field(None, title),
229
+
glance.Field(Some("content"), _snapshot_content),
230
+
],
231
+
)
232
+
| glance.Call(
233
+
function: function,
234
+
arguments: [glance.Field(None, _snapshot_content), glance.Field(_, title)],
235
+
)
236
+
| glance.Call(
237
+
function: function,
238
+
arguments: [
239
+
glance.Field(Some("content"), _snapshot_content),
240
+
glance.Field(_, title),
241
+
],
242
+
)
243
+
| // A direct function call to the `birdie.snap` function where the first
244
+
// argument is the labelled title.
245
+
glance.Call(
246
+
function: function,
247
+
arguments: [glance.Field(Some("title"), title), glance.Field(_, _)],
248
+
)
249
+
| // A call to the `birdie.snap` function where the title is piped into it
250
+
// and the content is passed as a labelled argument.
251
+
glance.BinaryOperator(
252
+
name: glance.Pipe,
253
+
left: title,
254
+
right: glance.Call(
255
+
function: function,
256
+
arguments: [glance.Field(Some("content"), _snapshot_content)],
257
+
),
258
+
)
259
+
| // A call to the `birdie.snap` function where the content is piped into
260
+
// it and the title is passed as an argument - labelled or not.
261
+
glance.BinaryOperator(
262
+
name: glance.Pipe,
263
+
left: _snapshot_content,
264
+
right: glance.Call(
265
+
function: function,
266
+
arguments: [glance.Field(_, title)],
267
+
),
268
+
)
269
+
| // We pipe into `title: _`, since we're using a label we don't have to
270
+
// check the position.
271
+
glance.BinaryOperator(
272
+
name: glance.Pipe,
273
+
left: title,
274
+
right: glance.FnCapture(
275
+
function: function,
276
+
label: Some("title"),
277
+
arguments_before: _,
278
+
arguments_after: _,
279
+
),
280
+
)
281
+
| // A call to the `birdie.snap` function where the title is piped into it
282
+
// and the content is passed as an argument. This must be done using a
283
+
// function capture.
284
+
glance.BinaryOperator(
285
+
name: glance.Pipe,
286
+
left: title,
287
+
right: glance.FnCapture(
288
+
function: function,
289
+
label: _,
290
+
arguments_before: [glance.Field(None, _snapshot_content)],
291
+
arguments_after: [],
292
+
),
293
+
) -> {
294
+
let is_snap_function = is_snap_function(function, birdie_import)
295
+
use <- bool.guard(when: !is_snap_function, return: Error(Nil))
296
+
expression_to_snap_title(title)
297
+
}
298
+
// Everything else is in a format birdie currently doesn't support.
299
+
_ -> Error(Nil)
300
+
}
301
+
}
302
+
303
+
fn is_snap_function(
304
+
expression: glance.Expression,
305
+
birdie_import: BirdieImport,
306
+
) -> Bool {
307
+
let is_a_call_to_snap = fn(module, name) -> Bool {
308
+
case module, birdie_import {
309
+
None, Unqualified(module_alias: _) -> False
310
+
None, Qualified(module_alias: _, snap_alias: snap) -> snap == name
311
+
Some(module), Qualified(module_alias: birdie, snap_alias: snap) ->
312
+
module <> "." <> name == birdie <> "." <> snap
313
+
Some(module), Unqualified(module_alias: birdie) ->
314
+
module <> "." <> name == birdie <> ".snap"
315
+
}
316
+
}
317
+
318
+
case expression {
319
+
glance.Variable(name) -> is_a_call_to_snap(None, name)
320
+
glance.FieldAccess(glance.Variable(module), name) ->
321
+
is_a_call_to_snap(Some(module), name)
322
+
_ -> False
323
+
}
324
+
}
325
+
326
+
fn expression_to_snap_title(
327
+
expression: glance.Expression,
328
+
) -> Result(SnapTitle, Nil) {
329
+
case expression {
330
+
glance.String(title) -> Ok(LiteralTitle(title))
331
+
glance.BinaryOperator(
332
+
name: glance.Concatenate,
333
+
left: glance.String(prefix),
334
+
right: _,
335
+
) -> Ok(PrefixTitle(prefix))
336
+
_ -> Error(Nil)
337
+
}
338
+
}
339
+
340
+
// --- AST FOLDING -------------------------------------------------------------
341
+
342
+
fn try_fold_statements(
343
+
statements: List(glance.Statement),
344
+
acc: a,
345
+
fun: fn(a, glance.Expression) -> Result(a, b),
346
+
) -> Result(a, b) {
347
+
use acc, statement <- list.try_fold(over: statements, from: acc)
348
+
case statement {
349
+
glance.Use(patterns: _, function: expression)
350
+
| glance.Assignment(kind: _, pattern: _, annotation: _, value: expression)
351
+
| glance.Expression(expression) -> try_fold_expression(expression, acc, fun)
352
+
}
353
+
}
354
+
355
+
fn try_fold_expression(
356
+
expression: glance.Expression,
357
+
acc: a,
358
+
fun: fn(a, glance.Expression) -> Result(a, b),
359
+
) -> Result(a, b) {
360
+
use acc <- result.try(fun(acc, expression))
361
+
case expression {
362
+
glance.Int(_)
363
+
| glance.Float(_)
364
+
| glance.String(_)
365
+
| glance.Variable(_)
366
+
| glance.Panic(_)
367
+
| glance.Todo(_) -> Ok(acc)
368
+
369
+
glance.NegateInt(expression)
370
+
| glance.NegateBool(expression)
371
+
| glance.FieldAccess(container: expression, label: _)
372
+
| glance.TupleIndex(tuple: expression, index: _) ->
373
+
try_fold_expression(expression, acc, fun)
374
+
375
+
glance.Block(statements) -> try_fold_statements(statements, acc, fun)
376
+
377
+
glance.Tuple(expressions) | glance.List(expressions, None) ->
378
+
try_fold_expressions(expressions, acc, fun)
379
+
380
+
glance.List(elements: elements, rest: Some(rest)) -> {
381
+
use acc <- result.try(try_fold_expressions(elements, acc, fun))
382
+
try_fold_expression(rest, acc, fun)
383
+
}
384
+
385
+
glance.Fn(arguments: _, return_annotation: _, body: statements) ->
386
+
try_fold_statements(statements, acc, fun)
387
+
388
+
glance.RecordUpdate(
389
+
module: _,
390
+
constructor: _,
391
+
record: record,
392
+
fields: fields,
393
+
) -> {
394
+
use acc <- result.try(try_fold_expression(record, acc, fun))
395
+
use acc, #(_field, expression) <- list.try_fold(over: fields, from: acc)
396
+
try_fold_expression(expression, acc, fun)
397
+
}
398
+
399
+
glance.Call(function: function, arguments: arguments) -> {
400
+
use acc <- result.try(try_fold_expression(function, acc, fun))
401
+
try_fold_fields(arguments, acc, fun)
402
+
}
403
+
404
+
glance.FnCapture(
405
+
label: _,
406
+
function: function,
407
+
arguments_before: arguments_before,
408
+
arguments_after: arguments_after,
409
+
) -> {
410
+
use acc <- result.try(try_fold_expression(function, acc, fun))
411
+
use acc <- result.try(try_fold_fields(arguments_before, acc, fun))
412
+
try_fold_fields(arguments_after, acc, fun)
413
+
}
414
+
415
+
glance.BitString(segments: segments) -> {
416
+
use acc, #(segment, options) <- list.try_fold(over: segments, from: acc)
417
+
use acc <- result.try(try_fold_expression(segment, acc, fun))
418
+
use acc, option <- list.try_fold(over: options, from: acc)
419
+
case option {
420
+
glance.SizeValueOption(expression) ->
421
+
try_fold_expression(expression, acc, fun)
422
+
_ -> Ok(acc)
423
+
}
424
+
}
425
+
426
+
glance.Case(subjects: subjects, clauses: clauses) -> {
427
+
use acc <- result.try(try_fold_expressions(subjects, acc, fun))
428
+
try_fold_clauses(clauses, acc, fun)
429
+
}
430
+
431
+
glance.BinaryOperator(name: _, left: left, right: right) -> {
432
+
use acc <- result.try(try_fold_expression(left, acc, fun))
433
+
try_fold_expression(right, acc, fun)
434
+
}
435
+
}
436
+
}
437
+
438
+
fn try_fold_fields(
439
+
fields: List(glance.Field(glance.Expression)),
440
+
acc: a,
441
+
fun: fn(a, glance.Expression) -> Result(a, b),
442
+
) -> Result(a, b) {
443
+
use acc, field <- list.try_fold(over: fields, from: acc)
444
+
let glance.Field(item: expression, label: _) = field
445
+
try_fold_expression(expression, acc, fun)
446
+
}
447
+
448
+
fn try_fold_clauses(
449
+
clauses: List(glance.Clause),
450
+
acc: a,
451
+
fun: fn(a, glance.Expression) -> Result(a, b),
452
+
) -> Result(a, b) {
453
+
use acc, clause <- list.try_fold(over: clauses, from: acc)
454
+
case clause {
455
+
glance.Clause(patterns: _, guard: None, body: body) ->
456
+
try_fold_expression(body, acc, fun)
457
+
glance.Clause(patterns: _, guard: Some(guard), body: body) -> {
458
+
use acc <- result.try(try_fold_expression(guard, acc, fun))
459
+
try_fold_expression(body, acc, fun)
460
+
}
461
+
}
462
+
}
463
+
464
+
fn try_fold_expressions(
465
+
expressions: List(glance.Expression),
466
+
acc: a,
467
+
fun: fn(a, glance.Expression) -> Result(a, b),
468
+
) -> Result(a, b) {
469
+
use acc, expression <- list.try_fold(expressions, acc)
470
+
try_fold_expression(expression, acc, fun)
471
+
}
472
+
473
+
// --- UTILITIES ---------------------------------------------------------------
474
+
475
+
fn try_or(result: Result(a, b), return default: c, with fun: fn(a) -> c) -> c {
476
+
case result {
477
+
Ok(a) -> fun(a)
478
+
Error(_) -> default
479
+
}
480
+
}
481
+
482
+
fn try(
483
+
result: Result(a, b),
484
+
map_error: fn(b) -> c,
485
+
fun: fn(a) -> Result(d, c),
486
+
) -> Result(d, c) {
487
+
case result {
488
+
Ok(a) -> fun(a)
489
+
Error(e) -> Error(map_error(e))
490
+
}
491
+
}
+204
test/titles_test.gleam
+204
test/titles_test.gleam
···
1
+
import gleam/dict
2
+
import gleam/list
3
+
import gleam/string
4
+
import glance
5
+
import birdie
6
+
import birdie/internal/titles
7
+
import gleeunit/should
8
+
9
+
const module = "
10
+
{{birdie_import}}
11
+
12
+
pub fn in_pipeline_with_piped_content() {
13
+
some_content
14
+
|> in_a_pipeline
15
+
|> with_some_steps
16
+
|> {{snap_invocation}}(title: \"with label\")
17
+
18
+
some_content
19
+
|> in_a_pipeline
20
+
|> with_some_steps
21
+
|> {{snap_invocation}}(\"without label\")
22
+
}
23
+
24
+
pub fn in_pipeline_with_piped_title() {
25
+
\"with function capture and labels\"
26
+
|> {{snap_invocation}}(content: \"content\", title: _)
27
+
28
+
\"with function capture and some labels\"
29
+
|> {{snap_invocation}}(\"content\", title: _)
30
+
31
+
\"with function capture and no labels\"
32
+
|> {{snap_invocation}}(\"content\", _)
33
+
34
+
\"without function capture and labelled content\"
35
+
|> {{snap_invocation}}(content: \"content\")
36
+
}
37
+
38
+
pub fn direct_call() {
39
+
{{snap_invocation}}(\"content\", \"with no labels\")
40
+
{{snap_invocation}}(content: \"content\", title: \"with labels\")
41
+
{{snap_invocation}}(\"content\", title: \"with just title label\")
42
+
{{snap_invocation}}(\"with just content label\", content: \"content\")
43
+
{{snap_invocation}}(title: \"with swapped labels\", content: \"content\")
44
+
}
45
+
"
46
+
47
+
//const module_with_prefixes = "
48
+
//{{birdie_import}}
49
+
//
50
+
//pub fn in_pipeline_with_piped_content() {
51
+
// some_content
52
+
// |> in_a_pipeline
53
+
// |> with_some_steps
54
+
// |> {{snap_invocation}}(title: \"in pipeline with label\" <> x)
55
+
//
56
+
// some_content
57
+
// |> in_a_pipeline
58
+
// |> with_some_steps
59
+
// |> {{snap_invocation}}(\"without label\" <> x)
60
+
//}
61
+
//
62
+
//pub fn direct_call() {
63
+
// {{snap_invocation}}(\"content\", \"with no labels\" <> x)
64
+
// {{snap_invocation}}(content: \"content\", title: \"with labels\" <> x)
65
+
// {{snap_invocation}}(\"content\", title: \"with just title label\" <> x)
66
+
// {{snap_invocation}}(\"with just content label\" <> x, content: \"content\")
67
+
// {{snap_invocation}}(title: \"with swapped labels\" <> x, content: \"content\")
68
+
//}
69
+
//"
70
+
71
+
fn assert_error(module: String) -> titles.Error {
72
+
let assert Ok(module) = glance.module(module)
73
+
let assert Error(error) =
74
+
titles.from_module(titles.new(), "my/module", module)
75
+
error
76
+
}
77
+
78
+
fn assert_titles(module: String) -> titles.Titles {
79
+
let assert Ok(module) = glance.module(module)
80
+
let assert Ok(titles) = titles.from_module(titles.new(), "my/module", module)
81
+
titles
82
+
}
83
+
84
+
fn pretty_titles(titles: titles.Titles) -> String {
85
+
let pretty = fn(title, info) {
86
+
let titles.TestInfo(file: file, test_name: test_name) = info
87
+
let title = string.pad_right(title, to: 40, with: " ")
88
+
let info = "[" <> test_name <> " - " <> file <> "]"
89
+
title <> " " <> info
90
+
}
91
+
92
+
let literals =
93
+
dict.to_list(titles.literals(titles))
94
+
|> list.map(fn(pair) { pretty(pair.0, pair.1) })
95
+
|> string.join(with: "\n")
96
+
97
+
let prefixes =
98
+
dict.to_list(titles.prefixes(titles))
99
+
|> list.map(fn(pair) { pretty(pair.0, pair.1) })
100
+
|> string.join(with: "\n")
101
+
102
+
"--- LITERALS ---\n" <> literals <> "\n\n--- PREFIXES ---\n" <> prefixes
103
+
}
104
+
105
+
pub fn can_find_literal_titles_when_calling_birdie_snap_test() {
106
+
module
107
+
|> string.replace(each: "{{birdie_import}}", with: "import birdie")
108
+
|> string.replace(each: "{{snap_invocation}}", with: "birdie.snap")
109
+
|> assert_titles
110
+
|> pretty_titles
111
+
|> birdie.snap(title: "can find literal titles when calling `birdie.snap`")
112
+
}
113
+
114
+
pub fn can_find_literal_titles_when_calling_snap_test() {
115
+
module
116
+
|> string.replace(each: "{{birdie_import}}", with: "import birdie.{snap}")
117
+
|> string.replace(each: "{{snap_invocation}}", with: "snap")
118
+
|> assert_titles
119
+
|> pretty_titles
120
+
|> birdie.snap(title: "can find literal titles when calling `snap`")
121
+
}
122
+
123
+
pub fn can_find_literal_titles_when_calling_aliased_birdie_snap_test() {
124
+
module
125
+
|> string.replace(each: "{{birdie_import}}", with: "import birdie as b")
126
+
|> string.replace(each: "{{snap_invocation}}", with: "b.snap")
127
+
|> assert_titles
128
+
|> pretty_titles
129
+
|> birdie.snap(title: "can find literal titles when calling aliased `b.snap`")
130
+
}
131
+
132
+
pub fn can_find_literal_titles_when_calling_aliased_snap_test() {
133
+
module
134
+
|> string.replace(
135
+
each: "{{birdie_import}}",
136
+
with: "import birdie.{snap as s}",
137
+
)
138
+
|> string.replace(each: "{{snap_invocation}}", with: "s")
139
+
|> assert_titles
140
+
|> pretty_titles
141
+
|> birdie.snap(title: "can find literal titles when calling aliased `s`")
142
+
}
143
+
144
+
// pub fn can_find_prefix_titles_when_calling_birdie_snap_test() {
145
+
// module_with_prefixes
146
+
// |> string.replace(each: "{{birdie_import}}", with: "import birdie")
147
+
// |> string.replace(each: "{{snap_invocation}}", with: "birdie.snap")
148
+
// |> assert_titles
149
+
// |> pretty_titles
150
+
// |> birdie.snap(title: "can find prefix titles when calling `birdie.snap`")
151
+
// }
152
+
//
153
+
// pub fn can_find_prefix_titles_when_calling_snap_test() {
154
+
// module_with_prefixes
155
+
// |> string.replace(each: "{{birdie_import}}", with: "import birdie.{snap}")
156
+
// |> string.replace(each: "{{snap_invocation}}", with: "snap")
157
+
// |> assert_titles
158
+
// |> pretty_titles
159
+
// |> birdie.snap(title: "can find prexif titles when calling `snap`")
160
+
// }
161
+
//
162
+
// pub fn can_find_prefix_titles_when_calling_aliased_birdie_snap_test() {
163
+
// module_with_prefixes
164
+
// |> string.replace(each: "{{birdie_import}}", with: "import birdie as b")
165
+
// |> string.replace(each: "{{snap_invocation}}", with: "b.snap")
166
+
// |> assert_titles
167
+
// |> pretty_titles
168
+
// |> birdie.snap(title: "can find prefix titles when calling aliased `b.snap`")
169
+
// }
170
+
//
171
+
// pub fn can_find_prefix_titles_when_calling_aliased_snap_test() {
172
+
// module_with_prefixes
173
+
// |> string.replace(
174
+
// each: "{{birdie_import}}",
175
+
// with: "import birdie.{snap as s}",
176
+
// )
177
+
// |> string.replace(each: "{{snap_invocation}}", with: "s")
178
+
// |> assert_titles
179
+
// |> pretty_titles
180
+
// |> birdie.snap(title: "can find prefix titles when calling aliased `s`")
181
+
// }
182
+
183
+
pub fn we_get_an_error_for_same_literal_titles_test() {
184
+
"
185
+
import birdie
186
+
pub fn wibble_test() {
187
+
birdie.snap(title: \"wibble\", content: \"\")
188
+
birdie.snap(title: \"wibble\", content: \"\")
189
+
}
190
+
"
191
+
|> assert_error
192
+
|> should.equal(titles.DuplicateLiteralTitles(
193
+
title: "wibble",
194
+
one: titles.TestInfo(file: "my/module", test_name: "wibble_test"),
195
+
other: titles.TestInfo(file: "my/module", test_name: "wibble_test"),
196
+
))
197
+
}
198
+
199
+
pub fn can_read_the_snap_titles_from_the_project_itself_test() {
200
+
titles.from_test_directory()
201
+
|> should.be_ok
202
+
|> pretty_titles
203
+
|> birdie.snap(title: "can read the snap titles from the project itself")
204
+
}