Full Code of giacomocavalieri/birdie for AI

main 1404298b945d cached
34 files
153.5 KB
41.5k tokens
3 symbols
1 requests
Download .txt
Repository: giacomocavalieri/birdie
Branch: main
Commit: 1404298b945d
Files: 34
Total size: 153.5 KB

Directory structure:
gitextract_pg72u7m9/

├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── setup.yml
│       └── test.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── birdie.tape
├── gleam.toml
├── manifest.toml
├── src/
│   ├── birdie/
│   │   └── internal/
│   │       ├── analyser.gleam
│   │       ├── cli.gleam
│   │       ├── diagnostic.gleam
│   │       ├── diff.gleam
│   │       ├── project.gleam
│   │       └── version.gleam
│   ├── birdie.gleam
│   ├── birdie_ffi.erl
│   └── birdie_ffi.mjs
└── test/
    ├── birdie_snapshots/
    │   ├── a_result_test.accepted
    │   ├── complex_function_test.accepted
    │   ├── hello_birdie_test.accepted
    │   ├── list_test.accepted
    │   ├── multi_line_diagnostic.accepted
    │   ├── single_line_diagnostic_with_no_tooltip_text.accepted
    │   ├── single_line_diagnostic_with_secondary_label_above.accepted
    │   ├── single_line_diagnostic_with_secondary_label_below.accepted
    │   ├── single_line_diagnostic_with_secondary_label_on_same_line,_secondary_label_is_ignored.accepted
    │   ├── single_line_diagnostic_with_secondary_label_right_above.accepted
    │   ├── single_line_diagnostic_with_secondary_label_right_below.accepted
    │   └── single_line_diagnostic_with_tooltip_text.accepted
    ├── birdie_test/
    │   ├── cli_test.gleam
    │   └── diagnostic_test.gleam
    └── birdie_test.gleam

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/FUNDING.yml
================================================
github: [giacomocavalieri]


================================================
FILE: .github/workflows/setup.yml
================================================
name: shared

on:
  workflow_call:

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "28"
          gleam-version: "1.16.0"
          rebar3-version: "3"


================================================
FILE: .github/workflows/test.yml
================================================
name: Test

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "28"
          gleam-version: "1.16.0"
          rebar3-version: "3"
      - run: gleam format --check src test

  test-erl:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        otp-version: [27, 28]
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: ${{ matrix.otp-version }}
          gleam-version: "1.16.0"
          rebar3-version: "3"
      - run: gleam test --target=erl

  stale-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "28"
          gleam-version: "1.16.0"
          rebar3-version: "3"
      - run: gleam test --target=erl
      - run: gleam run -m birdie stale check

  test-node:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: ["latest"]
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "28"
          gleam-version: "1.16.0"
          rebar3-version: "3"
      - uses: actions/setup-node@v6
        with:
          node-version: ${{ matrix.node-version }}
      - run: gleam test --target=js --runtime=node

  test-deno:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        deno-version: ["latest", "lts"]
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "28"
          gleam-version: "1.16.0"
          rebar3-version: "3"
      - uses: denoland/setup-deno@v2
        with:
          deno-version: ${{ matrix.deno-version }}
      - run: gleam test --target=js --runtime=deno

  test-bun:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        bun-version: ["latest"]
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: "28"
          gleam-version: "1.16.0"
          rebar3-version: "3"
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: ${{ matrix.bun-version }}
      - run: gleam test --target=js --runtime=bun


================================================
FILE: .gitignore
================================================
*.beam
*.ez
/build
erl_crash.dump


================================================
FILE: CHANGELOG.md
================================================
# Changelog

## 2.0.0 - 2026-05-03

- Birdie snapshots are now located under the `test/birdie_snapshots` directory.
  When running a `gleam run -m birdie` for the first time in an old project,
  birdie will automatically move any existing snapshot.
- The look of birdie's error messages has been improved.

## 1.5.5 - 2026-04-18

- Updated the `gleam_stdlib` constraint to allow using `v1.0.0`!

## 1.5.4 - 2026-01-28

- Improved the error message birdie produces when it can't open the temporary
  file due to not having the right permission.

## 1.5.3 - 2025-12-23 🎄 Happy Holidays!

- Fixed a bug that inadvertently made it so the `birdie.snap` function no longer
  supported the JavaScript target.

## 1.5.2 - 2025-12-11

- Updated the `glance` dependency to `>= 6.0.0 and < 7.0.0`.

## 1.5.1 - 2025-12-11

- The CLI has been improved with better error messages and a nicer overall look.
- Birdie now has better tooling to deal with stale snapshots. A stale snapshot
  is a snapshot that is no longer referenced by any snapshot test and so it
  could be safely removed.
  It's easy to end up having stale snapshots, especially when changing the name
  of a snapshot and forgetting to remove the olde one.
  The new `stale` command allows to:
  - `gleam run -m birdie stale check` to check if there's any stale snapshot
  - `gleam run -m birdie stale delete` to remove any stale snapshot

## 1.4.1 - 2025-09-28

- Updated the `edit_distance` dependency to `>= 3.0.0 and < 4.0.0`.

## 1.4.0 - 2025-08-20

- Birdie will generate valid snapshot files by adding an empty line at the end
  of each.

## 1.3.2 - 2025-07-28

- Stop using deprecated `int.digits` from `gleam_stdlib`.

## 1.3.1 - 2025-05-30

- Updated the `glance` dependency to `>= 5.0.0 and < 6.0.0`.

## 1.3.0 - 2025-05-16

- When reviewing snapshots with birdie there's now an option to hide the diff
  view.
- When running `gleam run -m birdie` birdie will now update all the accepted
  snapshots' info if they've been moved to a different module.

## 1.2.7 - 2025-04-27

- ⬆️ Update `glance` to `>= 2.0.0 and < 4.0.0`.

## 1.2.6 - 2025-02-16

- 🔥 Move ffi code to pure Gleam implementation.

## 1.2.5 - 2024-12-20

- ⬆️ Update `glance` to `>= 2.0.0 and < 3.0.0`.

## 1.2.4 - 2024-11-20

- ⬆️ Update `stdlib` to `>= 0.43.0 and < 1.0.0` and remove deprecated code.

## 1.2.3 - 2024-09-06

- 🐛 Fixed a bug where birdie would strip `\r\n` out of a snapshot content

## 1.2.2 - 2024-09-06

- 🐛 Fixed a bug where snapshot tests would fail on Windows

## v1.2.1 - 2024-08-20

- ⬆️ Update `stdlib` to `>= 0.40.0 and < 1.0.0`

## v1.2.0 - 2024-08-12

- ✨ Birdie can now suggest and run the correct command if it can tell you've
  made a typo

## v1.1.8 - 2024-05-28

- ⬆️ Update `stdlib` to `>= 0.39.0 and < 1.0.0`

## v1.1.7 - 2024-05-28

- ⬆️ Update `simplifile` to `>= 2.0.1 and < 3.0.0`

## v1.1.6 - 2024-05-28

- ⬆️ Change dependencies contraints

## v1.1.5 - 2024-05-10

- 🧑🏻‍💻 No longer fail the process review if Glance cannot parse a test module.
  Instead title data will simply be omitted.

## v1.1.4 - 2024-04-26

- 🐛 Ignore non Gleam files in the test directory

## v1.1.3 - 2024-04-20

- ⬆️ Update glance dependency

## v1.1.2 - 2024-04-11

- 🐛 Make sure no snapshot with an empty title is accepted

## v1.1.1 - 2024-04-07

- ⬆️ Update filepath from `~> 0.1` to `~> 1.0`
- ➖ Drop `gap` dependency
- ➖ Drop `gleeunit` dependency

## v1.1.0 - 2024-03-12

- ✨ Fail the review if there's tests with duplicate titles

## v1.0.4 - 2024-02-01

- ➖ Drop the `glam` dependency

## v1.0.3 - 2024-02-01

- 🧑🏻‍💻 Improve diffs look

## v1.0.2 - 2024-01-28

- 📝 Improve `birdie.main` documentation

## v1.0.1 - 2024-01-27

- 🐛 Fix a bug with js FFI code

## v1.0.0 - 2024-01-27

- 🎉 First release!


================================================
FILE: LICENSE
================================================
                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.


================================================
FILE: README.md
================================================
# 🐦‍⬛ Birdie - snapshot testing in Gleam

