import argv import birdie/internal/diff.{type DiffLine, DiffLine} import birdie/internal/project import birdie/internal/titles import edit_distance/levenshtein import filepath import gleam/bool import gleam/erlang import gleam/int import gleam/io import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import gleam_community/ansi import justin import rank import simplifile const birdie_version = "1.2.2" const birdie_snapshots_folder = "birdie_snapshots" const hint_review_message = "run `gleam run -m birdie` to review the snapshots" type Error { SnapshotWithEmptyTitle CannotCreateSnapshotsFolder(reason: simplifile.FileError) CannotReadAcceptedSnapshot(reason: simplifile.FileError, source: String) CannotReadNewSnapshot(reason: simplifile.FileError, source: String) CannotSaveNewSnapshot( reason: simplifile.FileError, title: String, destination: String, ) CannotReadSnapshots(reason: simplifile.FileError, folder: String) CannotRejectSnapshot(reason: simplifile.FileError, snapshot: String) CannotAcceptSnapshot(reason: simplifile.FileError, snapshot: String) CannotReadUserInput CorruptedSnapshot(source: String) CannotFindProjectRoot(reason: simplifile.FileError) CannotGetTitles(reason: titles.Error) } // --- THE SNAPSHOT TYPE ------------------------------------------------------- type New type Accepted type Snapshot(status) { Snapshot(title: String, content: String, info: Option(titles.TestInfo)) } // --- SNAP -------------------------------------------------------------------- /// Performs a snapshot test with the given title, saving the content to a new /// snapshot file. All your snapshots will be stored in a folder called /// `birdie_snapshots` in the project's root. /// /// The test will fail if there already is an accepted snapshot with the same /// title and a different content. /// The test will also fail if there's no accepted snapshot with the same title /// to make sure you will review new snapshots as well. /// /// > 🚨 A snapshot is saved to a file named after its title, so all titles /// > should be unique! Otherwise you'd end up comparing unrelated snapshots. /// /// > đŸĻ‍âŦ› To review all your snapshots interactively you can run /// > `gleam run -m birdie`. /// > /// > To get an help text and all the available options you can run /// > `gleam run -m birdie help`. /// pub fn snap(content content: String, title title: String) -> Nil { case do_snap(content, title) { Ok(Same) -> Nil Ok(NewSnapshotCreated(snapshot, destination: _)) -> { let hint_message = ansi.yellow(hint_review_message) let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint") let box = new_snapshot_box(snapshot, [hint]) io.println_error("\n\n" <> box <> "\n") panic as "Birdie snapshot test failed" } Ok(Different(accepted, new)) -> { let hint_message = ansi.yellow(hint_review_message) let hint = InfoLineWithTitle(hint_message, DoNotSplit, "hint") let box = diff_snapshot_box(accepted, new, [hint]) io.println_error("\n\n" <> box <> "\n") panic as "Birdie snapshot test failed" } Error(error) -> { let panic_message = "Birdie snapshot test failed\n" <> explain(error) panic as panic_message } } } type Outcome { NewSnapshotCreated(snapshot: Snapshot(New), destination: String) Different(accepted: Snapshot(Accepted), new: Snapshot(New)) Same } fn do_snap(content: String, title: String) -> Result(Outcome, Error) { use _ <- result.try(validate_snapshot_title(title)) // We have to find the snapshot folder since the `gleam test` command might // be run from any subfolder we can't just assume we're in the project's root. use folder <- result.try(find_snapshots_folder()) // 🚨 When snapping with the `snap` function we don't try and get the test // info from the file it's defined in. That would require re-parsing the test // directory every single time the `snap` function is called. We just put the // `info` field to `None`. // // That additional data will be retrieved and updated during the review // process where the parsing of the test directory can be done just once for // all the tests. // // 💡 TODO: I could investigate using a shared cache or something but it // sounds like a pain to implement and should have to work for both // targets. let new = Snapshot(title: title, content: content, info: None) let new_snapshot_path = new_destination(new, folder) let accepted_snapshot_path = to_accepted_path(new_snapshot_path) // Find an accepted snapshot with the same title to make a comparison. use accepted <- result.try(read_accepted(accepted_snapshot_path)) case accepted { // If there's no accepted snapshot then we save the new one as there's no // comparison to be made. None -> { use _ <- result.try(save(new, to: new_snapshot_path)) Ok(NewSnapshotCreated(snapshot: new, destination: new_snapshot_path)) } // If there's a corresponding accepted snapshot we compare it with the new // one. Some(accepted) -> { // If the new snapshot is the same as the old one then there's no need to // save it in a `.new` file: we can just say they are the same. use <- bool.guard(when: accepted.content == new.content, return: Ok(Same)) use _ <- result.try(save(new, to: new_snapshot_path)) Ok(Different(accepted, new)) } } } fn validate_snapshot_title(title: String) -> Result(Nil, Error) { case string.trim(title) { "" -> Error(SnapshotWithEmptyTitle) _ -> Ok(Nil) } } // --- SNAPSHOT CONTENT DIFFING ------------------------------------------------ fn to_diff_lines( accepted: Snapshot(Accepted), new: Snapshot(New), ) -> List(DiffLine) { let Snapshot(title: _, content: accepted_content, info: _) = accepted let Snapshot(title: _, content: new_content, info: _) = new diff.histogram(accepted_content, new_content) } // --- SNAPSHOT (DE)SERIALISATION ---------------------------------------------- fn split_n( string, times n: Int, on separator: String, ) -> Result(#(List(String), String), Nil) { case n <= 0 { True -> Ok(#([], string)) False -> { use #(line, rest) <- result.try(string.split_once(string, on: separator)) use #(lines, rest) <- result.try(split_n(rest, n - 1, separator)) Ok(#([line, ..lines], rest)) } } } fn deserialise(raw: String) -> Result(Snapshot(a), Nil) { let raw = string.replace(each: "\r\n", in: raw, with: "\n") case split_n(raw, 4, "\n") { Ok(#(["---", "version: " <> _, "title: " <> title, "---"], content)) -> Ok(Snapshot(title: title, content: content, info: None)) Ok(_) | Error(_) -> case split_n(raw, 6, "\n") { Ok(#( [ "---", "version: " <> _, "title: " <> title, "file: " <> file, "test_name: " <> test_name, "---", ], content, )) -> Ok(Snapshot( title: title, content: content, info: Some(titles.TestInfo(file: file, test_name: test_name)), )) Ok(_) | Error(_) -> Error(Nil) } } } fn serialise(snapshot: Snapshot(New)) -> String { let Snapshot(title: title, content: content, info: info) = snapshot let info_lines = case info { None -> [] Some(titles.TestInfo(file: file, test_name: test_name)) -> [ "file: " <> file, "test_name: " <> test_name, ] } [ [ "---", "version: " <> birdie_version, // We escape the newlines in the title so that it fits on one line and it's // easier to parse. // Is this the best course of action? Probably not. // Does this make my life a lot easier? Absolutely! 😁 "title: " <> string.replace(title, each: "\n", with: "\\n"), ], info_lines, ["---", content], ] |> list.concat |> string.join(with: "\n") } // --- FILE SYSTEM OPERATIONS -------------------------------------------------- /// Save a new snapshot to a given path. /// fn save(snapshot: Snapshot(New), to destination: String) -> Result(Nil, Error) { // Just to make sure I'm not messing up something anywhere else in the code // base: a new snapshot's destination MUST always end with a `.new` extension. // If it doesn't there's a fatal error in my code and I should fix it. case string.ends_with(destination, ".new") { False -> panic as "Looks like I've messed up something, all new snapshots should have the `.new` extension" True -> simplifile.write(to: destination, contents: serialise(snapshot)) |> result.map_error(CannotSaveNewSnapshot( reason: _, title: snapshot.title, destination: destination, )) } } /// Read an accepted snapshot which might be missing. /// fn read_accepted(source: String) -> Result(Option(Snapshot(Accepted)), Error) { case simplifile.read(source) { Ok(content) -> case deserialise(content) { Ok(snapshot) -> Ok(Some(snapshot)) Error(Nil) -> Error(CorruptedSnapshot(source)) } Error(simplifile.Enoent) -> Ok(None) Error(reason) -> Error(CannotReadAcceptedSnapshot(reason: reason, source: source)) } } /// Read a new snapshot. /// /// > â„šī¸ Notice the different return type compared to `read_accepted`: when we /// > try to read a new snapshot we are sure it's there (because we've listed /// > the directory or something else) so if it's not present that's an error /// > and we don't return an `Ok(None)`. /// fn read_new(source: String) -> Result(Snapshot(New), Error) { case simplifile.read(source) { Ok(content) -> result.replace_error(deserialise(content), CorruptedSnapshot(source)) Error(reason) -> Error(CannotReadNewSnapshot(reason: reason, source: source)) } } /// List all the new snapshots in a folder. Every file is automatically /// prepended with the folder so you get the full path of each file. /// fn list_new_snapshots(in folder: String) -> Result(List(String), Error) { case simplifile.read_directory(folder) { Error(reason) -> Error(CannotReadSnapshots(reason: reason, folder: folder)) Ok(files) -> Ok({ use file <- list.filter_map(files) case filepath.extension(file) { // Only keep the files with the ".new" extension and join their name // with the folder's path. Ok("new") -> Ok(filepath.join(folder, file)) _ -> Error(Nil) } }) } } /// Finds the snapshots folder at the root of the project the command is run /// into. If it's not present the folder is created automatically. /// fn find_snapshots_folder() -> Result(String, Error) { let result = result.map_error(project.find_root(), CannotFindProjectRoot) use project_root <- result.try(result) let snapshots_folder = filepath.join(project_root, birdie_snapshots_folder) case simplifile.create_directory(snapshots_folder) { Ok(Nil) | Error(simplifile.Eexist) -> Ok(snapshots_folder) Error(error) -> Error(CannotCreateSnapshotsFolder(error)) } } fn accept_snapshot( new_snapshot_path: String, titles: titles.Titles, ) -> Result(Nil, Error) { use snapshot <- result.try(read_new(new_snapshot_path)) let Snapshot(title: title, content: content, info: _) = snapshot let accepted_snapshot_path = to_accepted_path(new_snapshot_path) case titles.find(titles, title) { // We could find additional info about the test so we add it to the snapshot // before saving it! So we delete the `new` file and write an `accepted` // one with all the new info we found. Ok(titles.Literal(info)) | Ok(titles.Prefix(info, _)) -> { let delete_new_snapshot = simplifile.delete(new_snapshot_path) |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path)) use _ <- result.try(delete_new_snapshot) Snapshot(title: title, content: content, info: Some(info)) |> serialise |> simplifile.write(to: accepted_snapshot_path) |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot_path)) } Error(_) -> // Birdie couldn't find any additional info about the given test, so // we can just move the `new` snapshot to the `accepted` one. simplifile.rename_file(new_snapshot_path, accepted_snapshot_path) |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path)) } } fn reject_snapshot(new_snapshot_path: String) -> Result(Nil, Error) { simplifile.delete(new_snapshot_path) |> result.map_error(CannotRejectSnapshot(_, new_snapshot_path)) } // --- UTILITIES --------------------------------------------------------------- /// Turns a snapshot's title into a file name stripping it of all dangerous /// characters (or at least those I could think ok 😁). /// fn file_name(title: String) -> String { string.replace(each: "/", with: " ", in: title) |> string.replace(each: "\\", with: " ") |> string.replace(each: "\n", with: " ") |> string.replace(each: "\t", with: " ") |> string.replace(each: "\r", with: " ") |> string.replace(each: ".", with: " ") |> string.replace(each: ":", with: " ") |> justin.snake_case } /// Returns the path where a new snapshot should be saved. /// fn new_destination(snapshot: Snapshot(New), folder: String) -> String { filepath.join(folder, file_name(snapshot.title)) <> ".new" } /// Turns a new snapshot path into the path of the corresponding accepted /// snapshot. /// fn to_accepted_path(file: String) -> String { // This just replaces the `.new` extension with the `.accepted` one. filepath.strip_extension(file) <> ".accepted" } // --- PRETTY PRINTING --------------------------------------------------------- fn explain(error: Error) -> String { let heading = fn(reason) { "[" <> ansi.bold(string.inspect(reason)) <> "] " } let message = case error { SnapshotWithEmptyTitle -> "A snapshot cannot have the empty string as a title." CannotCreateSnapshotsFolder(reason: reason) -> heading(reason) <> "I couldn't create the snapshots folder." CannotReadAcceptedSnapshot(reason: reason, source: source) -> heading(reason) <> "I couldn't read the accepted snapshot from " <> ansi.italic("\"" <> source <> "\".") CannotReadNewSnapshot(reason: reason, source: source) -> heading(reason) <> "I couldn't read the new snapshot from " <> ansi.italic("\"" <> source <> "\".") CannotSaveNewSnapshot( reason: reason, title: title, destination: destination, ) -> heading(reason) <> "I couldn't save the snapshot " <> ansi.italic("\"" <> title <> "\" ") <> "to " <> ansi.italic("\"" <> destination <> "\".") CannotReadSnapshots(reason: reason, folder: _) -> heading(reason) <> "I couldn't read the snapshots folder's contents." CannotRejectSnapshot(reason: reason, snapshot: snapshot) -> heading(reason) <> "I couldn't reject the snapshot " <> ansi.italic("\"" <> snapshot <> "\".") CannotAcceptSnapshot(reason: reason, snapshot: snapshot) -> heading(reason) <> "I couldn't accept the snapshot " <> ansi.italic("\"" <> snapshot <> "\".") CannotReadUserInput -> "I couldn't read the user input." CorruptedSnapshot(source: source) -> "It looks like " <> ansi.italic("\"" <> source <> "\"\n") <> "is not a valid snapshot.\n" <> "This might happen when someone modifies its content.\n" <> "Try deleting the snapshot and recreating it." CannotFindProjectRoot(reason: reason) | CannotGetTitles(titles.CannotFindProjectRoot(reason: reason)) -> heading(reason) <> "I couldn't locate the project's root where the snapshot's" <> " folder should be." CannotGetTitles(titles.CannotReadTestDirectory(reason: reason)) -> heading(reason) <> "I couldn't list the contents of the test folder." CannotGetTitles(titles.CannotReadTestFile(reason: reason, file: file)) -> heading(reason) <> "I couldn't read the test file " <> ansi.italic("\"" <> file <> "\"\n") CannotGetTitles(titles.DuplicateLiteralTitles( title: title, one: titles.TestInfo(file: one_file, test_name: one_test_name), other: titles.TestInfo(file: other_file, test_name: other_test_name), )) -> { let same_file = one_file == other_file let same_function = one_test_name == other_test_name let location = case same_file, same_function { True, True -> "Both tests are defined in:\n\n " <> ansi.italic(to_function_name(one_file, one_test_name)) _, _ -> "One test is defined in:\n\n " <> ansi.italic(to_function_name(one_file, one_test_name)) <> "\n\nWhile the other is defined in:\n\n " <> ansi.italic(to_function_name(other_file, other_test_name)) } "It looks like there's some snapshot tests sharing the same title: " <> ansi.italic("\"" <> title <> "\"") <> " Snapshot titles " <> ansi.bold("must be unique") <> " or you would run into strange diffs when reviewing them, try changing one of those. " <> location } CannotGetTitles(titles.OverlappingPrefixes(..)) -> panic as "Prefixes are not implemented yet" CannotGetTitles(titles.PrefixOverlappingWithLiteralTitle(..)) -> panic as "Prefixes are not implemented yet" } message } fn to_function_name(file: String, function_name: String) -> String { let module_name = case file { "./test/" <> rest -> filepath.strip_extension(rest) _ -> filepath.strip_extension(file) } module_name <> ".{" <> function_name <> "}" } type InfoLine { InfoLineWithTitle(content: String, split: Split, title: String) InfoLineWithNoTitle(content: String, split: Split) } type Split { DoNotSplit SplitWords Truncate } fn snapshot_default_lines(snapshot: Snapshot(status)) -> List(InfoLine) { let Snapshot(title: title, content: _, info: info) = snapshot case info { None -> [InfoLineWithTitle(title, SplitWords, "title")] Some(titles.TestInfo(file: file, test_name: test_name)) -> [ InfoLineWithTitle(title, SplitWords, "title"), InfoLineWithTitle(file, Truncate, "file"), InfoLineWithTitle(test_name, Truncate, "name"), ] } } fn new_snapshot_box( snapshot: Snapshot(New), additional_info_lines: List(InfoLine), ) -> String { let Snapshot(title: _, content: content, info: _) = snapshot let content = string.split(content, on: "\n") |> list.index_map(fn(line, i) { DiffLine(number: i + 1, line: line, kind: diff.New) }) pretty_box( "new snapshot", content, list.concat([snapshot_default_lines(snapshot), additional_info_lines]), ) } fn diff_snapshot_box( accepted: Snapshot(Accepted), new: Snapshot(New), additional_info_lines: List(InfoLine), ) -> String { pretty_box( "mismatched snapshots", to_diff_lines(accepted, new), [ snapshot_default_lines(accepted), additional_info_lines, [ InfoLineWithNoTitle("", DoNotSplit), InfoLineWithNoTitle(ansi.red("- old snapshot"), DoNotSplit), InfoLineWithNoTitle(ansi.green("+ new snapshot"), DoNotSplit), ], ] |> list.concat, ) } fn pretty_box( title: String, content_lines: List(DiffLine), info_lines: List(InfoLine), ) -> String { let width = terminal_width() let assert Ok(padding) = { let lines_count = list.length(content_lines) + 1 use digits <- result.try(int.digits(lines_count, 10)) Ok(list.length(digits) * 2 + 5) } // Make the title line. let title_length = string.length(title) let title_line_right = string.repeat("─", width - 5 - title_length) let title_line = "── " <> title <> " ─" <> title_line_right // Make the pretty info lines. let info_lines = list.map(info_lines, pretty_info_line(_, width)) |> string.join("\n") // Add numbers to the content's lines. let content = list.map(content_lines, pretty_diff_line(_, padding)) |> string.join(with: "\n") // The open and closed delimiters for the box main content. let left_padding_line = string.repeat("─", padding) let right_padding_line = string.repeat("─", width - padding - 1) let open_line = left_padding_line <> "â”Ŧ" <> right_padding_line let closed_line = left_padding_line <> "┴" <> right_padding_line // Assemble everything together with some empty lines to allow the content to // breath a little. [title_line, "", info_lines, "", open_line, content, closed_line] |> string.join(with: "\n") } fn pretty_info_line(line: InfoLine, width: Int) -> String { let #(prefix, prefix_length) = case line { InfoLineWithNoTitle(..) -> #(" ", 2) InfoLineWithTitle(title: title, ..) -> #( " " <> ansi.blue(title <> ": "), string.length(title) + 4, ) } case line.split { Truncate -> prefix <> truncate(line.content, width - prefix_length) DoNotSplit -> prefix <> line.content SplitWords -> case to_lines(line.content, width - prefix_length) { [] -> prefix [line, ..lines] -> { use acc, line <- list.fold(over: lines, from: prefix <> line) acc <> "\n" <> string.repeat(" ", prefix_length) <> line } } } } fn pretty_diff_line(diff_line: DiffLine, padding: Int) -> String { let DiffLine(number: number, line: line, kind: kind) = diff_line let #(pretty_number, pretty_line, separator) = case kind { diff.Shared -> #( int.to_string(number) |> string.pad_left(to: padding - 1, with: " ") |> ansi.dim, ansi.dim(line), " │ ", ) diff.New -> #( int.to_string(number) |> string.pad_left(to: padding - 1, with: " ") |> ansi.green |> ansi.bold, ansi.green(line), ansi.green(" + "), ) diff.Old -> { let number = { " " <> int.to_string(number) } |> string.pad_right(to: padding - 1, with: " ") #(ansi.red(number), ansi.red(line), ansi.red(" - ")) } } pretty_number <> separator <> pretty_line } // --- STRING UTILITIES -------------------------------------------------------- fn truncate(string: String, max_length: Int) -> String { case string.length(string) > max_length { False -> string True -> string.to_graphemes(string) |> list.take(max_length - 3) |> string.join(with: "") |> string.append("...") } } fn to_lines(string: String, max_length: Int) -> List(String) { // We still want to keep the original lines, so we work line by line. use line <- list.flat_map(string.split(string, on: "\n")) let words = string.split(line, on: " ") do_to_lines([], "", 0, words, max_length) } fn do_to_lines( lines: List(String), line: String, line_length: Int, words: List(String), max_length: Int, ) -> List(String) { case words { [] -> case line == "" { True -> list.reverse(lines) False -> list.reverse([line, ..lines]) } [word, ..rest] -> { let word_length = string.length(word) let new_line_length = word_length + line_length + 1 // ^ With the +1 we account for the whitespace that separates words! case new_line_length > max_length { True -> do_to_lines([line, ..lines], "", 0, words, max_length) False -> { let new_line = case line { "" -> word _ -> line <> " " <> word } do_to_lines(lines, new_line, new_line_length, rest, max_length) } } } } } // --- CLI COMMAND ------------------------------------------------------------- type Command { Review AcceptAll RejectAll Help } fn command_to_string(command: Command) -> String { case command { Review -> "review" AcceptAll -> "accept-all" RejectAll -> "reject-all" Help -> "help" } } /// Reviews the snapshots in the project's folder. /// This function will behave differently depending on the command line /// arguments provided to the program. /// To have a look at all the available options you can run /// `gleam run -m birdie help`. /// /// > đŸĻ‍âŦ› The recommended workflow is to first run your gleeunit tests with /// > `gleam test` and then review any new/failing snapshot manually running /// > `gleam run -m birdie`. /// > /// > And don't forget to commit your snapshots! Those should be treated as code /// > and checked with the vcs you're using. /// pub fn main() -> Nil { let args = argv.load().arguments case parse_command(args) { Ok(command) -> run_command(command) Error(_) -> case args { [subcommand] -> case closest_command(subcommand) { Ok(command) -> suggest_run_command(subcommand, command) Error(Nil) -> unexpected_subcommand(subcommand) } subcommands -> more_than_one_command(subcommands) } } } fn parse_command(arguments: List(String)) { case arguments { [] | ["review"] -> Ok(Review) ["accept-all"] | ["accept", "all"] -> Ok(AcceptAll) ["reject-all"] | ["reject", "all"] -> Ok(RejectAll) ["help"] -> Ok(Help) _ -> Error(Nil) } } fn run_command(command: Command) -> Nil { case command { Review -> report_status(review()) AcceptAll -> report_status(accept_all()) RejectAll -> report_status(reject_all()) Help -> help() } } fn suggest_run_command(invalid: String, command: Command) -> Nil { let error_message = ansi.bold("Error: ") <> "\"" <> invalid <> "\" isn't a valid subcommand." io.println(ansi.red(error_message)) let msg = "I think you misspelled `" <> command_to_string(command) <> "`, would you like me to run it instead? [Y/n] " case erlang.get_line(msg) { Error(_) -> Nil Ok(line) -> case string.lowercase(line) |> string.trim { "yes" | "y" | "" -> run_command(command) _ -> io.println("\n" <> help_text()) } } } fn closest_command(to string: String) -> Result(Command, Nil) { let distance = fn(c) { command_to_string(c) |> levenshtein.distance(string) } [Review, AcceptAll, RejectAll, Help] |> list.map(fn(command) { #(command, distance(command)) }) |> list.filter(keeping: fn(command) { command.1 <= 3 }) |> list.sort(fn(one, other) { int.compare(one.1, other.1) }) |> list.first |> result.map(fn(pair) { pair.0 }) } fn review() -> Result(Nil, Error) { use snapshots_folder <- result.try(find_snapshots_folder()) let get_titles = titles.from_test_directory() use titles <- result.try(result.map_error(get_titles, CannotGetTitles)) use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) case list.length(new_snapshots) { // If there's no snapshots to review, we're done! 0 -> { io.println("No new snapshots to review.") Ok(Nil) } // If there's snapshots to review start the interactive session. n -> { let result = do_review(new_snapshots, titles, 1, n) // Despite the review process ending well or with an error, we want to // clear the screen of any garbage before showing the error explanation // or the happy completion string. // That's why we postpone the `result.try` step. clear() use _ <- result.try(result) // A nice message based on the number of snapshots :) io.println(case n { 1 -> "Reviewed one snapshot" n -> "Reviewed " <> int.to_string(n) <> " snapshots" }) Ok(Nil) } } } fn do_review( new_snapshot_paths: List(String), titles: titles.Titles, current: Int, out_of: Int, ) -> Result(Nil, Error) { case new_snapshot_paths { [] -> Ok(Nil) [new_snapshot_path, ..rest] -> { clear() // We try reading the new snapshot and the accepted one (which might be // missing). use new_snapshot <- result.try(read_new(new_snapshot_path)) // We need to add to the new test info about its location and the function // it's defined in. let new_snapshot_info = case titles.find(titles, new_snapshot.title) { Ok(titles.Prefix(info: info, ..)) | Ok(titles.Literal(info: info)) -> Some(info) Error(_) -> None } let new_snapshot = Snapshot(..new_snapshot, info: new_snapshot_info) let accepted_snapshot_path = to_accepted_path(new_snapshot_path) use accepted_snapshot <- result.try(read_accepted(accepted_snapshot_path)) let progress = ansi.dim("Reviewing ") <> ansi.bold(ansi.yellow(rank.ordinalise(current))) <> ansi.dim(" out of ") <> ansi.bold(ansi.yellow(int.to_string(out_of))) // If there's no accepted snapshot then we're just reviewing a new // snapshot. Otherwise we show a nice diff. let box = case accepted_snapshot { None -> new_snapshot_box(new_snapshot, []) Some(accepted_snapshot) -> diff_snapshot_box(accepted_snapshot, new_snapshot, []) } io.println(progress <> "\n\n" <> box <> "\n") // We ask the user what to do with this snapshot. use choice <- result.try(ask_choice()) use _ <- result.try(case choice { AcceptSnapshot -> accept_snapshot(new_snapshot_path, titles) RejectSnapshot -> reject_snapshot(new_snapshot_path) SkipSnapshot -> Ok(Nil) }) // Let's keep going with the remaining snapshots. do_review(rest, titles, current + 1, out_of) } } } /// The choice the user can make when reviewing a snapshot. /// type ReviewChoice { AcceptSnapshot RejectSnapshot SkipSnapshot } /// Asks the user to make a choice: it first prints a reminder of the options /// and waits for the user to choose one. /// Will prompt again if the choice is not amongst the possible options. /// fn ask_choice() -> Result(ReviewChoice, Error) { io.println( ansi.bold(ansi.green(" a")) <> " accept " <> ansi.dim("accept the new snapshot\n") <> ansi.bold(ansi.red(" r")) <> " reject " <> ansi.dim("reject the new snapshot\n") <> ansi.bold(ansi.yellow(" s")) <> " skip " <> ansi.dim("skip the snapshot for now\n"), ) // We clear the line of any possible garbage that might still be there from // a previous prompt of the same method. clear_line() case result.map(erlang.get_line("> "), string.trim) { Ok("a") -> Ok(AcceptSnapshot) Ok("r") -> Ok(RejectSnapshot) Ok("s") -> Ok(SkipSnapshot) // If the choice is not one of the proposed ones we move the cursor back to // the top of where it was and print everything once again, asking for a // valid option. Ok(_) -> { cursor_up(5) ask_choice() } Error(_) -> Error(CannotReadUserInput) } } fn accept_all() -> Result(Nil, Error) { io.println("Looking for new snapshots...") use snapshots_folder <- result.try(find_snapshots_folder()) use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) let get_titles = titles.from_test_directory() use titles <- result.try(result.map_error(get_titles, CannotGetTitles)) case list.length(new_snapshots) { 0 -> io.println("No new snapshots to accept.") 1 -> io.println("Accepting one new snapshot.") n -> io.println("Accepting " <> int.to_string(n) <> " new snapshots.") } list.try_each(new_snapshots, accept_snapshot(_, titles)) } fn reject_all() -> Result(Nil, Error) { io.println("Looking for new snapshots...") use snapshots_folder <- result.try(find_snapshots_folder()) use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder)) case list.length(new_snapshots) { 0 -> io.println("No new snapshots to reject.") 1 -> io.println("Rejecting one new snapshot.") n -> io.println("Rejecting " <> int.to_string(n) <> " new snapshots.") } list.try_each(new_snapshots, reject_snapshot) } fn help() -> Nil { let version = ansi.green("đŸĻ‍âŦ› birdie ") <> "v" <> birdie_version io.println(version <> "\n\n" <> help_text()) } fn help_text() -> String { ansi.yellow("USAGE:\n") <> " gleam run -m birdie [ ]\n\n" <> ansi.yellow("SUBCOMMANDS:\n") <> ansi.green(" review ") <> "Review all new snapshots one by one\n" <> ansi.green(" accept-all ") <> "Accept all new snapshots\n" <> ansi.green(" reject-all ") <> "Reject all new snapshots\n" <> ansi.green(" help ") <> "Show this help text\n" } fn unexpected_subcommand(subcommand: String) -> Nil { let error_message = ansi.bold("Error: ") <> "\"" <> subcommand <> "\" isn't a valid subcommand." io.println(ansi.red(error_message) <> "\n\n" <> help_text()) } fn more_than_one_command(subcommands: List(String)) -> Nil { let error_message = ansi.bold("Error: ") <> "I can only run one subcommand at a time, but more than one were provided: " <> string.join(list.map(subcommands, fn(s) { "\"" <> s <> "\"" }), ", ") io.println(ansi.red(error_message) <> "\n\n" <> help_text()) } fn report_status(result: Result(Nil, Error)) -> Nil { case result { Ok(Nil) -> io.println(ansi.green("đŸĻ‍âŦ› Done!")) Error(error) -> io.println_error("❌ " <> explain(error)) } } // --- FFI --------------------------------------------------------------------- /// Clear the screen. /// @external(erlang, "birdie_ffi_erl", "clear") fn clear() -> Nil /// Move the cursor up a given number of lines. /// @external(erlang, "birdie_ffi_erl", "cursor_up") fn cursor_up(n: Int) -> Nil /// Clear the line the cursor is currently on. /// @external(erlang, "birdie_ffi_erl", "clear_line") fn clear_line() -> Nil fn terminal_width() -> Int { result.unwrap(do_terminal_width(), or: 80) } @external(erlang, "birdie_ffi_erl", "terminal_width") @external(javascript, "./birdie_ffi_js.mjs", "terminal_width") fn do_terminal_width() -> Result(Int, Nil) { // We have a default implementation that will fail on all other targets so // that it can be unwrapped to a default value and we stay compatible with // all future Gleam's targets. Error(Nil) }