🐦‍⬛ Snapshot testing in Gleam

:sparkles: Fail the review if there's tests with duplicate titles

* :construction: Add `birdie/internal/titles` module

* :sparkles: Refine titles implementation and add error messages

* :memo: Update README

* :memo: CHANGELOG!

authored by giacomocavalieri.me and committed by GitHub 25d44c7a 590a2225

+4
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Unreleased 4 + 5 + - ✨ Fail the review if there's tests with duplicate titles 6 + 3 7 ## v1.0.4 - 2024-02-01 4 8 5 9 - ➖ Drop the `glam` dependency
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 1 --- 2 - version: 1.0.3 2 + version: 1.0.4 3 3 title: diffing a case expression 4 + file: ./test/birdie_test.gleam 5 + test_name: complex_function_test 4 6 --- 5 7 case foo(bar, baz) { 6 8 True ->
+3 -1
birdie_snapshots/my_favourite_number_wrapped_in_a_result.accepted
··· 1 1 --- 2 - version: 1.0.3 2 + version: 1.0.4 3 3 title: my favourite number wrapped in a result 4 + file: ./test/birdie_test.gleam 5 + test_name: a_result_test 4 6 --- 5 7 Ok(11)
+3 -1
birdie_snapshots/my_first_snapshot.accepted
··· 1 1 --- 2 - version: 1.0.3 2 + version: 1.0.4 3 3 title: my first snapshot 4 + file: ./test/birdie_test.gleam 5 + test_name: hello_birdie_test 4 6 --- 5 7 🐦‍⬛ smile for the birdie!
+5
birdie_snapshots/my_first_snapshot.new
··· 1 + --- 2 + version: 1.0.4 3 + title: my first snapshot 4 + --- 5 + 🐦‍⬛ smile for birdie!
+3 -1
birdie_snapshots/snapping_a_list_of_numbers.accepted
··· 1 1 --- 2 - version: 1.0.3 2 + version: 1.0.4 3 3 title: snapping a list of numbers 4 + file: ./test/birdie_test.gleam 5 + test_name: list_test 4 6 --- 5 7 [ 1, 2, 3, 4 ]
+2
gleam.toml
··· 17 17 gleam_erlang = "~> 0.24" 18 18 rank = "~> 1.0" 19 19 gleeunit = "~> 1.0" 20 + glance = "~> 0.8" 21 + trie_again = "~> 1.1"
+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
··· 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
··· 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
··· 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
··· 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 + }