[![Package Version](https://img.shields.io/hexpm/v/birdie)](https://hex.pm/packages/birdie)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/birdie/)

Snapshot testing allows you to perform assertions without having to write the
expectation yourself. Birdie will store a snapshot of the expected value and
compare future runs of the same test against it. Imagine doing a
`assert got == expected` where you don't have to take care of writing the
expected output.

> If you want a video introduction to snapshot testing, you can check out
> [my talk on the topic](https://youtu.be/DpakV96jeRk?si=ODqE_geVaMIR0Qoa)
> from Code BEAM Europe.

## Writing snapshot tests with Birdie

First you'll want to add the package to your dependencies:

```sh
gleam add --dev birdie
```

To write snapshot tests you can import the `birdie` module and use the
[`snap`](https://hexdocs.pm/birdie/birdie.html#snap) function:

```gleam
import gleeunit
import birdie

pub fn main() {
  gleeunit.main()
}

pub fn hello_birdie_test() {
  "🐦‍⬛ Smile for the birdie!"
  |> birdie.snap(title: "my first snapshot")
  // All snapshots must have a unique title!
}
```

This will record a new snapshot with the given title and content. A snapshot
test will always fail on its first run until you review and accept it.
Once you've reviewed and accepted a snapshot, the test will fail only if the
snapshot's content changes; in that case you will be presented with a diff and
asked to review it once again.

A typical workflow will look like this:

- Run your tests
- If you have any new snapshots - or some of the snapshots have changed - some
  tests will fail
- Review all the new snapshots deciding if you want to keep the new version or
  the previously accepted one
- And don't forget to commit your snapshots! Those should be treated like
  code and checked with the vcs you're using

## Reviewing snapshots

Birdie also provides a CLI tool to help you in the review process: run
`gleam run -m birdie` in your project and birdie will help you interactively
review all your new snapshots.

> The CLI tool can also do more than just guide you through all your snapshots
> one by one. To check all the available options you can run
> `gleam run -m birdie help`

![image](https://github.com/giacomocavalieri/birdie/blob/main/birdie.gif?raw=true)

## FAQ

### What should my snapshots be named like?

A good idea is to give snapshots long descriptive titles that clearly state
what you're expecting to see when reviewing those.
Also all snapshots _must_ have unique names so that birdie won't mix those up,
so be careful when naming snapshots to not repeat the same title twice!

> During the review process, Birdie will try to be helpful and show you an
> error message if it can spot two tests that happen to share the same exact
> title. It will only work for snapshots that have a literal string as a title
> but it can be really helpful to spot some of those confusing bugs!

### How big should the snapshot's content be?

My recommendation is strive to have small and cohesive snapshots. Each
snapshot test should test one thing and one thing only. Having small snapshots
will make your life way easier during the review process!
It's better to review 10 small snapshots than a single huge one and you'll
see better, more focused diffs.

### Why is the snapshot content a `String`? I want to snapshot other things!

Birdie will only ever accept `String` values and it's up to you to turn your
own Gleam types into a `String` before snapping those: this way you have total
freedom and will be able to choose a format that makes sense to you and makes
things easier to review!

The time spent making snapshots nicer is well worth the effort and will pay
dividends as your test suite grows! You absolutely want to be intentional about
the look of your snapshots, crafting one that makes it easy to review them.
[I have an entire talk about this!](https://www.youtube.com/watch?v=DpakV96jeRk)

## References

This package was heavily inspired by the excellent Rust library
[`insta`](https://insta.rs), do check it out!

## Contributing

If you think there's any way to improve this package, or if you spot a bug don't
be afraid to open PRs, issues or requests of any kind!
Any contribution is welcome 💜


================================================
FILE: birdie.tape
================================================
Output birdie.gif

Set Shell "bash"
Set FontSize 24
Set Width 900
Set Height 700

Type "gleam run -m birdie"
Sleep 500ms 
Enter

Sleep 5s
Type "s"
Sleep 500ms
Enter

Sleep 5s
Type "s"
Sleep 500ms
Enter

Sleep 5s
Type "s"
Sleep 500ms
Enter

Sleep 5s


================================================
FILE: gleam.toml
================================================
name = "birdie"
version = "2.0.0"

description = "🐦‍⬛ Snapshot testing in Gleam"
licences = ["Apache-2.0"]
repository = { type = "github", user = "giacomocavalieri", repo = "birdie" }
target = "erlang"

[javascript.deno]
allow_env = ["TMPDIR", "TEMP", "TMP"]
allow_read = true
allow_write = true

[dependencies]
argv = ">= 1.0.2 and < 2.0.0"
trie_again = ">= 1.1.2 and < 2.0.0"
edit_distance = ">= 3.0.0 and < 4.0.0"
envoy = ">= 1.1.0 and < 2.0.0"
filepath = ">= 1.1.0 and < 2.0.0"
glance = ">= 6.0.0 and < 7.0.0"
gleam_community_ansi = ">= 1.4.0 and < 2.0.0"
gleam_json = ">= 3.0.0 and < 4.0.0"
gleam_stdlib = ">= 0.43.0 and < 2.0.0"
global_value = ">= 1.0.0 and < 2.0.0"
justin = ">= 1.0.1 and < 2.0.0"
rank = ">= 1.0.0 and < 2.0.0"
simplifile = ">= 2.4.0 and < 3.0.0"
term_size = ">= 1.0.1 and < 2.0.0"
tom = ">= 2.0.0 and < 3.0.0"

[dev-dependencies]
gleeunit = ">= 1.2.0 and < 2.0.0"


================================================
FILE: manifest.toml
================================================
# This file was generated by Gleam
# You typically do not need to edit this file

packages = [
  { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
  { name = "edit_distance", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "DE588BC3483ED6DBA717211B59F3178891A5FC6F1C00D415AE8C4233EAFB94B1" },
  { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" },
  { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
  { name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" },
  { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" },
  { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" },
  { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
  { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
  { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" },
  { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" },
  { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
  { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" },
  { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" },
  { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" },
  { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" },
  { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" },
  { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
  { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" },
  { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" },
  { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" },
]

[requirements]
argv = { version = ">= 1.0.2 and < 2.0.0" }
edit_distance = { version = ">= 3.0.0 and < 4.0.0" }
envoy = { version = ">= 1.1.0 and < 2.0.0" }
filepath = { version = ">= 1.1.0 and < 2.0.0" }
glance = { version = ">= 6.0.0 and < 7.0.0" }
gleam_community_ansi = { version = ">= 1.4.0 and < 2.0.0" }
gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
gleam_stdlib = { version = ">= 0.43.0 and < 2.0.0" }
gleeunit = { version = ">= 1.2.0 and < 2.0.0" }
global_value = { version = ">= 1.0.0 and < 2.0.0" }
justin = { version = ">= 1.0.1 and < 2.0.0" }
rank = { version = ">= 1.0.0 and < 2.0.0" }
simplifile = { version = ">= 2.4.0 and < 3.0.0" }
term_size = { version = ">= 1.0.1 and < 2.0.0" }
tom = { version = ">= 2.0.0 and < 3.0.0" }
trie_again = { version = ">= 1.1.2 and < 2.0.0" }


================================================
FILE: src/birdie/internal/analyser.gleam
================================================
import birdie/internal/diagnostic.{type Diagnostic}
import glance.{type Span}
import gleam/bool
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{None, Some}
import gleam/result
import gleam/set.{type Set}
import gleam/string
import gleam/uri.{type Uri}

pub opaque type Analyser {
  Analyser(
    /// A dict from module name to the titles inside that module.
    modules: Dict(Uri, AnalysedModule),
    /// A dictionary from snapshot literal title to a dictionary mapping from
    /// modules to the snapshots that it defines with that title.
    literal_titles: Dict(String, Dict(Uri, List(SnapshotTest))),
  )
}

pub type Error {
  TitleAlreadyInUse(
    module: AnalysedModule,
    /// The span covering the function name where the snapshot test is defined.
    /// For example:
    ///
    /// ```gleam
    /// pub fn wibble() {
    /// //     ^^^^^^ This!
    ///   birdie.snap(...)
    /// }
    /// ```
    ///
    test_function_name_span: Span,
    /// The span covering the title of the snapshot.
    /// For example:
    ///
    /// ```gleam
    /// pub fn wibble() {
    ///   birdie.snap(todo, title: "hello")
    /// //                         ^^^^^^^ This!
    /// }
    /// ```
    ///
    title_span: Span,
  )
}

pub type Warning {
  NonLiteralTitle(module: AnalysedModule, title_span: Span)
}

pub type Module {
  Module(path: Uri, source: String)
}

pub type AnalysedModule {
  AnalysedModule(path: Uri, source: String, snapshots: List(SnapshotTest))
}

pub type SnapshotTest {
  SnapshotTest(
    /// The title used for the snapshot. For example:
    ///
    /// ```gleam
    /// birdie.snap(todo, title: "wibble")
    /// //                       ^^^^^^^^ This is the title!
    /// ```
    ///
    title: SnapshotTitle,
    /// The span covering just the title of the `birdie.snap` call. For example:
    ///
    /// ```gleam
    ///    birdie.snap(todo, title: "wibble")
    /// //                          ^^^^^^^^ This!
    /// ```
    ///
    title_span: Span,
    /// The span covering the whole `birdie.snap` call. For example:
    ///
    /// ```gleam
    ///    birdie.snap(todo, todo)
    /// // ^^^^^^^^^^^^^^^^^^^^^^^ This!
    /// ```
    ///
    /// With pipelines, it covers the entire pipeline!
    ///
    /// ```gleam
    ///    todo |> birdie.snap(title: "wibble")
    /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This!
    /// ```
    ///
    call_span: Span,
    /// This is the name of the function where the snapshot test is defined.
    /// For example:
    ///
    /// ```gleam
    /// fn wibble_test() {
    /// // ^^^^^^^^^^^ This!
    ///   birdie.snap(...)
    /// }
    /// ```
    ///
    test_function_name: String,
    /// This is span covering the test function name.
    /// For example:
    ///
    /// ```gleam
    /// fn wibble_test() {
    /// // ^^^^^^^^^^^ This!
    ///   birdie.snap(...)
    /// }
    /// ```
    ///
    test_function_name_span: Span,
  )
}

pub type SnapshotTitle {
  LiteralTitle(title: String)
  ExpressionTitle
}

pub fn new() -> Analyser {
  Analyser(modules: dict.new(), literal_titles: dict.new())
}

pub fn remove_module(analyser: Analyser, module: Uri) -> Analyser {
  let Analyser(modules:, literal_titles:) = analyser

  case dict.get(modules, module) {
    // We were asked to remove a module which wasn't analysed in the first
    // place, or that has already been removed. We do nothing!
    Error(_) -> analyser
    // Found the module we should remove.
    Ok(module) -> {
      // We need to remove it from the analysed modules...
      let modules = dict.delete(modules, module.path)
      // ...and we also need to remove its names from all the name references!
      // In order to do that we go over all the names the module defined and
      // update them removing the reference.
      let literal_titles =
        list.fold(module.snapshots, literal_titles, fn(names, snapshot) {
          remove_snapshot_title(snapshot, in: module, from: names)
        })

      Analyser(modules:, literal_titles:)
    }
  }
}

fn remove_snapshot_title(
  snapshot: SnapshotTest,
  in module: AnalysedModule,
  from names: Dict(String, Dict(Uri, List(SnapshotTest))),
) -> Dict(String, Dict(Uri, List(SnapshotTest))) {
  case snapshot.title {
    // If the snapshot doesn't have a literal title then it can't be part of the
    // names, we just skip it!
    ExpressionTitle(..) -> names
    // Otherwise we need to remove it from the names.
    LiteralTitle(title:) ->
      case dict.get(names, title) {
        Error(_) -> names
        Ok(module_to_spans) -> {
          let module_to_spans = dict.delete(module_to_spans, module.path)
          dict.insert(names, title, module_to_spans)
        }
      }
  }
}

pub fn errors(analyser: Analyser) -> List(Error) {
  dict.fold(analyser.literal_titles, [], fn(acc, _title, snapshots) {
    let errors =
      dict.fold(snapshots, [], fn(acc, module, snapshots) {
        list.fold(snapshots, acc, fn(acc, snapshot) {
          case dict.get(analyser.modules, module) {
            Error(_) -> acc
            Ok(module) -> {
              [
                TitleAlreadyInUse(
                  module: module,
                  title_span: snapshot.title_span,
                  test_function_name_span: snapshot.test_function_name_span,
                ),
                ..acc
              ]
            }
          }
        })
      })

    case errors {
      [] | [_] -> acc
      [_, _, ..] -> errors |> list.append(acc)
    }
  })
}

pub fn warnings(analyser: Analyser) -> List(Warning) {
  dict.fold(analyser.modules, [], fn(acc, _, module) {
    list.fold(module.snapshots, acc, fn(acc, snapshot) {
      case snapshot.title {
        LiteralTitle(_) -> acc
        ExpressionTitle -> [
          NonLiteralTitle(module:, title_span: snapshot.title_span),
          ..acc
        ]
      }
    })
  })
}

// ---- QUERYING THE ANALYSER --------------------------------------------------

/// Given a string title, this returns all the snapshots that have that title
/// as their literal title, paired with the Uri for the module inside of which
/// they are defined.
/// If a snapshot has an expression title, it will not be included here.
/// If there's snapshots with duplicate titles, this will return a list with
/// multiple elements.
pub fn get_snapshot_tests(
  analyser: Analyser,
  titled title: String,
) -> List(#(Uri, SnapshotTest)) {
  case dict.get(analyser.literal_titles, title) {
    Error(_) -> []
    Ok(modules) ->
      dict.fold(over: modules, from: [], with: fn(tests, module, new_tests) {
        list.map(new_tests, fn(new_test) { #(module, new_test) })
        |> list.append(tests)
      })
  }
}

// ---- MODULE ANALYSIS --------------------------------------------------------

pub fn analyse(analyser: Analyser, module module: Module) -> Analyser {
  case analyse_module(module) {
    Error(_) -> analyser
    Ok(module) -> add_analysed_module(analyser, module)
  }
}

fn add_analysed_module(analyser: Analyser, module: AnalysedModule) -> Analyser {
  let Analyser(modules:, literal_titles:) = analyser

  // We add the module to the analysed ones...
  let modules = dict.insert(modules, module.path, module)

  // ...and we keep track of all the literal snapshot names it defines
  let literal_titles =
    list.group(module.snapshots, fn(snapshot) { snapshot.title })
    |> dict.fold(literal_titles, fn(literal_titles, title, snapshots) {
      // We only care about snapshots that share a literal title!
      case title {
        ExpressionTitle -> literal_titles
        LiteralTitle(title:) -> {
          dict.upsert(literal_titles, title, fn(references) {
            case references {
              None -> dict.from_list([#(module.path, snapshots)])
              Some(references) ->
                dict.insert(references, module.path, snapshots)
            }
          })
        }
      }
    })

  Analyser(modules:, literal_titles:)
}

/// Analyses a module, returning `Error(Nil)` if the module is not using
/// `birdie.snap` at all, or if it can't be parsed for any reason.
fn analyse_module(module: Module) -> Result(AnalysedModule, Nil) {
  // We first need to parse the module, if it contains any error there's not
  // much we can do!
  use parsed_module <- result.try(
    glance.module(module.source)
    |> result.replace_error(Nil),
  )
  // We then figure out how the `birdie.snap` function might be called inside
  // the module. If `birdie` isn't imported at all we're done! There's nothing
  // to do for the module.
  use snap_usage <- result.try(snap_usage(for: parsed_module))
  // We now go over all expressions in the module, collecting all snapshot tests
  // we can find
  let snapshots = {
    use snapshots, function <- list.fold(parsed_module.functions, [])
    let body = function.definition.body
    let function_name = function.definition.name

    // This works on the assumption that the function is typed with a normal
    // ammount of whitespace. It wouldn't work if there was more empty space;
    // however, it's a pretty safe assumption.
    let function_name_start = function.definition.location.start + 7
    let function_name_span =
      glance.Span(
        start: function_name_start,
        end: function_name_start + string.byte_size(function_name),
      )

    // pub fn wibble() {}

    // Each function has a new empty scope.
    let scope = Scope(set.new())
    use snapshots, scope, expression <- fold_statements(body, scope, snapshots)
    let snapshot =
      snapshot_test(
        snap_usage,
        scope,
        function_name,
        function_name_span,
        expression,
      )
    case snapshot {
      Ok(snapshot) -> [snapshot, ..snapshots]
      Error(_) -> snapshots
    }
  }

  case snapshots {
    [] -> Error(Nil)
    [_, ..] ->
      Ok(AnalysedModule(path: module.path, source: module.source, snapshots:))
  }
}

fn snapshot_test(
  snap_usage: SnapUsage,
  scope: Scope,
  // The name of the function inside of which we've found this expression.
  function_name: String,
  // The span covering the function name passed above.
  function_name_span: Span,
  expression: glance.Expression,
) -> Result(SnapshotTest, Nil) {
  case expression {
    // `func(title, content: content)`
    glance.Call(
      location: call_span,
      function:,
      arguments: [
        glance.UnlabelledField(title),
        glance.LabelledField("content", _location, _snapshot_content),
      ],
    )
    | // `func(content, title)`
      glance.Call(
        location: call_span,
        function:,
        arguments: [
          glance.UnlabelledField(_snapshot_content),
          glance.UnlabelledField(title),
        ],
      )
    | // `func(content, title: title)`
      glance.Call(
        location: call_span,
        function:,
        arguments: [
          glance.UnlabelledField(_snapshot_content),
          glance.LabelledField("title", _location, title),
        ],
      )
    | // `func(content: content, title)`
      glance.Call(
        location: call_span,
        function:,
        arguments: [
          glance.LabelledField("content", _location, _snapshot_content),
          glance.UnlabelledField(title),
        ],
      )
    | // `func(content: content, title: title)`
      glance.Call(
        location: call_span,
        function:,
        arguments: [
          glance.LabelledField("content", _location, _snapshot_content),
          glance.LabelledField("title", _location, title),
        ],
      )
    | // `func(title: title, content)`, `func(title: title, content: content)`
      glance.Call(
        location: call_span,
        function:,
        arguments: [
          glance.LabelledField("title", _location, title),
          _content_field,
        ],
      )
    | // `title |> func(content: content)`
      glance.BinaryOperator(
        location: call_span,
        name: glance.Pipe,
        left: title,
        right: glance.Call(
          location: _,
          function:,
          arguments: [
            glance.LabelledField("content", _location, _snapshot_content),
          ],
        ),
      )
    | // `content |> func(title)`
      glance.BinaryOperator(
        location: call_span,
        name: glance.Pipe,
        left: _snapshot_content,
        right: glance.Call(
          location: _,
          function:,
          arguments: [glance.UnlabelledField(title)],
        ),
      )
    | // `content |> func(title: title)`
      glance.BinaryOperator(
        location: call_span,
        name: glance.Pipe,
        left: _snapshot_content,
        right: glance.Call(
          location: _,
          function:,
          arguments: [glance.LabelledField("title", _location, title)],
        ),
      )
    | // `title |> func(content, title: _)`
      glance.BinaryOperator(
        location: call_span,
        name: glance.Pipe,
        left: title,
        right: glance.FnCapture(
          location: _,
          function:,
          arguments_before: [_content],
          label: Some("title"),
          arguments_after: [],
        ),
      )
    | // `title |> func(title: _, content)`
      glance.BinaryOperator(
        location: call_span,
        name: glance.Pipe,
        left: title,
        right: glance.FnCapture(
          location: _,
          function:,
          arguments_before: [],
          label: Some("title"),
          arguments_after: [_content],
        ),
      )
    | // `title |> func(content, _)`
      glance.BinaryOperator(
        location: call_span,
        name: glance.Pipe,
        left: title,
        right: glance.FnCapture(
          location: _,
          function:,
          label: _,
          arguments_before: [glance.UnlabelledField(_snapshot_content)],
          arguments_after: [],
        ),
      ) ->
      // Most of the work is done, we have captured all calls that _look like_
      // they might be a call to `birdie.snap`, but now we need to make sure
      // they actually are!
      case is_snap_function(function, scope, snap_usage) {
        False -> Error(Nil)
        True ->
          Ok(SnapshotTest(
            title: expression_to_title(title),
            title_span: title.location,
            call_span:,
            test_function_name: function_name,
            test_function_name_span: function_name_span,
          ))
      }

    // Echo trivially wraps an expression, so we need to check that!
    glance.Echo(expression: Some(expression), ..) ->
      snapshot_test(
        snap_usage,
        scope,
        function_name,
        function_name_span,
        expression,
      )

    // Everything else cannot be a call to `birdie.snap` (or it is a format
    // I've forgot about).
    _ -> Error(Nil)
  }
}

fn expression_to_title(title: glance.Expression) -> SnapshotTitle {
  case title {
    glance.String(value:, ..) -> LiteralTitle(title: value)
    glance.Echo(expression: Some(expression), ..) ->
      expression_to_title(expression)

    // If we're joining two or more literal strings those are still considered
    // literal titles, because we can tell at compile time what they will be.
    glance.BinaryOperator(name: glance.Concatenate, left:, right:, ..) ->
      case expression_to_title(left) {
        ExpressionTitle -> ExpressionTitle
        LiteralTitle(title: left) ->
          case expression_to_title(right) {
            LiteralTitle(title: right) -> LiteralTitle(title: left <> right)
            ExpressionTitle -> ExpressionTitle
          }
      }

    _ -> ExpressionTitle
  }
}

/// Returns `True` if the given function is a valid `birdie.snap` call given how
/// the function can be used.
fn is_snap_function(
  function: glance.Expression,
  scope: Scope,
  snap_usage: SnapUsage,
) -> Bool {
  case function {
    // We have an unqualified call: `name(content, title)`.
    // We must check that the name used is the name that was picked for the
    // unqualified birdie import.
    glance.Variable(name: used_snap_name, ..) ->
      case snap_usage {
        OnlyQualified(..) -> False
        QualifiedAndUnqualified(snap_name:, ..) | OnlyUnqualified(snap_name:) ->
          snap_name == used_snap_name
          && !set.contains(scope.variables, snap_name)
      }

    // We have a qualified call: `module_name.snap(content, title)`.
    // We must check that the name used is the name that was picked for the
    // birdie module when imported.
    glance.FieldAccess(
      container: glance.Variable(name: used_module_name, ..),
      label: "snap",
      ..,
    ) ->
      case snap_usage {
        OnlyUnqualified(..) -> False
        OnlyQualified(birdie_name:)
        | QualifiedAndUnqualified(birdie_name:, ..) ->
          used_module_name == birdie_name
          && !set.contains(scope.variables, birdie_name)
      }

    _ -> False
  }
}

/// How the `birdie.snap` function can be called in a module.
type SnapUsage {
  /// The birdie module has been imported but the snap function has not been
  /// imported as unqualified. For example:
  ///
  /// ```gleam
  /// import birdie as wibble
  /// //               ^^^^^^ birdie_name
  /// ```
  ///
  /// This means the function can only be called qualified as
  /// `birdie_name.snap`.
  OnlyQualified(birdie_name: String)

  /// The snap function has been imported as unqualified with the given name
  /// and the module itself has been given a name. For example:
  ///
  /// ```gleam
  /// import birdie.{snap as wibble} as wobble
  /// //                     ^^^^^^ snap_name
  /// //                                ^^^^^^ birdie_name
  /// ```
  ///
  /// This means the function could be called qualified as
  /// `module_name.snap_name`, or unqualified as `snap_name`!
  QualifiedAndUnqualified(birdie_name: String, snap_name: String)

  /// The birdie module itself is discarded, but the snap function is imported
  /// with the given name. For example:
  ///
  /// ```gleam
  /// import birdie.{snap as wibble} as _
  /// //                     ^^^^^^ snap_name
  ///
  /// /// import birdie.{snap} as _
  /// //                 ^^^^ snap_name
  /// ```
  ///
  /// This means the function can only be called as `snap_name` unqualified.
  OnlyUnqualified(snap_name: String)
}

/// Returns how the `birdie.snap` can be used inside the given module, returning
/// `Error(Nil)` if the function can't be used at all!
fn snap_usage(for module: glance.Module) -> Result(SnapUsage, Nil) {
  list.find_map(module.imports, fn(import_) {
    let glance.Import(module:, alias:, unqualified_values:, ..) =
      import_.definition

    // We only care about the import that is importing `birdie`, all the other
    // ones will be skipped.
    use <- bool.guard(when: module != "birdie", return: Error(Nil))

    // We then figure out what name we need to use for the `snap` function if it
    // is imported in an unqualified manner.
    let unqualified_snap_name =
      list.find_map(unqualified_values, fn(unqualified_import) {
        case unqualified_import {
          glance.UnqualifiedImport(name: "snap", alias: None) -> Ok("snap")
          glance.UnqualifiedImport(name: "snap", alias: Some(name)) -> Ok(name)

          glance.UnqualifiedImport(name: _, alias: Some(_))
          | glance.UnqualifiedImport(name: _, alias: None) -> Error(Nil)
        }
      })

    case alias, unqualified_snap_name {
      // `import birdie.{snap}`
      // `import birdie.{snap as snap_name}`
      None, Ok(snap_name) ->
        Ok(QualifiedAndUnqualified(birdie_name: "birdie", snap_name:))
      // `import birdie.{snap} as birdie_name`
      // `import birdie.{snap as snap_name} as birdie_name`
      Some(glance.Named(birdie_name)), Ok(snap_name) ->
        Ok(QualifiedAndUnqualified(birdie_name:, snap_name:))
      // `import birdie.{snap} as _`
      // `import birdie.{snap as snap_name} as _`
      Some(glance.Discarded(_)), Ok(snap_name) ->
        Ok(OnlyUnqualified(snap_name:))

      // `import birdie`
      None, Error(_) -> Ok(OnlyQualified(birdie_name: "birdie"))
      // `import birdie as _`
      Some(glance.Discarded(_)), Error(_) -> Error(Nil)
      // `import birdie as birdie_name`
      Some(glance.Named(birdie_name)), Error(_) ->
        Ok(OnlyQualified(birdie_name:))
    }
  })
}

// ---- GLANCE EXPRESSION FOLDING ----------------------------------------------

type Scope {
  Scope(variables: Set(String))
}

fn fold_statements(
  statements: List(glance.Statement),
  scope: Scope,
  acc: a,
  fun: fn(a, Scope, glance.Expression) -> a,
) -> a {
  let #(_scope, acc) =
    list.fold(over: statements, from: #(scope, acc), with: fn(acc, statement) {
      let #(scope, acc) = acc
      case statement {
        // A use expression can introduce new variables into scope.
        glance.Use(patterns:, function:, ..) -> {
          // The function on the right hand side of use doesn't see the variable
          // that it introduces, so we have to go over it before updating the
          // scope.
          let acc = fold_expression(function, scope, acc, fun)
          let scope =
            list.fold(patterns, scope, fn(scope, use_pattern) {
              update_scope_from_patterns(scope, [use_pattern.pattern])
            })
          #(scope, acc)
        }

        // An assignment can introduce variables into scope.
        glance.Assignment(pattern:, value:, ..) -> {
          // The value on the right hand side of the assignment doesn't see the
          // variable that it introduces, so we have to go over it before
          // updating the scope.
          let acc = fold_expression(value, scope, acc, fun)
          let scope = update_scope_from_patterns(scope, [pattern])
          #(scope, acc)
        }

        // Assertions and simple expression cannot introduce new variables in
        // the current scope!
        glance.Assert(location: _, expression:, message: None) -> {
          #(scope, fold_expression(expression, scope, acc, fun))
        }
        glance.Assert(location: _, expression:, message: Some(message)) -> {
          let acc = fold_expression(expression, scope, acc, fun)
          #(scope, fold_expression(message, scope, acc, fun))
        }
        glance.Expression(expression) -> {
          #(scope, fold_expression(expression, scope, acc, fun))
        }
      }
    })

  acc
}

fn fold_expression(
  expression: glance.Expression,
  scope: Scope,
  acc: a,
  fun: fn(a, Scope, glance.Expression) -> a,
) -> a {
  let acc = fun(acc, scope, expression)
  case expression {
    glance.Echo(expression: Some(expression), message: None, ..)
    | glance.Echo(expression: None, message: Some(expression), ..) ->
      fold_expression(expression, scope, acc, fun)
    glance.Echo(expression: Some(expression), message: Some(message), ..) -> {
      let acc = fold_expression(expression, scope, acc, fun)
      fold_expression(message, scope, acc, fun)
    }

    glance.NegateInt(value: expression, ..)
    | glance.NegateBool(value: expression, ..)
    | glance.FieldAccess(container: expression, ..)
    | glance.TupleIndex(tuple: expression, ..) ->
      fold_expression(expression, scope, acc, fun)

    glance.Block(statements:, ..) ->
      fold_statements(statements, scope, acc, fun)

    glance.Tuple(elements:, ..) | glance.List(elements:, rest: None, ..) ->
      fold_expressions(elements, scope, acc, fun)

    glance.List(elements:, rest: Some(rest), ..) -> {
      let acc = fold_expressions(elements, scope, acc, fun)
      fold_expression(rest, scope, acc, fun)
    }

    glance.Fn(body: statements, arguments:, ..) -> {
      let scope =
        list.fold(arguments, scope, fn(scope, argument) {
          case argument.name {
            glance.Discarded(_) -> scope
            glance.Named(name) -> Scope(set.insert(scope.variables, name))
          }
        })
      fold_statements(statements, scope, acc, fun)
    }

    glance.RecordUpdate(record:, fields:, ..) -> {
      let acc = fold_expression(record, scope, acc, fun)
      list.fold(over: fields, from: acc, with: fn(acc, field) {
        case field.item {
          Some(item) -> fold_expression(item, scope, acc, fun)
          None -> acc
        }
      })
    }

    glance.Call(function:, arguments:, ..) -> {
      let acc = fold_expression(function, scope, acc, fun)
      fold_fields(arguments, scope, acc, fun)
    }

    glance.FnCapture(function:, arguments_before:, arguments_after:, ..) -> {
      let acc = fold_expression(function, scope, acc, fun)
      let acc = fold_fields(arguments_before, scope, acc, fun)
      fold_fields(arguments_after, scope, acc, fun)
    }

    glance.Case(subjects:, clauses:, ..) -> {
      let acc = fold_expressions(subjects, scope, acc, fun)
      fold_clauses(clauses, scope, acc, fun)
    }

    glance.BinaryOperator(left:, right:, ..) -> {
      let acc = fold_expression(left, scope, acc, fun)
      fold_expression(right, scope, acc, fun)
    }

    glance.Panic(message: Some(expression), ..)
    | glance.Todo(message: Some(expression), ..) ->
      fold_expression(expression, scope, acc, fun)

    // We can't find any `birdie.snap` call here for sure.
    glance.Panic(message: None, ..)
    | glance.Todo(message: None, ..)
    | glance.BitString(..)
    | glance.Int(..)
    | glance.Float(..)
    | glance.String(..)
    | glance.Variable(..)
    | glance.Echo(expression: None, message: None, ..) -> acc
  }
}

fn fold_fields(
  fields: List(glance.Field(glance.Expression)),
  scope: Scope,
  acc: a,
  fun: fn(a, Scope, glance.Expression) -> a,
) -> a {
  list.fold(over: fields, from: acc, with: fn(acc, field) {
    case field {
      glance.LabelledField(item:, ..) | glance.UnlabelledField(item:) ->
        fold_expression(item, scope, acc, fun)
      glance.ShorthandField(..) -> acc
    }
  })
}

fn fold_clauses(
  clauses: List(glance.Clause),
  scope: Scope,
  acc: a,
  fun: fn(a, Scope, glance.Expression) -> a,
) -> a {
  list.fold(over: clauses, from: acc, with: fn(acc, clause) {
    case clause {
      glance.Clause(patterns:, guard: None, body:) -> {
        let scope = list.fold(patterns, scope, update_scope_from_patterns)
        fold_expression(body, scope, acc, fun)
      }
      glance.Clause(patterns:, guard: Some(guard), body:) -> {
        let scope = list.fold(patterns, scope, update_scope_from_patterns)
        let acc = fold_expression(guard, scope, acc, fun)
        fold_expression(body, scope, acc, fun)
      }
    }
  })
}

fn fold_expressions(
  expressions: List(glance.Expression),
  scope: Scope,
  acc: a,
  fun: fn(a, Scope, glance.Expression) -> a,
) -> a {
  list.fold(expressions, acc, fn(acc, expression) {
    fold_expression(expression, scope, acc, fun)
  })
}

fn update_scope_from_patterns(
  scope: Scope,
  patterns: List(glance.Pattern),
) -> Scope {
  Scope(list.fold(patterns, scope.variables, pattern_variables))
}

fn pattern_variables(acc: Set(String), pattern: glance.Pattern) -> Set(String) {
  case pattern {
    glance.PatternInt(..) -> acc
    glance.PatternFloat(..) -> acc
    glance.PatternString(..) -> acc
    glance.PatternDiscard(..) -> acc
    glance.PatternVariable(name:, ..) -> set.insert(acc, name)

    glance.PatternTuple(elements:, ..) ->
      list.fold(elements, acc, pattern_variables)

    glance.PatternList(elements:, tail: None, ..) ->
      list.fold(elements, acc, pattern_variables)
    glance.PatternList(elements:, tail: Some(tail), ..) -> {
      let acc = list.fold(elements, acc, pattern_variables)
      pattern_variables(acc, tail)
    }

    glance.PatternAssignment(pattern:, name:, ..) -> {
      let acc = set.insert(acc, name)
      pattern_variables(acc, pattern)
    }

    glance.PatternConcatenate(prefix_name:, rest_name:, ..) -> {
      let acc = case prefix_name {
        Some(glance.Discarded(_)) | None -> acc
        Some(glance.Named(name)) -> set.insert(acc, name)
      }
      case rest_name {
        glance.Named(name) -> set.insert(acc, name)
        glance.Discarded(_) -> acc
      }
    }

    glance.PatternBitString(segments:, ..) ->
      list.fold(segments, acc, fn(acc, segment) {
        pattern_variables(acc, segment.0)
      })

    glance.PatternVariant(arguments:, ..) ->
      list.fold(arguments, acc, fn(acc, argument) {
        case argument {
          glance.ShorthandField(label:, ..) -> set.insert(acc, label)
          glance.LabelledField(item:, ..) | glance.UnlabelledField(item:) ->
            pattern_variables(acc, item)
        }
      })
  }
}

// ------ ERROR PRETTY PRINTING ------------------------------------------------

pub fn error_to_diagnostic(error: Error) -> Diagnostic {
  case error {
    TitleAlreadyInUse(module:, test_function_name_span:, title_span:) ->
      diagnostic.Diagnostic(
        level: diagnostic.Erro,
        title: "duplicate snapshot title",
        label: Some(diagnostic.Label(
          file_name: module.path.path,
          source: module.source,
          position: title_span,
          content: "multiple snapshots have this title",
          secondary_label: Some(#(
            test_function_name_span,
            "defined in this function",
          )),
        )),
        text: "Snapshot titles should be unique but title is duplicated.",
        hint: Some("change this title so that it is unique"),
      )
  }
}


================================================
FILE: src/birdie/internal/cli.gleam
================================================
import edit_distance
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import gleam_community/ansi

pub type Command {
  Review
  Accept
  Reject
  Help
  Stale(subcommand: StaleSubcommand)

  WithHelpOption(command: Command, explained: Explained)
}

pub type StaleSubcommand {
  CheckStale
  DeleteStale
}

pub type Explained {
  /// We're tasked with helping with a command in full, considering its sub
  /// commands as well: `gleam run -m birdie stale check --help`
  ///
  FullCommand

  /// We're tasked with helping with a command that would otherwise be invalid
  /// because it's missing some required subcommand, it's still valid to ask for
  /// help there: `gleam run -m birdie stale --help`
  TopLevelCommand
}

pub type Error {
  UnknownCommand(command: String)
  UnknownSubcommand(command: Command, subcommand: String)
  UnexpectedArgument(command: Command, argument: String)
  UnknownOption(command: Command, option: String)
  MissingSubcommand(command: Command)
}

pub fn parse(args: List(String)) -> Result(Command, Error) {
  let #(commands, options) =
    list.partition(args, fn(arg) {
      case arg {
        "--" <> _ | "-" <> _ -> False
        _ -> True
      }
    })

  case commands {
    [] -> Review |> or_help(options)

    ["review"] -> Review |> or_help(options)
    ["review", subcommand, ..] ->
      Error(UnknownSubcommand(command: Review, subcommand:))

    ["reject"] -> Reject |> or_help(options)
    ["reject", subcommand, ..] ->
      Error(UnknownSubcommand(command: Reject, subcommand:))

    ["accept"] -> Accept |> or_help(options)
    ["accept", subcommand, ..] ->
      Error(UnknownSubcommand(command: Accept, subcommand:))

    ["stale"] -> Stale(CheckStale) |> require_help(options)
    ["stale", "check"] -> Stale(CheckStale) |> or_help(options)
    ["stale", "check", argument, ..] ->
      Error(UnexpectedArgument(command: Stale(CheckStale), argument:))
    ["stale", "delete"] -> Stale(DeleteStale) |> or_help(options)
    ["stale", "delete", argument, ..] ->
      Error(UnexpectedArgument(command: Stale(DeleteStale), argument:))
    ["stale", subcommand, ..] ->
      Error(UnknownSubcommand(command: Stale(CheckStale), subcommand:))

    ["help", ..] -> Ok(Help)

    [command, ..] -> Error(UnknownCommand(command:))
  }
}

fn or_help(command: Command, options: List(String)) -> Result(Command, Error) {
  case list.find(options, one_that: fn(option) { !is_help(option) }) {
    Ok(option) -> Error(UnknownOption(command:, option:))
    Error(_) ->
      case list.any(options, is_help) {
        True -> Ok(WithHelpOption(command, explained: FullCommand))
        False -> Ok(command)
      }
  }
}

fn require_help(
  command: Command,
  options: List(String),
) -> Result(Command, Error) {
  case list.find(options, one_that: fn(option) { !is_help(option) }) {
    Ok(option) -> Error(UnknownOption(command:, option:))
    Error(_) ->
      case list.any(options, is_help) {
        True -> Ok(WithHelpOption(command, explained: TopLevelCommand))
        False -> Error(MissingSubcommand(command:))
      }
  }
}

fn is_help(option: String) -> Bool {
  case option {
    "-h" | "--help" -> True
    _ -> False
  }
}

/// This will return one of the allowed commands if there's one that's similar
/// enough to the given one.
///
pub fn similar_command(to command: String) -> Result(String, Nil) {
  list.filter_map(all_commands(), fn(valid_command) {
    case edit_distance.levenshtein(command, valid_command) {
      distance if distance <= 3 -> Ok(#(valid_command, distance))
      _ -> Error(Nil)
    }
  })
  |> list.sort(fn(one, other) { int.compare(one.1, other.1) })
  |> list.first
  |> result.map(fn(pair) { pair.0 })
}

pub fn all_commands() -> List(String) {
  ["accept", "help", "reject", "review", "stale"]
}

// ERROR MESSAGES --------------------------------------------------------------

pub fn unknown_command_error(command: String, show_help_text: Bool) -> String {
  let message =
    ansi.red("Error: ")
    <> style_invalid_value(command)
    <> " is not a valid command"

  case show_help_text {
    False -> message
    True -> message <> "\n\n" <> main_help_text()
  }
}

pub fn unknown_subcommand_error(
  birdie_version: String,
  command: Command,
  subcommand: String,
) -> String {
  ansi.red("Error: ")
  <> style_invalid_value(subcommand)
  <> " is not a valid subcommand\n\n"
  <> help_text(birdie_version, for: command, explaining: TopLevelCommand)
}

pub fn unknown_option_error(
  birdie_version: String,
  command: Command,
  option: String,
) -> String {
  ansi.red("Error: ")
  <> style_invalid_value(option)
  <> " is not a valid option\n\n"
  <> help_text(birdie_version, for: command, explaining: FullCommand)
}

pub fn missing_subcommand_error(
  birdie_version: String,
  command: Command,
) -> String {
  ansi.red("Error: ")
  <> style_invalid_value(command_to_string(command))
  <> " is missing a required subcommand\n\n"
  <> help_text(birdie_version, for: command, explaining: TopLevelCommand)
}

pub fn unexpected_argument_error(
  birdie_version: String,
  command: Command,
  argument: String,
) -> String {
  ansi.red("Error: ")
  <> " unexpected argument "
  <> style_invalid_value(argument)
  <> "\n\n"
  <> help_text(birdie_version, command, explaining: FullCommand)
}

fn command_to_string(command: Command) {
  case command {
    WithHelpOption(command:, ..) -> command_to_string(command)
    Stale(..) -> "stale"
    Review -> "review"
    Accept -> "accept"
    Reject -> "reject"
    Help -> "help"
  }
}

fn style_invalid_value(value: String) -> String {
  ansi.yellow("'" <> value <> "'")
}

// HELP TEXTS ------------------------------------------------------------------

/// Returns the help text for the given command.
///
pub fn help_text(
  birdie_version: String,
  for command: Command,
  explaining explained: Explained,
) -> String {
  case command, explained {
    // These commands do not have any subcommands, so the explanation text is
    // always going to be the same, no matter what we're asked to explain.
    Help(..), _ -> help_help_text(birdie_version)
    Accept, _ -> accept_help_text()
    Reject, _ -> reject_help_text()
    Review, _ -> review_help_text()

    Stale(subcommand), FullCommand -> stale_help_text(Some(subcommand))
    Stale(_), TopLevelCommand -> stale_help_text(None)

    // This would only happen if we wrapped a `WithHelpOption` command in one
    // other. Just for the sake of safety I return a value instead of panicking.
    WithHelpOption(command:, explained:), _ ->
      help_text(birdie_version, command, explained)
  }
}

fn stale_help_text(subcommand: Option(StaleSubcommand)) -> String {
  case subcommand {
    None -> {
      let stale_subcommands =
        ansi.yellow("Subcommands:\n")
        <> ansi.green("  check   ")
        <> "check if there's any stale snapshot\n"
        <> ansi.green("  delete  ")
        <> "delete all stale snapshots"

      usage(["stale"], Some(Subcommand))
      <> "\n\n"
      <> "Find and remove stale snapshots."
      <> "\n\n"
      <> stale_subcommands
      <> "\n\n"
      <> options()
    }

    Some(CheckStale) ->
      usage(["stale", "check"], None)
      <> "\n\n"
      <> "Check if there's any snapshot that is no longer used by any test.\n"
      <> "This exits with an error status code if any stale snapshot is found."
      <> "\n\n"
      <> options()

    Some(DeleteStale) ->
      usage(["stale", "delete"], None)
      <> "\n\n"
      <> "Removes any snapshot that is no longer used by any test."
      <> "\n\n"
      <> options()
  }
}

fn review_help_text() -> String {
  usage(["review"], None)
  <> "\n\n"
  <> "Review all new snapshots one by one"
  <> "\n\n"
  <> options()
}

fn reject_help_text() -> String {
  usage(["reject"], None)
  <> "\n\n"
  <> "Reject all new snapshots"
  <> "\n\n"
  <> options()
}

fn accept_help_text() -> String {
  usage(["accept"], None)
  <> "\n\n"
  <> "Accept all new snapshots"
  <> "\n\n"
  <> options()
}

fn help_help_text(birdie_version: String) -> String {
  { ansi.green("🐦‍⬛ birdie ") <> "v" <> birdie_version }
  <> "\n\n"
  <> main_help_text()
}

pub fn main_help_text() -> String {
  usage([], Some(Command)) <> "\n\n" <> command_menu() <> "\n\n" <> options()
}

fn command_menu() -> String {
  ansi.yellow("Commands:\n")
  <> ansi.green("  review  ")
  <> "review all new snapshots one by one\n"
  <> ansi.green("  accept  ")
  <> "accept all new snapshots\n"
  <> ansi.green("  reject  ")
  <> "reject all new snapshots\n"
  <> ansi.green("  stale   ")
  <> "find and remove stale snapshots\n"
  <> ansi.green("  help    ")
  <> "print this help text"
}

type ArgumentsKind {
  Command
  Subcommand
}

fn usage(
  commands: List(String),
  arguments_kind: Option(ArgumentsKind),
) -> String {
  let command_placeholder = case arguments_kind {
    None -> " "
    Some(Command) -> ansi.dim(" <COMMAND> ")
    Some(Subcommand) -> ansi.dim(" <SUBCOMMAND> ")
  }
  let commands = case commands {
    [] -> ""
    [_, ..] -> " " <> ansi.green(string.join(commands, with: " "))
  }

  ansi.yellow("Usage: ")
  <> "gleam run -m"
  <> ansi.green(" birdie")
  <> commands
  <> command_placeholder
  <> ansi.dim("[OPTIONS]")
}

fn options() -> String {
  let help_option = ansi.green("-h") <> ", " <> ansi.green("--help")
  ansi.yellow("Options:") <> "\n  " <> help_option <> "  print this help text"
}


================================================
FILE: src/birdie/internal/diagnostic.gleam
================================================
import glance.{type Span}
import gleam/bit_array
import gleam/int
import gleam/option.{type Option}
import gleam/result
import gleam/string
import gleam_community/ansi

pub type Diagnostic {
  Diagnostic(
    level: Level,
    title: String,
    /// If the diagnostic points to some specific position in a source file this
    /// will be `Some`, with the information needed to display such tooltip.
    label: Option(Label),
    text: String,
    /// Some text displayed after the main text, always preceded by the `Hint:`
    /// label.
    hint: Option(String),
  )
}

pub type Level {
  Warn
  Erro
}

pub type Label {
  Label(
    /// The name of the file where the source comes from.
    file_name: String,
    /// The source code this label points to.
    source: String,
    /// The position in the source code we need to point to.
    position: Span,
    /// The content of the label. If this is not an empty string, the label
    /// will have a tooltip connecting it to the label text.
    content: String,
    /// This is an additional label that can be used to add additional context
    /// and is rendered with a muted color.
    secondary_label: Option(#(Span, String)),
  )
}

pub fn to_string(diagnostic: Diagnostic) {
  let Diagnostic(level:, title:, label:, text:, hint:) = diagnostic
  let text = string.trim(text)

  let heading = case level {
    Warn -> ansi.yellow("warning")
    Erro -> ansi.red("error")
  }

  // We start with the heading line...
  let error = ansi.bold(heading <> ": " <> title)
  // ...followed by the label pointing to some source, if present.
  let error = case label {
    option.None -> error
    option.Some(label) -> error <> "\n" <> label_to_string(level, label)
  }
  // Then we add the error text...
  let error = case string.trim(text) {
    "" -> error
    text -> error <> "\n" <> text
  }
  // ...and finally, if present, we also add the hint!
  let error = case option.map(hint, string.trim) {
    option.Some("") | option.None -> error
    option.Some(hint) -> error <> "\nHint: " <> hint
  }

  error
}

/// Turns a label into a pretty string, pointing to the original source.
fn label_to_string(level: Level, label: Label) -> String {
  let Label(file_name:, source:, position:, content:, secondary_label:) = label
  let colour = case level {
    Warn -> ansi.yellow
    Erro -> ansi.red
  }

  let #(start_line, start_line_number, trimmed_to_start) =
    get_line(source, containing: position.start)
  let #(end_line, end_line_number, trimmed_to_end) =
    get_line(source, containing: position.end)

  let is_single_line = start_line_number == end_line_number
  let required_digits =
    int.to_string(start_line_number)
    |> string.length
    |> int.max(int.to_string(end_line_number) |> string.length)

  let file_name =
    string.repeat(" ", required_digits) <> ansi.dim(" ╭─ ") <> file_name
  let empty_line = string.repeat(" ", required_digits) <> ansi.dim(" │")
  let highlighted_code = case is_single_line {
    True -> {
      let start_line =
        colour_string_between_bytes(
          start_line,
          position.start - trimmed_to_start,
          position.end - trimmed_to_start,
          colour,
        )
      ansi.dim(int.to_string(start_line_number) <> " │ ") <> start_line
    }
    False -> {
      let start_line =
        colour_string_between_bytes(
          start_line,
          position.start - trimmed_to_start,
          string.byte_size(start_line),
          colour,
        )
      let end_line =
        colour_string_between_bytes(
          end_line,
          0,
          position.end - trimmed_to_end,
          colour,
        )

      let start_line =
        ansi.dim(
          string.pad_start(
            int.to_string(start_line_number),
            required_digits,
            " ",
          )
          <> " │ ",
        )
        <> start_line

      let end_line =
        ansi.dim(
          string.pad_start(int.to_string(end_line_number), required_digits, " ")
          <> " │ ",
        )
        <> end_line

      case end_line_number - start_line_number {
        0 | 1 -> start_line <> "\n" <> end_line
        _ -> {
          let dashed_line =
            string.repeat(" ", required_digits) <> ansi.dim(" ╎")
          start_line <> "\n" <> dashed_line <> "\n" <> end_line
        }
      }
    }
  }
  // Now we need to add the tooltip to the highlighted code, if the tooltip has
  // any text!
  let tooltip = case is_single_line {
    True -> {
      empty_line
      <> " "
      <> string.repeat(" ", position.start - trimmed_to_start)
      <> string.repeat(colour("^"), position.end - position.start)
      <> case string.trim(content) {
        "" -> ""
        content -> " " <> colour(content)
      }
    }

    False -> {
      empty_line
      <> " "
      <> string.repeat(colour("^"), string.byte_size(start_line))
      <> case string.trim(content) {
        "" -> ""
        content -> " " <> colour(content)
      }
    }
  }

  let primary_label = highlighted_code <> "\n" <> tooltip
  let labels = case secondary_label {
    option.None -> primary_label
    option.Some(#(span, content)) -> {
      let #(secondary_line, secondary_line_number, dropped_bytes) =
        get_line(source, containing: span.start)
      let secondary_line =
        colour_string_between_bytes(
          secondary_line,
          span.start - dropped_bytes,
          span.end - dropped_bytes,
          ansi.dim,
        )
      let secondary_tooltip =
        empty_line
        <> " "
        <> string.repeat(" ", span.start - dropped_bytes)
        <> string.repeat(ansi.dim("~"), span.end - span.start)
        <> case string.trim(content) {
          "" -> ""
          content -> " " <> ansi.dim(content)
        }

      let secondary_label =
        ansi.dim(
          string.pad_start(
            int.to_string(secondary_line_number),
            required_digits,
            " ",
          )
          <> " │ ",
        )
        <> secondary_line
        <> "\n"
        <> secondary_tooltip
      let dashed_line = string.repeat(" ", required_digits) <> ansi.dim(" ╎")
      case secondary_line_number - start_line_number {
        0 -> primary_label
        1 -> primary_label <> "\n" <> secondary_label
        n if n == -1 -> secondary_label <> "\n" <> primary_label
        n if n < 0 ->
          secondary_label <> "\n" <> dashed_line <> "\n" <> primary_label
        _ -> primary_label <> "\n" <> dashed_line <> "\n" <> secondary_label
      }
    }
  }

  file_name <> "\n" <> empty_line <> "\n" <> labels <> "\n" <> empty_line
}

fn colour_string_between_bytes(
  string: String,
  start: Int,
  end: Int,
  colour: fn(String) -> String,
) -> String {
  let result = case <<string:utf8>> {
    <<
      prefix:size(start)-bytes,
      to_colour:size(end - start)-bytes,
      suffix:bytes,
    >> -> {
      use prefix <- result.try(bit_array.to_string(prefix))
      use to_colour <- result.try(bit_array.to_string(to_colour))
      use suffix <- result.try(bit_array.to_string(suffix))
      Ok(prefix <> colour(to_colour) <> suffix)
    }

    _ -> Error(Nil)
  }

  result.unwrap(result, string)
}

/// This returns
/// - the line in `string` containing the given byte
/// - the number of such line in the source code
/// - the number of bytes that come before such line
///
fn get_line(string: String, containing byte: Int) -> #(String, Int, Int) {
  get_line_loop(string, 1, 0, byte)
}

fn get_line_loop(
  string: String,
  line_number: Int,
  trimmed_bytes: Int,
  byte: Int,
) -> #(String, Int, Int) {
  case string.split_once(string, on: "\n") {
    // This is the last line! We can't keep going any further
    Error(_) -> #(string, line_number, trimmed_bytes)
    Ok(#(line, rest)) ->
      case trimmed_bytes + string.byte_size(line) + 1 {
        // The byte falls inside this line, so we have to return it.
        trimmed if trimmed > byte -> #(line, line_number, trimmed_bytes)
        trimmed -> get_line_loop(rest, line_number + 1, trimmed, byte)
      }
  }
}


================================================
FILE: src/birdie/internal/diff.gleam
================================================
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string

pub type DiffLine {
  DiffLine(number: Int, line: String, kind: DiffLineKind)
}

pub type DiffLineKind {
  Old
  New
  Shared
}

pub fn histogram(one, other) {
  let one_lines = string.split(one, on: "\n")
  let other_lines = string.split(other, on: "\n")
  let lcs = lcs(one_lines, other_lines)
  match_diff_lines([], lcs, 1, one_lines, 1, other_lines)
}

fn match_diff_lines(
  lines: List(DiffLine),
  lcs: List(String),
  line_one: Int,
  one: List(String),
  line_other: Int,
  other: List(String),
) -> List(DiffLine) {
  case lcs, one, other {
    // We drained all the lines, we can return the accumulator which was built
    // in reverse order.
    [], [], [] -> list.reverse(lines)

    // If we don't have any more lines in the common prefix we first drain all
    // the lines from the first list marking those as old.
    [], [first, ..one], other ->
      [DiffLine(line_one, first, Old), ..lines]
      |> match_diff_lines(lcs, line_one + 1, one, line_other, other)

    // If we've also drained the first list we finish by draining the other one
    // marking all its lines as new ones.
    [], [], [first, ..other] ->
      [DiffLine(line_other, first, New), ..lines]
      |> match_diff_lines(lcs, line_one, one, line_other + 1, other)

    // While the first list has lines that are not in common we add those
    // marking them as old.
    [first_common, ..], [first_one, ..one], other
      if first_common != first_one
    ->
      [DiffLine(line_one, first_one, Old), ..lines]
      |> match_diff_lines(lcs, line_one + 1, one, line_other, other)

    // While the second list has lines that are not in common we add those
    // marking them as new.
    [first_common, ..], one, [first_other, ..other]
      if first_common != first_other
    ->
      [DiffLine(line_other, first_other, New), ..lines]
      |> match_diff_lines(lcs, line_one, one, line_other + 1, other)

    [first_common, ..lcs], [_, ..one], [_, ..other] ->
      [DiffLine(line_other, first_common, Shared), ..lines]
      |> match_diff_lines(lcs, line_one + 1, one, line_other + 1, other)

    [_, ..], [], _ | [_, ..], _, [] -> panic as "unreachable"
  }
}

/// Find the least common subsequences of shared items between two lists.
///
/// Reference: https://tiarkrompf.github.io/notes/?/diff-algorithm/
///
fn lcs(one: List(a), other: List(a)) -> List(a) {
  // The recursive definition is so intuitive and elegant, just please don't
  // look at how `lowest_occurrence_common_item` is defined.
  //
  // 1. We remove the common prefix and suffix from the lists.
  // 2. In the remaining lists we find the common element that appears in both
  //    the list number of times.
  // 3. We recursively look for the `lcs` of the pieces that come before the
  //    common element in both lists and the pieces that come after the common
  //    element in both lists.
  // 4. We join the common prefix and suffix, `lcs`s and common item.

  let #(prefix, one, other) = pop_common_prefix(between: one, and: other)
  let #(suffix, one, other) = pop_common_suffix(between: one, and: other)

  // 💡 A possible optimisation could be using a cache and hit that before
  // calling this function. That might make things faster as well.
  case lowest_occurrence_common_item(one, other) {
    None -> list.flatten([prefix, suffix])
    Some(#(item, _, before_a, after_a, before_b, after_b)) ->
      // 💡 A possible optimisation I want to look into is using bags (super
      // fast append only) and turn that into a list only after everything is
      // done. That way we could avoid always repeatedly appending lists.
      list.flatten([
        prefix,
        lcs(list.reverse(before_a), list.reverse(before_b)),
        [item],
        lcs(after_a, after_b),
        suffix,
      ])
  }
}

// --- HISTOGRAM ---------------------------------------------------------------

type Occurs(a) {
  One(times: Int, before: List(a), after: List(a))
  Other(times: Int, before: List(a), after: List(a))
  Both(
    times: Int,
    before_one: List(a),
    after_one: List(a),
    before_other: List(a),
    after_other: List(a),
  )
}

fn lowest_occurrence_common_item(
  between one: List(a),
  and other: List(a),
) -> Option(#(a, Int, List(a), List(a), List(a), List(a))) {
  let histogram =
    histogram_add(to: dict.new(), from: one, with: One, acc: [])
    |> histogram_add(from: other, with: Other, acc: [])

  use lowest, a, occurs <- dict.fold(over: histogram, from: None)
  case occurs {
    // We're only looking for items that appear in both.
    One(..) | Other(..) -> lowest
    Both(n, before_one, after_one, before_other, after_other) ->
      case lowest {
        None -> Some(#(a, n, before_one, after_one, before_other, after_other))
        // We keep the one that appears the least, so we compare `n` and `m`,
        // that is the number of occurrences of the current lowest and the new
        // item.
        Some(#(_, m, _, _, _, _)) ->
          case m <= n {
            True -> lowest
            False ->
              #(a, n, before_one, after_one, before_other, after_other)
              |> Some
          }
      }
  }
}

fn histogram_add(
  to histogram: Dict(a, Occurs(a)),
  from list: List(a),
  with to_occurrence: fn(Int, List(a), List(a)) -> Occurs(a),
  acc reverse_prefix: List(a),
) -> Dict(a, Occurs(a)) {
  case list {
    [] -> histogram
    [first, ..rest] ->
      {
        use previous <- dict.upsert(in: histogram, update: first)
        let new_occurrence = to_occurrence(1, reverse_prefix, rest)
        case previous {
          Some(occurrence) -> sum_occurrences(occurrence, new_occurrence)
          None -> new_occurrence
        }
      }
      |> histogram_add(rest, to_occurrence, [first, ..reverse_prefix])
  }
}

// This is not general purpose and only takes into accounts the particular cases
// that might occur in the histogram building. In particular we first add all
// the `One`s and then all the `Other`s.
fn sum_occurrences(one: Occurs(a), other: Occurs(a)) -> Occurs(a) {
  case one, other {
    One(n, _, _), One(m, before, after) -> One(n + m, before, after)
    Other(n, _, _), Other(m, before, after) -> Other(n + m, before, after)

    One(n, before_one, after_one), Other(m, before_other, after_other)
    | Both(n, before_one, after_one, _, _), Other(m, before_other, after_other)
    -> Both(n + m, before_one, after_one, before_other, after_other)

    _, _ -> panic as "unreachable: sum_occurrences"
  }
}

// --- LIST UTILS --------------------------------------------------------------

/// Returns the common prefix between two lists, and the remaining lists after
/// removing the common prefix from each one.
///
fn pop_common_prefix(
  between one: List(a),
  and other: List(a),
) -> #(List(a), List(a), List(a)) {
  let #(reverse_prefix, one, other) = do_pop_common_prefix([], one, other)
  #(list.reverse(reverse_prefix), one, other)
}

fn do_pop_common_prefix(
  reverse_prefix: List(a),
  one: List(a),
  other: List(a),
) -> #(List(a), List(a), List(a)) {
  case one, other {
    [first_one, ..one], [first_other, ..other] if first_one == first_other ->
      do_pop_common_prefix([first_one, ..reverse_prefix], one, other)
    _, _ -> #(reverse_prefix, one, other)
  }
}

/// Returns the common suffix between two lists, and the remaining lists after
/// removing the common suffix from each one.
///
fn pop_common_suffix(
  between one: List(a),
  and other: List(a),
) -> #(List(a), List(a), List(a)) {
  let #(suffix, reverse_one, reverse_other) =
    do_pop_common_prefix([], list.reverse(one), list.reverse(other))
  #(suffix, list.reverse(reverse_one), list.reverse(reverse_other))
}


================================================
FILE: src/birdie/internal/project.gleam
================================================
import filepath
import gleam/result
import simplifile.{type FileError}
import tom

/// Returns the path to the project's root.
///
/// > ⚠️ This assumes that this is only ever run inside a Gleam's project and
/// > sooner or later it will reach a `gleam.toml` file.
/// > Otherwise this will end up in an infinite loop, I think.
///
pub fn find_root() -> Result(String, FileError) {
  do_find_root(".")
}

fn do_find_root(path: String) -> Result(String, FileError) {
  let manifest = filepath.join(path, "gleam.toml")
  case simplifile.is_file(manifest) {
    Ok(True) -> Ok(path)
    Ok(False) -> do_find_root(filepath.join(path, ".."))
    Error(reason) -> Error(reason)
  }
}

/// Returns the project's name as specified in its `gleam.toml`.
///
pub fn name() -> Result(String, FileError) {
  use root <- result.try(find_root())
  use file <- result.try(simplifile.read(filepath.join(root, "gleam.toml")))
  let assert Ok(toml) = tom.parse(file)
    as "running birdie in a gleam project with an invalid `gleam.toml` should be impossible"
  let assert Ok(name) = tom.get_string(toml, ["name"])
    as "`name` is a required field in `gleam.toml`, it should be impossible to run birdie on a project that doesn't have one"
  Ok(name)
}


================================================
FILE: src/birdie/internal/version.gleam
================================================
import gleam/int
import gleam/order
import gleam/result
import gleam/string

pub type Version {
  Version(major: Int, minor: Int, patch: Int)
}

pub fn new(major major: Int, minor minor: Int, patch patch: Int) -> Version {
  Version(major:, minor:, patch:)
}

pub fn parse(version: String) -> Result(Version, Nil) {
  case version |> string.trim |> string.split(on: ".") {
    [major, minor, patch] -> {
      use major <- result.try(int.parse(major))
      use minor <- result.try(int.parse(minor))
      use patch <- result.try(int.parse(patch))
      Ok(Version(major:, minor:, patch:))
    }
    _ -> Error(Nil)
  }
}

pub fn compare(one: Version, with other: Version) -> order.Order {
  use <- order.lazy_break_tie(int.compare(one.major, other.major))
  use <- order.lazy_break_tie(int.compare(one.minor, other.minor))
  int.compare(one.patch, other.patch)
}


================================================
FILE: src/birdie.gleam
================================================
import argv
import birdie/internal/analyser.{type Analyser}
import birdie/internal/cli.{
  type Command, Accept, CheckStale, DeleteStale, FullCommand, Help,
  MissingSubcommand, Reject, Review, Stale, UnexpectedArgument, UnknownCommand,
  UnknownOption, UnknownSubcommand, WithHelpOption,
}
import birdie/internal/diagnostic
import birdie/internal/diff.{type DiffLine, DiffLine}
import birdie/internal/project
import birdie/internal/version
import envoy
import filepath
import gleam/bool
import gleam/int
import gleam/io
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/result
import gleam/set
import gleam/string
import gleam/uri
import gleam_community/ansi
import global_value
import justin
import rank
import simplifile.{Eexist, Enoent}
import term_size

const birdie_version = "2.0.0"

const hint_review_message = "run `gleam run -m birdie` to review the snapshots"

const accepted_extension = "accepted"

const new_extension = "new"

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)

  CannotCreateReferencedFile(file: String, reason: simplifile.FileError)

  CannotReadReferencedFile(file: String, reason: simplifile.FileError)

  CannotMarkSnapshotAsReferenced(reason: simplifile.FileError)

  StaleSnapshotsFound(stale_snapshots: List(String))

  CannotDeleteStaleSnapshot(reason: simplifile.FileError)

  MissingReferencedFile

  /// This happens when we try and list all the files inside the `test/`
  /// directory and for some reason the operation fails.
  CannotReadTestDirectory(reason: simplifile.FileError)

  /// This happens when we're trying to read the content of a test file to then
  /// analyse it, but the operation fails for some reason.
  CannotReadTestFile(reason: simplifile.FileError, file: String)

  CannotFigureOutProjectName(reason: simplifile.FileError)

  /// This happens if there's any analysis error with the project modules.
  AnalysisError(errors: List(analyser.Error))

  /// This happens when it's not possible to move the legacy snapshot folder to
  /// the new expected location under tests.
  CannotMigrateBirdieSnapshotDirectory(
    reason: simplifile.FileError,
    from: String,
    to: String,
  )
}

// --- THE SNAPSHOT TYPE -------------------------------------------------------

type New

type Accepted

type Snapshot(status) {
  Snapshot(title: String, content: String, info: Option(SnapshotInfo))
}

type SnapshotInfo {
  SnapshotInfo(
    /// The path to the file where the snapshot is defined.
    file: String,
    /// The name of the function inside of which the snapshot test is defined.
    /// For example:
    ///
    /// ```gleam
    /// pub fn wibble_test() {
    ///   //   ^^^^^^^^^^^ This!
    ///   birdie.snap(...)
    /// }
    /// ```
    test_function_name: String,
  )
}

// --- SNAP --------------------------------------------------------------------

/// Returns the path to the referenced file, initialising it to be empty only
/// the first time this function is called.
///
fn global_referenced_file() -> Result(String, Error) {
  use <- global_value.create_with_unique_name("birdie.referenced_file")
  use referenced_file <- result.try(referenced_file_path())
  case simplifile.create_file(referenced_file) {
    Ok(_) -> Ok(referenced_file)
    Error(Eexist) ->
      simplifile.write("", to: referenced_file)
      |> result.replace(referenced_file)
      |> result.map_error(CannotCreateReferencedFile(
        file: referenced_file,
        reason: _,
      ))

    Error(reason) ->
      Error(CannotCreateReferencedFile(file: referenced_file, reason:))
  }
}

fn referenced_file_path() -> Result(String, Error) {
  use name <- result.try(
    project.name()
    |> result.map_error(CannotFigureOutProjectName),
  )
  Ok(filepath.join(get_temp_directory(), name <> "_referenced.txt"))
}

fn get_temp_directory() -> String {
  let temp = {
    use <- result.lazy_or(envoy.get("TMPDIR"))
    use <- result.lazy_or(envoy.get("TEMP"))
    envoy.get("TMP")
  }

  case temp {
    Ok(temp) -> temp
    Error(_) ->
      case is_windows() {
        True -> "C:\\TMP"
        False -> "/tmp"
      }
  }
}

@external(erlang, "birdie_ffi", "is_windows")
@external(javascript, "./birdie_ffi.mjs", "is_windows")
fn is_windows() -> Bool

/// 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 snapshot_folder() -> Result(String, Error) {
  use <- global_value.create_with_unique_name("birdie.snapshot_folder")

  use snapshot_folder <- result.try(snapshot_folder_name())
  use legacy_snapshot_folder <- result.try(legacy_snapshot_folder_name())

  case simplifile.is_directory(snapshot_folder) {
    Ok(True) -> Ok(snapshot_folder)
    Ok(False) | Error(Enoent) ->
      case simplifile.is_directory(legacy_snapshot_folder) {
        Ok(True) -> Ok(legacy_snapshot_folder)
        Ok(False) | Error(Enoent) ->
          case simplifile.create_directory(snapshot_folder) {
            Ok(_) -> Ok(snapshot_folder)
            Error(error) -> Error(CannotCreateSnapshotsFolder(error))
          }
        Error(error) -> Error(CannotCreateSnapshotsFolder(error))
      }
    Error(error) -> Error(CannotCreateSnapshotsFolder(error))
  }
}

fn snapshot_folder_name() -> Result(String, Error) {
  use <- global_value.create_with_unique_name("birdie.snapshot_folder_name")
  let result = result.map_error(project.find_root(), CannotFindProjectRoot)
  use project_root <- result.try(result)

  project_root
  |> filepath.join("test")
  |> filepath.join("birdie_snapshots")
  |> Ok
}

/// This returns the name of the snapshot folder that was used before `1.6.0`.
fn legacy_snapshot_folder_name() -> Result(String, Error) {
  use <- global_value.create_with_unique_name("birdie.legacy_snapshot_folder")
  let result = result.map_error(project.find_root(), CannotFindProjectRoot)
  use project_root <- result.try(result)
  Ok(filepath.join(project_root, "birdie_snapshots"))
}

/// 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) ->
      panic as {
        "Birdie snapshot test failed\n"
        <> to_diagnostic(error)
        |> list.map(diagnostic.to_string)
        |> string.join(with: "\n\n")
      }
  }
}

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(snapshot_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:, 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) -> {
      // Whenever we find an existing accepted snapshot file, we record that it
      // has been referenced in the current run. So we know it will not be
      // stale!
      use referenced_file <- result.try(global_referenced_file())
      use _ <- result.try(
        simplifile.append(
          filepath.base_name(accepted_snapshot_path) <> "\n",
          to: referenced_file,
        )
        |> result.map_error(CannotMarkSnapshotAsReferenced),
      )

      case accepted.content == new.content {
        True -> {
          // If the file is ok we make sure to delete any lingering `.new` file
          // that might have been leftover from somewhere else.
          let _ = simplifile.delete(new_snapshot_path)
          Ok(Same)
        }

        False -> {
          // 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 _ <- 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) {
  case split_n(raw, 4, "\n") {
    Ok(#(["---", "version: " <> version, "title: " <> title, "---"], content))
    | Ok(#(
        ["---\r", "version: " <> version, "title: " <> title, "---\r"],
        content,
      )) ->
      Ok(Snapshot(
        title: string.trim(title),
        content: trim_content(content, based_on: version),
        info: None,
      ))

    Ok(_) | Error(_) ->
      case split_n(raw, 6, "\n") {
        Ok(#(
          [
            "---",
            "version: " <> version,
            "title: " <> title,
            "file: " <> file,
            "test_name: " <> test_name,
            "---",
          ],
          content,
        ))
        | Ok(#(
            [
              "---\r",
              "version: " <> version,
              "title: " <> title,
              "file: " <> file,
              "test_name: " <> test_name,
              "---\r",
            ],
            content,
          )) ->
          Ok(Snapshot(
            title: string.trim(title),
            content: trim_content(content, based_on: version),
            info: Some(SnapshotInfo(
              file: string.trim(file),
              test_function_name: string.trim(test_name),
            )),
          ))

        Ok(_) | Error(_) -> Error(Nil)
      }
  }
}

/// Birdie started adding newlines to the end of files starting from `1.4.0`,
/// so if we're reading a snapshot created from `1.4.0` onwards then we want to
/// make sure to remove that newline!
///
fn trim_content(content: String, based_on version: String) -> String {
  let assert Ok(version) = version.parse(version) as "corrupt birdie version"
  case version.compare(version, version.new(1, 4, 0)) {
    order.Gt | order.Eq -> trim_end_once(content, "\n")
    order.Lt -> content
  }
}

fn trim_end_once(string: String, substring: String) {
  case string.ends_with(string, substring) {
    True -> string.drop_end(string, string.length(substring))
    False -> string
  }
}

fn serialise(snapshot: Snapshot(New)) -> String {
  let Snapshot(title:, content:, info:) = snapshot
  let info_lines = case info {
    None -> []
    Some(SnapshotInfo(file:, test_function_name:)) -> [
      "file: " <> file,
      "test_name: " <> test_function_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.flatten
  |> string.join(with: "\n")
  // We always add a newline at the end of each snapshot to make sure they're
  // valid files.
  |> string.append("\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:,
      ))
  }
}

/// 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(Enoent) -> Ok(None)
    Error(reason) -> Error(CannotReadAcceptedSnapshot(reason:, 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:, 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:, 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(extension) if extension == new_extension ->
            Ok(filepath.join(folder, file))
          _ -> Error(Nil)
        }
      })
  }
}

/// List all the accepted snapshots in a folder. Every file is automatically
/// prepended with the folder so you get the full path of each file.
///
fn list_accepted_snapshots(in folder: String) -> Result(List(String), Error) {
  case simplifile.read_directory(folder) {
    Error(reason) -> Error(CannotReadSnapshots(reason:, folder:))
    Ok(files) ->
      Ok({
        use file <- list.filter_map(files)
        case filepath.extension(file) {
          // Only keep the files with the ".accepted" extension and join their
          // name with the folder's path.
          Ok(extension) if extension == accepted_extension ->
            Ok(filepath.join(folder, file))
          _ -> Error(Nil)
        }
      })
  }
}

fn accept_snapshot(
  new_snapshot_path: String,
  analyser: Analyser,
) -> Result(Nil, Error) {
  use snapshot <- result.try(read_new(new_snapshot_path))
  let Snapshot(title:, content:, info: _) = snapshot
  let accepted_snapshot_path = to_accepted_path(new_snapshot_path)

  // Once a snapshot is accepted we need to mark it as referenced. Otherwise
  // running `gleam run -m birdie accept` (or `review`) followed by
  // `gleam run -m stale check` would result in all those accepted snapshots
  // being marked as stale!
  use referenced_file <- result.try(referenced_file_path())
  use _ <- result.try(case simplifile.is_file(referenced_file) {
    Ok(_) -> Ok(Nil)
    Error(_) ->
      simplifile.create_file(referenced_file)
      |> result.map_error(CannotCreateReferencedFile(
        file: referenced_file,
        reason: _,
      ))
  })
  use _ <- result.try(
    simplifile.append(
      filepath.base_name(accepted_snapshot_path) <> "\n",
      to: referenced_file,
    )
    |> result.map_error(CannotMarkSnapshotAsReferenced),
  )

  case get_info_for_snapshot(analyser, titled: 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(info) -> {
      use _ <- result.try(
        simplifile.delete(new_snapshot_path)
        |> result.map_error(CannotAcceptSnapshot(_, new_snapshot_path)),
      )

      Snapshot(title:, content:, info: Some(info))
      |> serialise
      |> simplifile.write(to: accepted_snapshot_path)
      |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot_path))
    }

    // If there's no snapshot with this title, or there's multiple ones (that's
    // an error!) we don't have any way of linking additional information to
    // this snapshot test.
    // So we can just move the `new` snapshot to the `accepted` one.
    Error(_) ->
      simplifile.rename(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_extension
}

/// 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_extension
}

// --- PRETTY PRINTING ---------------------------------------------------------

fn to_diagnostic(error: Error) -> List(diagnostic.Diagnostic) {
  // Produces a diagnostic with no label and an error level
  let error_diagnostic = fn(title, text) {
    [
      diagnostic.Diagnostic(
        level: diagnostic.Erro,
        label: None,
        hint: None,
        title:,
        text:,
      ),
    ]
  }

  case error {
    SnapshotWithEmptyTitle ->
      error_diagnostic(
        "snapshot with empty title",
        "A snapshot cannot have an empty title.",
      )

    CannotCreateSnapshotsFolder(reason:) ->
      error_diagnostic(
        "cannot create snapshot folder",
        "An unexpected error happened: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadAcceptedSnapshot(reason:, source:) ->
      error_diagnostic(
        "cannot read accepted snapshot",
        "An unexpected error happened trying to read "
          <> ansi.italic("\"" <> source <> "\":")
          <> " "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadNewSnapshot(reason:, source:) ->
      error_diagnostic(
        "cannot read new snapshot",
        "An unexpected error happened trying to read "
          <> ansi.italic("\"" <> source <> "\": ")
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotSaveNewSnapshot(reason:, title:, destination:) ->
      error_diagnostic(
        "cannot save new snapshot",
        "An unexpected error happened trying to save "
          <> ansi.italic("\"" <> title <> "\"")
          <> " to "
          <> ansi.italic("\"" <> destination <> "\": ")
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadSnapshots(reason:, folder: _) ->
      error_diagnostic(
        "cannot read snapshots folder",
        "An unexpected error happened trying to read the snapshots folder: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotRejectSnapshot(reason:, snapshot:) ->
      error_diagnostic(
        "cannot reject snapshot",
        "An unexpected error happened trying to reject "
          <> ansi.italic("\"" <> snapshot <> "\": ")
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotAcceptSnapshot(reason:, snapshot:) ->
      error_diagnostic(
        "cannot accept snapshot",
        "An unexpected error happened trying to accept "
          <> ansi.italic("\"" <> snapshot <> "\": ")
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadUserInput -> error_diagnostic("cannot read user input", "")

    CorruptedSnapshot(source:) -> [
      diagnostic.Diagnostic(
        level: diagnostic.Erro,
        title: "corrupted snapshot",
        label: None,
        text: "It looks like "
          <> ansi.italic("\"" <> source <> "\" ")
          <> "is not a valid snapshot.\n"
          <> "This might happen when someone modifies its content.",
        hint: Some("try deleting the snapshot and recreating it."),
      ),
    ]

    CannotCreateReferencedFile(file:, reason: simplifile.Eacces) -> [
      diagnostic.Diagnostic(
        level: diagnostic.Erro,
        title: "missing permission to create reference file",
        label: None,
        text: "I don't have the required permission to create the file used to track\n"
          <> { "stale snapshots at: `" <> file <> "`.\n" }
          <> "This usually happens when the current user doesn't have a write\n"
          <> "permission for the system's temporary directory.",
        hint: Some(
          "you can set the $TEMP environment variable to make me use a\n"
          <> "different directory to write the reference file in.",
        ),
      ),
    ]

    CannotReadReferencedFile(file:, reason: simplifile.Eacces) -> [
      diagnostic.Diagnostic(
        level: diagnostic.Erro,
        title: "missing permission to read reference file",
        label: None,
        text: "I don't have the required permission to read the file used to track\n"
          <> { "stale snapshots at: `" <> file <> "`.\n" }
          <> "This usually happens when the current user doesn't have a read\n"
          <> "permission for the system's temporary directory.",
        hint: Some(
          "you can set the $TEMP environment variable to make me use a\n"
          <> "different directory to write the reference file in.",
        ),
      ),
    ]

    CannotCreateReferencedFile(file: _, reason:) ->
      error_diagnostic(
        "cannot create reference file",
        "An unexpected error happened trying to create the file used to track stale snapshot: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadReferencedFile(file: _, reason:) ->
      error_diagnostic(
        "cannot read reference file",
        "An unexpected error happened trying to read the file used to track stale snapshot: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotMarkSnapshotAsReferenced(reason:) ->
      error_diagnostic(
        "cannot mark snapshot as referenced",
        "An unexpected error happened trying to mark a snapshot as referenced: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotFindProjectRoot(reason:) ->
      error_diagnostic(
        "cannot find project root",
        "An unexpected error happened trying to locate the project's root: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    MissingReferencedFile -> {
      [
        diagnostic.Diagnostic(
          level: diagnostic.Erro,
          title: "missing stale snapshot file",
          label: None,
          text: "I couldn't find any information about stale snapshots.",
          hint: Some(
            "remember you have to run `gleam test` first, so I can find any stale snapshot.",
          ),
        ),
      ]
    }

    StaleSnapshotsFound(stale_snapshots:) -> {
      let titles =
        list.map(stale_snapshots, fn(snapshot) {
          "  - " <> filepath.strip_extension(snapshot)
        })
        |> string.join(with: "\n")

      let text =
        "I found the following stale snapshots:\n\n"
        <> titles
        <> "\n\n"
        <> "These snapshots were not referenced by any snapshot test during the "
        <> "last `gleam test`\n"

      [
        diagnostic.Diagnostic(
          level: diagnostic.Erro,
          title: "stale snapshot found",
          label: None,
          text:,
          hint: Some("run `gleam run -m birdie stale delete` to delete them"),
        ),
      ]
    }

    CannotDeleteStaleSnapshot(reason:) ->
      error_diagnostic(
        "cannot delete stale snapshot",
        "An unexpected error happened trying to delete a stale snapshot: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadTestDirectory(reason:) ->
      error_diagnostic(
        "cannot read test directroy",
        "An unexpected error happened trying to read the constents of the test directory: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotFigureOutProjectName(reason:) ->
      error_diagnostic(
        "cannot figure out project's name",
        "An unexpected error happened trying to figure out the project's name: "
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotReadTestFile(reason:, file:) ->
      error_diagnostic(
        "cannot read test file",
        "An unexpected error happened trying to read "
          <> ansi.italic("\"" <> file <> "\": ")
          <> simplifile.describe_error(reason)
          <> ".",
      )

    CannotMigrateBirdieSnapshotDirectory(reason:, from:, to:) ->
      error_diagnostic(
        "cannot migrate snapshot directory",
        "An unexpected error happened when trying to migrate\n"
          <> ansi.italic("\"" <> from <> "\" to ")
          <> ansi.italic("\"" <> to <> "\"\n")
          <> "The error is: "
          <> simplifile.describe_error(reason),
      )

    AnalysisError(errors) -> list.map(errors, analyser.error_to_diagnostic)
  }
}

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:, content: _, info:) = snapshot
  case info {
    None -> [InfoLineWithTitle(title, SplitWords, "title")]
    Some(SnapshotInfo(file:, test_function_name:)) -> [
      InfoLineWithTitle(title, SplitWords, "title"),
      InfoLineWithTitle(file, Truncate, "file"),
      InfoLineWithTitle(test_function_name, Truncate, "name"),
    ]
  }
}

fn new_snapshot_box(
  snapshot: Snapshot(New),
  additional_info_lines: List(InfoLine),
) -> String {
  let Snapshot(title: _, content:, info: _) = snapshot

  let content =
    string.split(content, on: "\n")
    |> list.index_map(fn(line, i) {
      DiffLine(number: i + 1, line:, kind: diff.New)
    })

  pretty_box(
    "new snapshot",
    content,
    list.flatten([snapshot_default_lines(snapshot), additional_info_lines]),
    fn(shared_line) { shared_line },
  )
}

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.flatten,
    fn(shared_line) { ansi.dim(shared_line) },
  )
}

fn regular_snapshot_box(
  new: Snapshot(New),
  additional_info_lines: List(InfoLine),
) {
  let Snapshot(title: _, content:, info: _) = new

  let content =
    string.split(content, on: "\n")
    |> list.index_map(fn(line, i) {
      DiffLine(number: i + 1, line:, kind: diff.Shared)
    })

  pretty_box(
    "mismatched snapshots",
    content,
    [snapshot_default_lines(new), additional_info_lines]
      |> list.flatten,
    fn(shared_line) { shared_line },
  )
}

fn count_digits(number: Int) -> Int {
  count_digits_loop(int.absolute_value(number), 0)
}

fn count_digits_loop(number: Int, digits: Int) -> Int {
  case number < 10 {
    True -> 1 + digits
    False -> count_digits_loop(number / 10, 1 + digits)
  }
}

fn pretty_box(
  title: String,
  content_lines: List(DiffLine),
  info_lines: List(InfoLine),
  // Determines how a shared diff line is to be displayed
  shared_line_style: fn(String) -> String,
) -> String {
  let width = terminal_width()
  let lines_count = list.length(content_lines) + 1
  let padding = count_digits(lines_count) * 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, shared_line_style))
    |> 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:, ..) -> #(
      "  " <> 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,
  shared_line_style: fn(String) -> String,
) -> String {
  let DiffLine(number:, line:, kind:) = diff_line

  let #(pretty_number, pretty_line, separator) = case kind {
    diff.Shared -> #(
      int.to_string(number)
        |> string.pad_start(to: padding - 1, with: " ")
        |> ansi.dim,
      shared_line_style(line),
      " │ ",
    )

    diff.New -> #(
      int.to_string(number)
        |> string.pad_start(to: padding - 1, with: " ")
        |> ansi.green
        |> ansi.bold,
      ansi.green(line),
      ansi.green(" + "),
    )

    diff.Old -> {
      let number =
        { " " <> int.to_string(number) }
        |> string.pad_end(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 -------------------------------------------------------------

/// 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 {
  parse_and_run(argv.load().arguments)
}

fn parse_and_run(args: List(String)) {
  case cli.parse(args) {
    Ok(command) -> run_command(command)

    Error(UnknownOption(command:, option:)) -> {
      cli.unknown_option_error(birdie_version, command, option)
      |> io.println
      exit(1)
    }
    Error(UnknownSubcommand(command:, subcommand:)) -> {
      cli.unknown_subcommand_error(birdie_version, command, subcommand)
      |> io.println
      exit(1)
    }
    Error(MissingSubcommand(command:)) -> {
      cli.missing_subcommand_error(birdie_version, command)
      |> io.println
      exit(1)
    }
    Error(UnexpectedArgument(command:, argument:)) -> {
      cli.unexpected_argument_error(birdie_version, command, argument)
      |> io.println
      exit(1)
    }
    Error(UnknownCommand(command:)) ->
      case cli.similar_command(to: command) {
        Error(Nil) -> {
          cli.unknown_command_error(command, True)
          |> io.println
          exit(1)
        }

        Ok(new_command) -> {
          cli.unknown_command_error(command, False)
          |> io.println

          let prompt =
            "I think you misspelled `"
            <> new_command
            <> "`, would you like me to run it instead?"

          case ask_yes_or_no(prompt) {
            No -> {
              io.println("\n" <> cli.main_help_text())
              exit(1)
            }
            Yes ->
              replace_first(command, with: new_command, in: args)
              |> parse_and_run
          }
        }
      }
  }
}

fn ask_yes_or_no(prompt: String) -> Answer {
  case get_line(prompt <> " [Y/n] ") {
    Error(_) -> No
    Ok(line) ->
      case string.lowercase(line) |> string.trim {
        "yes" | "y" | "" -> Yes
        _ -> No
      }
  }
}

type Answer {
  Yes
  No
}

fn run_command(command: Command) -> Nil {
  case migrate_from_old_directory() {
    Error(diagnostic) -> report_status(Error(diagnostic))
    Ok(_) -> {
      case command {
        Review -> report_status(review())
        Accept -> report_status(accept_all())
        Reject -> report_status(reject_all())
        Stale(CheckStale) -> report_status(check_stale())
        Stale(DeleteStale) -> report_status(delete_stale())

        Help ->
          io.println(cli.help_text(
            birdie_version,
            for: Help,
            explaining: FullCommand,
          ))

        WithHelpOption(command:, explained:) ->
          io.println(cli.help_text(
            birdie_version,
            for: command,
            explaining: explained,
          ))
      }
    }
  }
}

fn migrate_from_old_directory() -> Result(Nil, Error) {
  // We're not using the snapshot_folder function, because we don't want to
  // create that directroy automatically if it doesn't exist!
  use snapshot_folder <- result.try(snapshot_folder_name())
  use legacy_snapshot_folder <- result.try(legacy_snapshot_folder_name())
  case simplifile.is_directory(legacy_snapshot_folder) {
    // If the legacy directory doesn't exist, or it's not a directory at all
    // there's no need to do anything.
    Error(simplifile.Enoent) -> Ok(Nil)
    Ok(False) -> Ok(Nil)

    Error(reason) ->
      Error(CannotReadSnapshots(reason:, folder: legacy_snapshot_folder))

    Ok(True) -> {
      diagnostic.Diagnostic(
        level: diagnostic.Warn,
        title: "moved snapshots directory",
        label: None,
        hint: None,
        text: "Starting from 1.6 birdie is using the `test/birdie_snapshots` directory to
store snapshot tests, so `birdie_snapshots` was moved there.",
      )
      |> diagnostic.to_string
      |> string.append("\n")
      |> io.println

      simplifile.rename(legacy_snapshot_folder, snapshot_folder)
      |> result.map_error(CannotMigrateBirdieSnapshotDirectory(
        reason: _,
        from: legacy_snapshot_folder,
        to: snapshot_folder,
      ))
    }
  }
}

fn review() -> Result(Nil, Error) {
  use snapshots_folder <- result.try(snapshot_folder())
  use analyser <- result.try(analyse_test_directory())
  use _ <- result.try(update_accepted_snapshots(snapshots_folder, analyser))

  // Before reviewing, we want to update the files of all the existing snapshots
  // because they might have been moved to a different module, changing their
  // source `file`.
  use _ <- result.try(do_review(snapshots_folder, analyser))
  Ok(Nil)
}

fn update_accepted_snapshots(
  snapshots_folder: String,
  analyser: Analyser,
) -> Result(Nil, Error) {
  use accepted_snapshots <- result.try(list_accepted_snapshots(snapshots_folder))
  use accepted_snapshot <- list.try_each(accepted_snapshots)
  use snapshot <- result.try(read_accepted(accepted_snapshot))
  case snapshot {
    None -> Ok(Nil)
    Some(Snapshot(title:, content: _, info: existing_info) as snapshot) -> {
      case get_info_for_snapshot(analyser, titled: title), existing_info {
        Ok(new_info), Some(existing_info) if new_info != existing_info ->
          Snapshot(..snapshot, info: Some(new_info))
          |> serialise
          |> simplifile.write(to: accepted_snapshot)
          |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot))

        Ok(info), None ->
          Snapshot(..snapshot, info: Some(info))
          |> serialise
          |> simplifile.write(to: accepted_snapshot)
          |> result.map_error(CannotAcceptSnapshot(_, accepted_snapshot))

        _, _ -> Ok(Nil)
      }
    }
  }
}

/// If there's a _single_ snapshot with the given title, this return information
/// about it.
/// If there's no snapshot, or there's multiple ones then that's an error! We
/// can't reliably return information about because it's either missing, or
/// there's multiple snapshots sharing the same title and it's impossible to
/// know which one we're referring to.
fn get_info_for_snapshot(
  analyser: Analyser,
  titled title: String,
) -> Result(SnapshotInfo, Nil) {
  case analyser.get_snapshot_tests(analyser, titled: title) {
    [] | [_, _, ..] -> Error(Nil)
    [#(uri, analyser.SnapshotTest(test_function_name:, ..))] ->
      Ok(SnapshotInfo(file: uri.path, test_function_name:))
  }
}

fn do_review(
  snapshots_folder: String,
  analyser: Analyser,
) -> Result(Nil, Error) {
  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 = review_loop(new_snapshots, analyser, 1, n, ShowDiff)
      // 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)
    }
  }
}

/// Reviews all the new snapshots one by one.
fn review_loop(
  new_snapshot_paths: List(String),
  analyser: Analyser,
  current: Int,
  out_of: Int,
  mode: ReviewMode,
) -> 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 =
        Snapshot(
          ..new_snapshot,
          info: get_info_for_snapshot(analyser, titled: new_snapshot.title)
            |> option.from_result,
        )

      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, mode {
        None, _ -> new_snapshot_box(new_snapshot, [])
        Some(accepted_snapshot), ShowDiff ->
          diff_snapshot_box(accepted_snapshot, new_snapshot, [])
        Some(_accepted_snapshot), HideDiff ->
          regular_snapshot_box(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(mode))
      case choice {
        AcceptSnapshot -> {
          use _ <- result.try(accept_snapshot(new_snapshot_path, analyser))
          review_loop(rest, analyser, current + 1, out_of, mode)
        }
        RejectSnapshot -> {
          use _ <- result.try(reject_snapshot(new_snapshot_path))
          review_loop(rest, analyser, current + 1, out_of, mode)
        }
        SkipSnapshot -> {
          review_loop(rest, analyser, current + 1, out_of, mode)
        }
        ToggleDiffView -> {
          let mode = toggle_mode(mode)
          review_loop(new_snapshot_paths, analyser, current, out_of, mode)
        }
      }
    }
  }
}

/// Wether or not we should be showing a diff during the current review process.
///
type ReviewMode {
  ShowDiff
  HideDiff
}

fn toggle_mode(mode: ReviewMode) -> ReviewMode {
  case mode {
    ShowDiff -> HideDiff
    HideDiff -> ShowDiff
  }
}

/// The choice the user can make when reviewing a snapshot.
///
type ReviewChoice {
  AcceptSnapshot
  RejectSnapshot
  SkipSnapshot
  ToggleDiffView
}

/// 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(mode: ReviewMode) -> Result(ReviewChoice, Error) {
  let diff_message = case mode {
    HideDiff -> " show diff  "
    ShowDiff -> " hide diff  "
  }

  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")
    }
    <> {
      ansi.bold(ansi.cyan("  d"))
      <> diff_message
      <> ansi.dim("toggle snapshot diff\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(get_line("> "), string.trim) {
    Ok("a") -> Ok(AcceptSnapshot)
    Ok("r") -> Ok(RejectSnapshot)
    Ok("s") -> Ok(SkipSnapshot)
    Ok("d") -> Ok(ToggleDiffView)
    // 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(6)
      ask_choice(mode)
    }
    Error(_) -> Error(CannotReadUserInput)
  }
}

fn accept_all() -> Result(Nil, Error) {
  io.println("Looking for new snapshots...")
  use snapshots_folder <- result.try(snapshot_folder())
  use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))

  use analyser <- result.try(analyse_test_directory())
  use _ <- result.try(update_accepted_snapshots(snapshots_folder, analyser))

  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(_, analyser))
}

fn reject_all() -> Result(Nil, Error) {
  io.println("Looking for new snapshots...")
  use snapshots_folder <- result.try(snapshot_folder())
  use new_snapshots <- result.try(list_new_snapshots(in: snapshots_folder))

  use analyser <- result.try(analyse_test_directory())
  use _ <- result.try(update_accepted_snapshots(snapshots_folder, analyser))

  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)
}

/// This finds the current Gleam project's test directory and analyses all the
/// modules inside to find snapshot tests and information related to them.
/// This could fail under different circumstances:
/// - If the file system operations (like reading) fail, should technically
///   never happen in a normal scenario
/// - OR if the test directory contains snapshots with duplicate titles!
///   This is something that could happen and we need to show a nice error
///   message.
fn analyse_test_directory() -> Result(Analyser, Error) {
  use root <- result.try(
    project.find_root()
    |> result.map_error(CannotFindProjectRoot),
  )
  use files <- result.try(
    filepath.join(root, "test")
    |> simplifile.get_files
    |> result.map_error(CannotReadTestDirectory),
  )

  use analyser <- result.try(
    list.try_fold(over: files, from: analyser.new(), with: fn(analyser, file) {
      // If the file is not a gleam file, we just keep going...
      let is_gleam_file = filepath.extension(file) == Ok("gleam")
      use <- bool.guard(when: !is_gleam_file, return: Ok(analyser))

      //...otherwise we try and read its content and analyse it
      use source <- result.try(
        simplifile.read(file)
        |> result.map_error(CannotReadTestFile(_, file)),
      )
      let path = filepath_to_uri(file)
      Ok(analyser.analyse(analyser, analyser.Module(path:, source:)))
    }),
  )

  // If we could successfully read all the modules, now we can check for errors.
  // TODO)) What about warnings?
  case analyser.errors(analyser) {
    [] -> Ok(analyser)
    [_, ..] as errors -> Error(AnalysisError(errors))
  }
}

fn filepath_to_uri(path: String) -> uri.Uri {
  uri.Uri(
    scheme: Some("file"),
    userinfo: None,
    host: None,
    port: None,
    path:,
    query: None,
    fragment: None,
  )
}

fn stale_snapshots_file_names() -> Result(List(String), Error) {
  use snapshots_folder <- result.try(snapshot_folder())
  use referenced_file <- result.try(referenced_file_path())
  case simplifile.read(referenced_file) {
    // If the file is not there we just give up. It means that we didn't run
    // `gleam test` beforehand.
    Error(Enoent) -> Error(MissingReferencedFile)

    // If the file cannot be read for any other reason we end up reporting the
    // error.
    Error(reason) ->
      Error(CannotReadReferencedFile(file: referenced_file, reason:))

    // Otherwise we can continue checking!
    Ok(non_stale_snapshots) -> {
      let existing_accepted_snapshots =
        simplifile.get_files(in: snapshots_folder)
        |> result.unwrap(or: [])
        |> list.fold(from: set.new(), with: fn(files, file) {
          case filepath.extension(file) == Ok(accepted_extension) {
            True -> set.insert(files, filepath.base_name(file))
            False -> files
          }
        })

      let non_stale_snapshots = string.split(non_stale_snapshots, on: "\n")

      existing_accepted_snapshots
      |> set.drop(non_stale_snapshots)
      |> set.to_list
      |> Ok
    }
  }
}

fn check_stale() -> Result(Nil, Error) {
  io.println("Checking stale snapshots...")
  use stale_snapshots <- result.try(stale_snapshots_file_names())
  case stale_snapshots {
    [] -> Ok(Nil)
    [_, ..] -> Error(StaleSnapshotsFound(stale_snapshots:))
  }
}

fn delete_stale() -> Result(Nil, Error) {
  io.println("Checking stale snapshots...")
  use snapshots_folder <- result.try(snapshot_folder())
  use stale_snapshots <- result.try(stale_snapshots_file_names())

  list.try_each(stale_snapshots, fn(stale_snapshot) {
    filepath.join(snapshots_folder, stale_snapshot)
    |> simplifile.delete
  })
  |> result.map_error(CannotDeleteStaleSnapshot(reason: _))
}

fn report_status(result: Result(Nil, Error)) -> Nil {
  case result {
    Ok(Nil) -> {
      io.println(ansi.green("🐦‍⬛ Done!"))
      exit(0)
    }
    Error(error) -> {
      to_diagnostic(error)
      |> list.map(diagnostic.to_string)
      |> string.join("\n\n")
      |> io.println_error

      exit(1)
    }
  }
}

fn terminal_width() -> Int {
  case term_size.get() {
    Ok(#(_, columns)) -> columns
    Error(_) -> 80
  }
}

// --- HELPERS -----------------------------------------------------------------

/// Replaces the first occurrence of an element in the list with the given
/// replacement.
///
fn replace_first(
  in list: List(a),
  item item: a,
  with replacement: a,
) -> List(a) {
  case list {
    [] -> []
    [first, ..rest] if first == item -> [replacement, ..rest]
    [first, ..rest] -> [first, ..replace_first(rest, item, replacement)]
  }
}

/// Clear the screen.
///
fn clear() -> Nil {
  io.print("\u{1b}c")
  io.print("\u{1b}[H\u{1b}[J")
}

/// Move the cursor up a given number of lines.
///
fn cursor_up(n: Int) -> Nil {
  io.print("\u{1b}[" <> int.to_string(n) <> "A")
}

/// Clear the line the cursor is currently on.
///
fn clear_line() -> Nil {
  io.print("\u{1b}[2K")
}

// --- FFI ---------------------------------------------------------------------

@external(erlang, "erlang", "halt")
@external(javascript, "./birdie_ffi.mjs", "halt")
fn exit(status_code: Int) -> Nil

/// Reads a line from standard input with the given prompt.
///
/// # Example
///
/// ```gleam
/// get_line("Language: ")
/// // > Language: <- Gleam
/// // -> Ok("Gleam\n")
/// ```
@external(erlang, "birdie_ffi", "get_line")
@external(javascript, "./birdie_ffi.mjs", "get_line")
fn get_line(prompt prompt: String) -> Result(String, Nil)


================================================
FILE: src/birdie_ffi.erl
================================================
-module(birdie_ffi).
-export([get_line/1, is_windows/0]).

get_line(Prompt) ->
    case io:get_line(Prompt) of
        eof -> {error, nil};
        {error, _} -> {error, nil};
        Data when is_binary(Data) -> {ok, Data};
        Data when is_list(Data) -> {ok, unicode:characters_to_binary(Data)}
    end.

is_windows() ->
    case os:type() of
        {win32, _} -> true;
        _ -> false
    end.


================================================
FILE: src/birdie_ffi.mjs
================================================
import fs from "node:fs";
import { exit } from "node:process";
import { Result$Ok, Result$Error } from "./gleam.mjs";

export function is_windows() {
  return globalThis?.process?.platform === "win32" || globalThis?.Deno?.build?.os === "windows";
}

export function halt(status_code) {
  exit(status_code);
}

export function get_line(prompt) {
  process.stdout.write(prompt);
  const buffer = Buffer.alloc(4096);
  while (true) {
    try {
      const bytesRead = fs.readSync(process.stdin.fd, buffer, 0, buffer.length, null);
      const input = buffer.toString("utf-8", 0, bytesRead);
      return Result$Ok(input);
    } catch (error) {
      if (error.code === "EAGAIN") continue;
      return Result$Error(undefined);
    }
  }
}


================================================
FILE: test/birdie_snapshots/a_result_test.accepted
================================================
---
version: 1.6.0
title: a result test
file: ./test/birdie_test.gleam
test_name: a_result_test
---
Ok(11)


================================================
FILE: test/birdie_snapshots/complex_function_test.accepted
================================================
---
version: 1.6.0
title: complex function test
file: ./test/birdie_test.gleam
test_name: complex_function_test
---
case wibble(wobble, woo) {
  True ->
    io.println("Phew, we don't have to launch the missiles...")
  False -> {
    io.println("Not wibble!")
    launch_missiles()
  }
}


================================================
FILE: test/birdie_snapshots/hello_birdie_test.accepted
================================================
---
version: 1.6.0
title: hello birdie test
file: ./test/birdie_test.gleam
test_name: hello_birdie_test
---
🐦‍⬛ smile for the birdie!


================================================
FILE: test/birdie_snapshots/list_test.accepted
================================================
---
version: 1.6.0
title: list test
file: ./test/birdie_test.gleam
test_name: list_test
---
[ 1, 2, 3, 4 ]


================================================
FILE: test/birdie_snapshots/multi_line_diagnostic.accepted
================================================
---
version: 1.6.0
title: multi line diagnostic
file: ./test/birdie_test/diagnostic_test.gleam
test_name: multi_line_diagnostic_test
---
error: example
  ╭─ wibble.src
  │
1 │ line here
2 │ here
  │ ^^^^^^^^^ message
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_no_tooltip_text.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with no tooltip text
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_no_tooltip_text_test
---
error: example
  ╭─ wibble.src
  │
1 │ highlighted-not-highlighted
  │ ^^^^^^^^^^^
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_above.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with secondary label above
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_secondary_label_above_test
---
error: example
  ╭─ wibble.src
  │
1 │ secondary here
  │           ~~~~
  ╎
3 │ main
  │ ^^^^
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_below.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with secondary label below
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_secondary_label_below_test
---
error: example
  ╭─ wibble.src
  │
1 │ main
  │ ^^^^ primary
  ╎
3 │ secondary here
  │           ~~~~ secondary
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_on_same_line,_secondary_label_is_ignored.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with secondary label on same line, secondary label is ignored
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_secondary_label_on_same_line_test
---
error: example
  ╭─ wibble.src
  │
1 │ secondary primary
  │           ^^^^^^^ shown
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_right_above.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with secondary label right above
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_secondary_label_right_above_test
---
error: example
  ╭─ wibble.src
  │
1 │ secondary here
  │           ~~~~
2 │ main
  │ ^^^^ primary
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_secondary_label_right_below.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with secondary label right below
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_secondary_label_right_below_test
---
warning: example
  ╭─ wibble.src
  │
1 │ main
  │ ^^^^ primary
2 │ secondary here
  │           ~~~~ secondary
  │


================================================
FILE: test/birdie_snapshots/single_line_diagnostic_with_tooltip_text.accepted
================================================
---
version: 1.6.0
title: single line diagnostic with tooltip text
file: ./test/birdie_test/diagnostic_test.gleam
test_name: single_line_diagnostic_with_tooltip_text_test
---
error: example
  ╭─ wibble.src
  │
1 │ highlighted-not-highlighted
  │ ^^^^^^^^^^^ not good
  │


================================================
FILE: test/birdie_test/cli_test.gleam
================================================
import birdie/internal/cli.{
  type Command, Accept, CheckStale, DeleteStale, FullCommand, Help, Reject,
  Review, Stale, TopLevelCommand, UnexpectedArgument, UnknownCommand,
  UnknownOption, UnknownSubcommand, WithHelpOption,
}

import gleam/list
import gleam/string

pub fn unknown_top_level_command_test() {
  assert Error(UnknownCommand(command: "wibble")) == cli.parse(["wibble"])
  assert Error(UnknownCommand(command: "wibble"))
    == cli.parse(["wibble", "wobble"])
  assert Error(UnknownCommand(command: "wibble"))
    == cli.parse(["wibble", "--help"])
  assert Error(UnknownCommand(command: "wibble")) == cli.parse(["wibble", "-h"])
}

pub fn parse_review_test() {
  // No arguments are interpreted as the review command
  assert Ok(Review) == cli.parse([])
  assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["--help"])
  assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["-h"])

  // Explicitly using the review command
  assert Ok(Review) == cli.parse(["review"])
  assert Ok(WithHelpOption(Review, FullCommand))
    == cli.parse(["review", "--help"])
  assert Ok(WithHelpOption(Review, FullCommand)) == cli.parse(["review", "-h"])

  // Unknown subcommands and options
  assert Error(UnknownSubcommand(Review, "wibble"))
    == cli.parse(["review", "wibble"])
  assert Error(UnknownOption(Review, "-w")) == cli.parse(["-w"])
  assert Error(UnknownOption(Review, "-w")) == cli.parse(["review", "-w"])
  assert Error(UnknownOption(Review, "--wibble")) == cli.parse(["--wibble"])
  assert Error(UnknownOption(Review, "--wibble"))
    == cli.parse(["review", "--wibble"])
}

pub fn parse_accept_test() {
  // Explicitly using the review command
  assert Ok(Accept) == cli.parse(["accept"])
  assert Ok(WithHelpOption(Accept, FullCommand))
    == cli.parse(["accept", "--help"])
  assert Ok(WithHelpOption(Accept, FullCommand)) == cli.parse(["accept", "-h"])

  // Unknown subcommands and options
  assert Error(UnknownSubcommand(Accept, "wibble"))
    == cli.parse(["accept", "wibble"])
  assert Error(UnknownOption(Accept, "-w")) == cli.parse(["accept", "-w"])
  assert Error(UnknownOption(Accept, "--wibble"))
    == cli.parse(["accept", "--wibble"])
}

pub fn parse_reject_test() {
  // Explicitly using the review command
  assert Ok(Reject) == cli.parse(["reject"])
  assert Ok(WithHelpOption(Reject, FullCommand))
    == cli.parse(["reject", "--help"])
  assert Ok(WithHelpOption(Reject, FullCommand)) == cli.parse(["reject", "-h"])

  // Unknown subcommands and options
  assert Error(UnknownSubcommand(Reject, "wibble"))
    == cli.parse(["reject", "wibble"])
  assert Error(UnknownOption(Reject, "-w")) == cli.parse(["reject", "-w"])
  assert Error(UnknownOption(Reject, "--wibble"))
    == cli.parse(["reject", "--wibble"])
}

pub fn parse_stale_test() {
  let assert Ok(WithHelpOption(Stale(_), TopLevelCommand)) =
    cli.parse(["stale", "--help"])
  let assert Ok(WithHelpOption(Stale(_), TopLevelCommand)) =
    cli.parse(["stale", "-h"])

  assert Ok(Stale(CheckStale)) == cli.parse(["stale", "check"])
  assert Ok(WithHelpOption(Stale(CheckStale), FullCommand))
    == cli.parse(["stale", "check", "--help"])

  assert Ok(Stale(DeleteStale)) == cli.parse(["stale", "delete"])
  assert Ok(WithHelpOption(Stale(DeleteStale), FullCommand))
    == cli.parse(["stale", "delete", "--help"])

  // Unknown subcommands and options
  let assert Error(UnknownSubcommand(Stale(_), "wibble")) =
    cli.parse(["stale", "wibble"])

  assert Error(UnexpectedArgument(Stale(CheckStale), "wibble"))
    == cli.parse(["stale", "check", "wibble"])
  assert Error(UnexpectedArgument(Stale(DeleteStale), "wibble"))
    == cli.parse(["stale", "delete", "wibble"])

  let assert Error(UnknownOption(Stale(_), "-w")) = cli.parse(["stale", "-w"])
  let assert Error(UnknownOption(Stale(_), "--wibble")) =
    cli.parse(["stale", "--wibble"])
  assert Error(UnknownOption(Stale(CheckStale), "-w"))
    == cli.parse(["stale", "check", "-w"])
  assert Error(UnknownOption(Stale(CheckStale), "--wibble"))
    == cli.parse(["stale", "check", "--wibble"])
  assert Error(UnknownOption(Stale(DeleteStale), "-w"))
    == cli.parse(["stale", "delete", "-w"])
  assert Error(UnknownOption(Stale(DeleteStale), "--wibble"))
    == cli.parse(["stale", "delete", "--wibble"])
}

pub fn parse_help_test() {
  // Explicitly using the review command
  assert Ok(Help) == cli.parse(["help"])
  assert Ok(Help) == cli.parse(["help", "--help"])
  assert Ok(Help) == cli.parse(["help", "-h"])
}

pub fn all_commands_test() {
  assert cli.all_commands() == all_known_commands([])
}

fn all_known_commands(all: List(Command)) -> List(String) {
  case all {
    [] -> all_known_commands([Accept, ..all])
    [Accept, ..] -> all_known_commands([Help, ..all])
    [Help, ..] -> all_known_commands([Reject, ..all])
    [Reject, ..] -> all_known_commands([Stale(CheckStale), ..all])
    [Stale(_), ..] -> all_known_commands([Review, ..all])
    [Review, ..] ->
      list.map(all, command_to_string)
      |> list.sort(string.compare)

    [WithHelpOption(..), ..] -> panic as "this command is never suggested"
  }
}

fn command_to_string(command: Command) {
  case command {
    Accept -> "accept"
    Help -> "help"
    Reject -> "reject"
    Review -> "review"
    Stale(_) -> "stale"
    WithHelpOption(..) -> panic as "this command is never suggested"
  }
}


================================================
FILE: test/birdie_test/diagnostic_test.gleam
================================================
import birdie
import birdie/internal/diagnostic
import glance
import gleam/option

pub fn single_line_diagnostic_with_no_tooltip_text_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "highlighted-not-highlighted\nnot shown",
      position: glance.Span(0, 11),
      content: "",
      secondary_label: option.None,
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(title: "single line diagnostic with no tooltip text")
}

pub fn single_line_diagnostic_with_tooltip_text_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "highlighted-not-highlighted\nnot shown",
      position: glance.Span(0, 11),
      content: "not good",
      secondary_label: option.None,
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(title: "single line diagnostic with tooltip text")
}

pub fn single_line_diagnostic_with_secondary_label_right_above_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "secondary here\nmain",
      position: glance.Span(15, 19),
      content: "primary",
      secondary_label: option.Some(#(glance.Span(10, 14), "")),
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(
    title: "single line diagnostic with secondary label right above",
  )
}

pub fn single_line_diagnostic_with_secondary_label_right_below_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Warn,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "main\nsecondary here",
      position: glance.Span(0, 4),
      content: "primary",
      secondary_label: option.Some(#(glance.Span(15, 19), "secondary")),
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(
    title: "single line diagnostic with secondary label right below",
  )
}

pub fn single_line_diagnostic_with_secondary_label_below_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "main\nnot shown\nsecondary here",
      position: glance.Span(0, 4),
      content: "primary",
      secondary_label: option.Some(#(glance.Span(25, 29), "secondary")),
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(title: "single line diagnostic with secondary label below")
}

pub fn single_line_diagnostic_with_secondary_label_above_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "secondary here\nnot shown\nmain",
      position: glance.Span(25, 29),
      content: "",
      secondary_label: option.Some(#(glance.Span(10, 14), "")),
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(title: "single line diagnostic with secondary label above")
}

pub fn single_line_diagnostic_with_secondary_label_on_same_line_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "secondary primary",
      position: glance.Span(10, 17),
      content: "shown",
      secondary_label: option.Some(#(glance.Span(0, 9), "not shows")),
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(
    title: "single line diagnostic with secondary label on same line, secondary label is ignored",
  )
}

pub fn multi_line_diagnostic_test() {
  diagnostic.Diagnostic(
    level: diagnostic.Erro,
    title: "example",
    label: option.Some(diagnostic.Label(
      file_name: "wibble.src",
      source: "line here\nhere",
      position: glance.Span(5, 14),
      content: "message",
      secondary_label: option.None,
    )),
    text: "",
    hint: option.None,
  )
  |> diagnostic.to_string
  |> birdie.snap(title: "multi line diagnostic")
}


================================================
FILE: test/birdie_test.gleam
================================================
import birdie
import gleam/string
import gleeunit

pub fn main() {
  gleeunit.main()
}

pub fn hello_birdie_test() {
  "🐦‍⬛ smile for the birdie!"
  |> birdie.snap(title: "hello birdie test")
}

pub fn a_result_test() {
  string.inspect(Ok(11))
  |> birdie.snap(title: "a result test")
}

pub fn list_test() {
  "[ 1, 2, 3, 4 ]"
  |> birdie.snap(title: "list test")
}

pub fn complex_function_test() {
  "case wibble(wobble, woo) {
  True ->
    io.println(\"Phew, we don't have to launch the missiles...\")
  False -> {
    io.println(\"Not wibble!\")
    launch_missiles()
  }
}"
  |> birdie.snap(title: "complex function test")
}
Download .txt
gitextract_pg72u7m9/

├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── setup.yml
│       └── test.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── birdie.tape
├── gleam.toml
├── manifest.toml
├── src/
│   ├── birdie/
│   │   └── internal/
│   │       ├── analyser.gleam
│   │       ├── cli.gleam
│   │       ├── diagnostic.gleam
│   │       ├── diff.gleam
│   │       ├── project.gleam
│   │       └── version.gleam
│   ├── birdie.gleam
│   ├── birdie_ffi.erl
│   └── birdie_ffi.mjs
└── test/
    ├── birdie_snapshots/
    │   ├── a_result_test.accepted
    │   ├── complex_function_test.accepted
    │   ├── hello_birdie_test.accepted
    │   ├── list_test.accepted
    │   ├── multi_line_diagnostic.accepted
    │   ├── single_line_diagnostic_with_no_tooltip_text.accepted
    │   ├── single_line_diagnostic_with_secondary_label_above.accepted
    │   ├── single_line_diagnostic_with_secondary_label_below.accepted
    │   ├── single_line_diagnostic_with_secondary_label_on_same_line,_secondary_label_is_ignored.accepted
    │   ├── single_line_diagnostic_with_secondary_label_right_above.accepted
    │   ├── single_line_diagnostic_with_secondary_label_right_below.accepted
    │   └── single_line_diagnostic_with_tooltip_text.accepted
    ├── birdie_test/
    │   ├── cli_test.gleam
    │   └── diagnostic_test.gleam
    └── birdie_test.gleam
Download .txt
SYMBOL INDEX (3 symbols across 1 files)

FILE: src/birdie_ffi.mjs
  function is_windows (line 5) | function is_windows() {
  function halt (line 9) | function halt(status_code) {
  function get_line (line 13) | function get_line(prompt) {
Condensed preview — 34 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (168K chars).
[
  {
    "path": ".github/FUNDING.yml",
    "chars": 27,
    "preview": "github: [giacomocavalieri]\n"
  },
  {
    "path": ".github/workflows/setup.yml",
    "chars": 263,
    "preview": "name: shared\n\non:\n  workflow_call:\n\njobs:\n  setup:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 2309,
    "preview": "name: Test\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n\njobs:\n  format:\n    runs-on: ubuntu-latest\n    steps"
  },
  {
    "path": ".gitignore",
    "chars": 34,
    "preview": "*.beam\n*.ez\n/build\nerl_crash.dump\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 3756,
    "preview": "# Changelog\n\n## 2.0.0 - 2026-05-03\n\n- Birdie snapshots are now located under the `test/birdie_snapshots` directory.\n  Wh"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "                                 Apache License\n                           Version 2.0, January 2004\n                   "
  },
  {
    "path": "README.md",
    "chars": 4346,
    "preview": "# 🐦‍⬛ Birdie - snapshot testing in Gleam\n\n[![Package Version](https://img.shields.io/hexpm/v/birdie)](https://hex.pm/pac"
  },
  {
    "path": "birdie.tape",
    "chars": 249,
    "preview": "Output birdie.gif\n\nSet Shell \"bash\"\nSet FontSize 24\nSet Width 900\nSet Height 700\n\nType \"gleam run -m birdie\"\nSleep 500ms"
  },
  {
    "path": "gleam.toml",
    "chars": 889,
    "preview": "name = \"birdie\"\nversion = \"2.0.0\"\n\ndescription = \"🐦‍⬛ Snapshot testing in Gleam\"\nlicences = [\"Apache-2.0\"]\nrepository = "
  },
  {
    "path": "manifest.toml",
    "chars": 5756,
    "preview": "# This file was generated by Gleam\n# You typically do not need to edit this file\n\npackages = [\n  { name = \"argv\", versio"
  },
  {
    "path": "src/birdie/internal/analyser.gleam",
    "chars": 29325,
    "preview": "import birdie/internal/diagnostic.{type Diagnostic}\nimport glance.{type Span}\nimport gleam/bool\nimport gleam/dict.{type "
  },
  {
    "path": "src/birdie/internal/cli.gleam",
    "chars": 9467,
    "preview": "import edit_distance\nimport gleam/int\nimport gleam/list\nimport gleam/option.{type Option, None, Some}\nimport gleam/resul"
  },
  {
    "path": "src/birdie/internal/diagnostic.gleam",
    "chars": 8044,
    "preview": "import glance.{type Span}\nimport gleam/bit_array\nimport gleam/int\nimport gleam/option.{type Option}\nimport gleam/result\n"
  },
  {
    "path": "src/birdie/internal/diff.gleam",
    "chars": 7788,
    "preview": "import gleam/dict.{type Dict}\nimport gleam/list\nimport gleam/option.{type Option, None, Some}\nimport gleam/string\n\npub t"
  },
  {
    "path": "src/birdie/internal/project.gleam",
    "chars": 1236,
    "preview": "import filepath\nimport gleam/result\nimport simplifile.{type FileError}\nimport tom\n\n/// Returns the path to the project's"
  },
  {
    "path": "src/birdie/internal/version.gleam",
    "chars": 864,
    "preview": "import gleam/int\nimport gleam/order\nimport gleam/result\nimport gleam/string\n\npub type Version {\n  Version(major: Int, mi"
  },
  {
    "path": "src/birdie.gleam",
    "chars": 55527,
    "preview": "import argv\nimport birdie/internal/analyser.{type Analyser}\nimport birdie/internal/cli.{\n  type Command, Accept, CheckSt"
  },
  {
    "path": "src/birdie_ffi.erl",
    "chars": 405,
    "preview": "-module(birdie_ffi).\n-export([get_line/1, is_windows/0]).\n\nget_line(Prompt) ->\n    case io:get_line(Prompt) of\n        e"
  },
  {
    "path": "src/birdie_ffi.mjs",
    "chars": 736,
    "preview": "import fs from \"node:fs\";\nimport { exit } from \"node:process\";\nimport { Result$Ok, Result$Error } from \"./gleam.mjs\";\n\ne"
  },
  {
    "path": "test/birdie_snapshots/a_result_test.accepted",
    "chars": 107,
    "preview": "---\nversion: 1.6.0\ntitle: a result test\nfile: ./test/birdie_test.gleam\ntest_name: a_result_test\n---\nOk(11)\n"
  },
  {
    "path": "test/birdie_snapshots/complex_function_test.accepted",
    "chars": 288,
    "preview": "---\nversion: 1.6.0\ntitle: complex function test\nfile: ./test/birdie_test.gleam\ntest_name: complex_function_test\n---\ncase"
  },
  {
    "path": "test/birdie_snapshots/hello_birdie_test.accepted",
    "chars": 134,
    "preview": "---\nversion: 1.6.0\ntitle: hello birdie test\nfile: ./test/birdie_test.gleam\ntest_name: hello_birdie_test\n---\n🐦‍⬛ smile fo"
  },
  {
    "path": "test/birdie_snapshots/list_test.accepted",
    "chars": 107,
    "preview": "---\nversion: 1.6.0\ntitle: list test\nfile: ./test/birdie_test.gleam\ntest_name: list_test\n---\n[ 1, 2, 3, 4 ]\n"
  },
  {
    "path": "test/birdie_snapshots/multi_line_diagnostic.accepted",
    "chars": 414,
    "preview": "---\nversion: 1.6.0\ntitle: multi line diagnostic\nfile: ./test/birdie_test/diagnostic_test.gleam\ntest_name: multi_line_dia"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_no_tooltip_text.accepted",
    "chars": 452,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with no tooltip text\nfile: ./test/birdie_test/diagnostic_test.gleam\ntes"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_secondary_label_above.accepted",
    "chars": 478,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with secondary label above\nfile: ./test/birdie_test/diagnostic_test.gle"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_secondary_label_below.accepted",
    "chars": 515,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with secondary label below\nfile: ./test/birdie_test/diagnostic_test.gle"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_secondary_label_on_same_line,_secondary_label_is_ignored.accepted",
    "chars": 478,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with secondary label on same line, secondary label is ignored\nfile: ./t"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_secondary_label_right_above.accepted",
    "chars": 495,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with secondary label right above\nfile: ./test/birdie_test/diagnostic_te"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_secondary_label_right_below.accepted",
    "chars": 516,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with secondary label right below\nfile: ./test/birdie_test/diagnostic_te"
  },
  {
    "path": "test/birdie_snapshots/single_line_diagnostic_with_tooltip_text.accepted",
    "chars": 465,
    "preview": "---\nversion: 1.6.0\ntitle: single line diagnostic with tooltip text\nfile: ./test/birdie_test/diagnostic_test.gleam\ntest_n"
  },
  {
    "path": "test/birdie_test/cli_test.gleam",
    "chars": 5361,
    "preview": "import birdie/internal/cli.{\n  type Command, Accept, CheckStale, DeleteStale, FullCommand, Help, Reject,\n  Review, Stale"
  },
  {
    "path": "test/birdie_test/diagnostic_test.gleam",
    "chars": 4322,
    "preview": "import birdie\nimport birdie/internal/diagnostic\nimport glance\nimport gleam/option\n\npub fn single_line_diagnostic_with_no"
  },
  {
    "path": "test/birdie_test.gleam",
    "chars": 633,
    "preview": "import birdie\nimport gleam/string\nimport gleeunit\n\npub fn main() {\n  gleeunit.main()\n}\n\npub fn hello_birdie_test() {\n  \""
  }
]

About this extraction

This page contains the full source code of the giacomocavalieri/birdie GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 34 files (153.5 KB), approximately 41.5k tokens, and a symbol index with 3 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!