├── .gitignore ├── LICENSE ├── README.md ├── build.zig ├── build.zig.zon └── src └── ohsnap.zig /.gitignore: -------------------------------------------------------------------------------- 1 | /zig-* 2 | /.zig-* 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sam Atman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oh Snap! Easy Snapshot Testing for Zig 2 | 3 | It's hard to know if a program, or part of one, actually works. But it's easy to know if it doesn't: if there isn't a test for some part of the program, then that part doesn't work. 4 | 5 | [Snapshot testing](https://tigerbeetle.com/blog/2024-05-14-snapshot-testing-for-the-masses) is a great way to get fast coverage for _data invariants_ in a program or library. The article I just linked to goes into great detail about the advantages of snapshot testing, and you should read it. 6 | 7 | `ohsnap` is a Zig library for doing snapshot testing, one which is, in fact, based on the [TigerBeetle library](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/testing/snaptest.zig) used in that post. 8 | 9 | It includes some features not found in the original. TigerBeetle has a no-dependencies policy, and I'm confident that what they have serves their needs just fine. But a library like this is a dependency by definition, and I didn't mind adding a couple more. 10 | 11 | Let me show you its features! 12 | 13 | ## Installation 14 | 15 | The best way to use `ohsnap` is to install it using the [Zig Build System](https://ziglang.org/learn/build-system/). From your project repo root, use `zig fetch` like this: 16 | 17 | ```sh 18 | zig fetch --save "https://github.com/mnemnion/ohsnap/archive/refs/tags/v0.4.0.tar.gz" 19 | ``` 20 | 21 | Then add it to your test artifact like so: 22 | 23 | ```zig 24 | if (b.lazyDependency("ohsnap", .{ 25 | .target = target, 26 | .optimize = optimize, 27 | })) |ohsnap_dep| { 28 | lib_unit_tests.root_module.addImport("ohsnap", ohsnap_dep.module("ohsnap")); 29 | } 30 | ``` 31 | 32 | That should be it! Now you're ready to write some snaps! 33 | 34 | ## Using `ohsnap` 35 | 36 | The interface will be familiar if you read the linked blog post, which, really, you should. 37 | 38 | One difference between `ohsnap` and the original, is that `ohsnap` includes [pretty](https://github.com/timfayz/pretty), a clever pretty-printer for arbitrary data structures. So you don't need to write a custom `.format` method to use `ohsnap`, although if you have one, you can use that instead. Or both. Belt and suspenders kinda thing. 39 | 40 | Writing a snap is simple, to get started, do something like this: 41 | 42 | ```zig 43 | const OhSnap = @import("ohsnap"); 44 | 45 | test "snap something" { 46 | const oh = OhSnap{}; 47 | // You can configure `pretty` by using `var oh` and changing settings 48 | // in `oh.pretty_options`. 49 | const snap_me = someFn(); 50 | try oh.snap(@src(), 51 | \\ 52 | , 53 | ).expectEqual(snap_me); 54 | } 55 | ``` 56 | 57 | Note that the call to `@src()` has to be directly above the string, and the string has to be multi-line style, with the double backslashes: `\\`. Both this: 58 | 59 | ```zig 60 | try oh.snap(@src(), 61 | \\ etc 62 | ,).expectEqual(snap_me); 63 | ``` 64 | 65 | And this: 66 | 67 | ```zig 68 | try oh.snap( 69 | @src(), 70 | \\ etc 71 | ,).expectEqual(snap_me); 72 | ``` 73 | 74 | Will work just fine. 75 | 76 | This test will fail, because the snapshot generated by `pretty` won't be equal to the empty string. `ohsnap` will diff that empty string with what it gets out of `snap_me`, and print what it got in all-green, because that's what happens when you diff an empty string against a string which isn't empty. 77 | 78 | If you like what you see, updating is simple. Change the file to the following: 79 | 80 | ```zig 81 | const OhSnap = @import("ohsnap"); 82 | 83 | test "snap something" { 84 | const oh = OhSnap{}; 85 | // You can configure `pretty` by using `var oh` and changing settings 86 | // in `oh.pretty_options`. 87 | const snap_me = someFn(); 88 | try oh.snap(@src(), 89 | \\ 90 | , 91 | ).expectEqual(snap_me); 92 | } 93 | ``` 94 | 95 | The snaptest will see the ``, which must be the beginning of the string, and replace it in your file with the output of the pretty printing. Easy! 96 | 97 | If your data structure has a `.format` method, and you'd prefer to use that as a basis, simply use `.expectEqualFmt` instead of `.expectEqual`. 98 | 99 | If, down the road, the snapshot doesn't compare to the expected string, `ohsnap` will use [diffz](https://github.com/mnemnion/diffz/tree/more-port)[^1], a Zig port of [diff-match-patch](https://github.com/google/diff-match-patch), to produce a terminal-colored character-level diff of the expected string with the actual string, making it easy to see exactly what's changed. These changes are either a bug, or a new feature. If it's the former, fix it, if it's the latter, just add `` to the head of the string again, and `ohsnap` will oblige. 100 | 101 | ## Pattern-Matching Snapshots 102 | 103 | This is fine and dandy, if the data structure, exactly as it prints, will always be the same on every test run. But what if that's only true of some of the data? 104 | 105 | Consider this example. We have a struct which looks like this: 106 | 107 | ```zig 108 | const StampedStruct = struct { 109 | message: []const u8, 110 | tag: u64, 111 | timestamp: isize, 112 | pub fn init(msg: []const u8, tag: u64) StampedStruct { 113 | return StampedStruct{ 114 | .message = msg, 115 | .tag = tag, 116 | .timestamp = std.time.timestamp(), 117 | }; 118 | } 119 | }; 120 | ``` 121 | 122 | Which we want to snapshot test, like this: 123 | 124 | ```zig 125 | test "snap with timestamp" { 126 | const oh = OhSnap{}; 127 | const with_stamp = StampedStruct.init( 128 | "frobnicate the turbo-encabulator", 129 | 31337, 130 | ); 131 | try oh.snap( 132 | @src(), 133 | \\ohsnap.StampedStruct 134 | \\ .message: []const u8 135 | \\ "frobnicate the turbo-encabulator" 136 | \\ .tag: u64 = 31337 137 | \\ .timestamp: isize = 1721501316 138 | , 139 | ).expectEqual(with_stamp); 140 | } 141 | ``` 142 | 143 | But of course, the next time we run the test, the timestamp will be different, so the test will fail. We care about the message and the tag, we care that there _is_ a timestamp, but we don't care what the timestamp is, because we know it will be changing. 144 | 145 | For cases like this, `ohsnap` includes [mvzr](https://github.com/mnemnion/mvzr), the Minimum Viable Zig Regex library, which I wrote specifically for this purpose. 146 | 147 | Simply replace the timestamp like so: 148 | 149 | ```zig 150 | try oh.snap( 151 | @src(), 152 | \\ohsnap.StampedStruct 153 | \\ .message: []const u8 154 | \\ "frobnicate the turbo-encabulator" 155 | \\ .tag: u64 = 31337 156 | \\ .timestamp: isize = <^\d+$> 157 | , 158 | ).expectEqual(with_stamp); 159 | ``` 160 | 161 | Through the magic of diffing, `ohsnap` will identify the part of the new string which matches `<^\d+$>`, and try to match the regular expression against that part of the string. Since this matches, the test now passes. 162 | 163 | Note that the regex must be in the form `<^.+?$>` (the exact regex we use is `<\^[^\n]+?\$>`, in fact), the `^` and `$` are essential and are load-bearing parts of the expression. This prevents partial matches, as well as making the regex portions of a snapshot test easier for `ohsnap` to find. Note that because this is a multi-line string, you don't have to do double-backslashes: its `<^\d+$>`, not `<^\\d+$>`. To be very clear, the `<` and `>` demarcate the regex, they aren't part of it. 164 | 165 | Let's say you make a change: 166 | 167 | ```zig 168 | const with_stamp = StampedStruct.init( 169 | "thoroughly frobnicate the encabulator", 170 | 31337, 171 | ); 172 | ``` 173 | 174 | The test will now fail: the word "thoroughly" will be highlighted in green, `turbo-` will be marked in red, and the timestamp will be cyan, indicating that the regex is still matching the pattern string. If a change in the test data means that the regex no longer matches, then the part of the test string which should match is highlighted in magenta. 175 | 176 | Since this was an intentional change, we need to update the snap: 177 | 178 | ```zig 179 | try oh.snap( 180 | @src(), 181 | \\ 182 | \\ohsnap.StampedStruct 183 | \\ .message: []const u8 184 | \\ "frobnicate the turbo-encabulator" 185 | \\ .tag: u64 = 31337 186 | \\ .timestamp: isize = <^\d+$> 187 | , 188 | ).expectEqual(with_stamp); 189 | ``` 190 | 191 | Once again, through the magic of diffing, `ohsnap` will locate the regexen in the old string, and patch them over the new one. 192 | 193 | ```zig 194 | try oh.snap( 195 | @src(), 196 | \\ohsnap.StampedStruct 197 | \\ .message: []const u8 198 | \\ "thoroughly frobnicate the encabulator" 199 | \\ .tag: u64 = 31337 200 | \\ .timestamp: isize = <^\d+$> 201 | , 202 | ).expectEqual(with_stamp); 203 | ``` 204 | 205 | Voila! 206 | 207 | Usage note: in some cases, the changes to the new string will displace the regex, you can tell because some part of the regex itself will be exposed in red. When that happens, the update may not apply correctly either: the regex will always be moved to the new string intact, but it may or may not be in the correct place (usually, not). This can generally be fixed by making changes to the expected-value string until whatever part of the regex was sticking out of the diff is no longer exposed. Sometimes running `` twice will fix it as well. 208 | 209 | ## Developing With Snapshots 210 | 211 | When we're programming, there are always points in the process where a data structure is in flux, and `ohsnap` can help you out with that as well. Instead of `.expectEqual(var)`, use `.show(var)`, or `.showFmt(var)`. This will print the snapshot, whether it diffs or not, and it doesn't count as a test. `` continues to work in the same way, but an updated `.show` snapshot counts as a failed test. The update logic is fairly simple, and updating often changes the line numbering of the file, so this helps update one at a time. Note that you can add the `` string to any number of snapshots, and just keep recompiling the test suite until they all pass. Also, if `ohsnap` can't find the snapshot because it moved, nothing untoward will happen, it will just report a failed test, and running it again will fix the problem if it was caused by a previous update. 212 | 213 | This also works as a minimalist way to regress a snapshot test, when you aren't sure what the final value will be. 214 | 215 | Whenever you're satisfied with the output, just change `.show` to its `.expect` cousin, and now you've got a test. 216 | 217 | ## Unusual Directory Structures 218 | 219 | Zig 0.14 changed some values in `SourceLocation`, which is returned by `@src()`. In particular, the filenames are now relative to the module root file's directory, not the repository root directory. 220 | 221 | This has required some changes to `ohsnap`. In the majority of cases, a module is rooted in the `/src` directory, and in those cases, nothing further needs to be done. The library will make that assumption and updates will proceed on that basis. 222 | 223 | However, in the event that a test module is rooted in some other directory, or even that there are several test modules rooted in several distinct directories, `ohsnap` must be configured to find those directories given those roots. 224 | 225 | The most satisfactory solution I was able to work out is two options, `module_name` and `root_directory`. Both have the type `[]const []const u8`. 226 | 227 | Given a test called `"root"` in the directory `/test`, `ohsnap` can be passed those values like so: 228 | 229 | ```sh 230 | > zig build test -Dmodule_name=root -Droot_directory=test 231 | ``` 232 | In this case, updating a snapshot will look for files in `root` in the directory `./test`. If `module_name` doesn't contain the name of the module, this will still fall back to `./src`. 233 | 234 | This can be repeated for as many test modules and associated directories as is needed. You can find what the build system names a module by adding a test like this to that module: 235 | 236 | ```zig 237 | test "print module name" { 238 | std.debug.print("module name is {s}\n", .{@src.module}); 239 | } 240 | ``` 241 | 242 | This will only change if certain changes to the build script take place, so there's no need to leave the test running once the name is obtained. 243 | 244 | These values can be provided from the build script as well, like so: 245 | 246 | ```zig 247 | if b.lazyDependency("ohsnap", .{ 248 | .target = target, 249 | .optimize = optimize, // etc 250 | module_name = .{ "root", "root1"}, 251 | root_directory = .{ "test", "src/subdir"}, 252 | }) |ohsnap_mod| { 253 | // Module imported in the usual fashion 254 | lib_unit_tests.root_module.addImport("ohsnap", ohsnap_dep.module("ohsnap")); 255 | lib_submodule_unit_tests.root_module.addImport("ohsnap", ohsnap_dep.module("ohsnap")); 256 | } 257 | ``` 258 | This solution is not perfectly satisfactory, but it retains the generality of the library with a minimum of fussing about. I hope subsequent work on Zig will restore the ability to reliably open a file, given the return value of `@src()`, in some fashion. 259 | 260 | ## That's It! 261 | 262 | One of the great advantages of snapshot testing is that it's easy, so `ohsnap`, like the library it's based upon, is intentionally quite simple. Simple, yet versatile, the latter to a large degree is owed to `pretty`, which can handle anything I've thrown at it, types, unions, you name it. 263 | 264 | It's a new library, but I expect the core interface to remain stable. It's meant to do one thing, well, and otherwise stay out of the way. I'm willing to consider suggestions for ways to make `ohsnap` better at what it already does, however. 265 | 266 | That said, the regex library `mvzr` is pretty new, and so is the added code in `diffz`, so version-bumps to fix any bugs in those can be expected over time. The build system doesn't currently do update checks, so you'll need to check for updates manually, for now. 267 | 268 | I hope you enjoy it! Test early, test often, and do it the easy way. 269 | 270 | [^1]: The link is to a fork of the library which has the necessary changes for terminal printing. That branch is in code review, and these things take time. `ohsnap` will be updated to fetch from the [main repo](https://github.com/ziglibs/diffz) when that's possible. 271 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) void { 4 | // Standard target options allows the person running `zig build` to choose 5 | // what target to build for. 6 | const target = b.standardTargetOptions(.{}); 7 | 8 | // Standard optimization options allow the person running `zig build` to select 9 | // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. 10 | const optimize = b.standardOptimizeOption(.{}); 11 | 12 | const options = b.addOptions(); 13 | 14 | const mod_names: []const []const u8 = b.option( 15 | []const []const u8, 16 | "module_name", 17 | "Name of a module with a non-standard directory.", 18 | ) orelse &.{}; 19 | 20 | options.addOption([]const []const u8, "module_name", mod_names); 21 | 22 | const mod_dirs: []const []const u8 = b.option( 23 | []const []const u8, 24 | "root_directory", 25 | "Non-standard directory in which the corresponding `module_name` is found (relative to repo).", 26 | ) orelse &.{}; 27 | 28 | options.addOption([]const []const u8, "root_directory", mod_dirs); 29 | 30 | // Export as module to be available for @import("ohsnap") on user site 31 | const snap_module = b.addModule("ohsnap", .{ 32 | .root_source_file = b.path("src/ohsnap.zig"), 33 | .target = target, 34 | .optimize = optimize, 35 | }); 36 | 37 | snap_module.addOptions("config", options); 38 | 39 | // Creates a step for unit testing. This only builds the test executable 40 | // but does not run it. 41 | const lib_unit_tests = b.addTest(.{ 42 | .root_source_file = b.path("src/ohsnap.zig"), 43 | .target = target, 44 | .optimize = optimize, 45 | .filter = b.option([]const u8, "filter", "Filter strings for tests"), 46 | }); 47 | 48 | lib_unit_tests.root_module.addOptions("config", options); 49 | 50 | const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); 51 | 52 | if (b.lazyDependency("pretty", .{ 53 | .target = target, 54 | .optimize = optimize, 55 | })) |pretty_dep| { 56 | lib_unit_tests.root_module.addImport("pretty", pretty_dep.module("pretty")); 57 | snap_module.addImport("pretty", pretty_dep.module("pretty")); 58 | } 59 | 60 | if (b.lazyDependency("diffz", .{ 61 | .target = target, 62 | .optimize = optimize, 63 | })) |diffz_dep| { 64 | lib_unit_tests.root_module.addImport("diffz", diffz_dep.module("diffz")); 65 | snap_module.addImport("diffz", diffz_dep.module("diffz")); 66 | } 67 | 68 | if (b.lazyDependency("mvzr", .{ 69 | .target = target, 70 | .optimize = optimize, 71 | })) |mvzr_dep| { 72 | lib_unit_tests.root_module.addImport("mvzr", mvzr_dep.module("mvzr")); 73 | snap_module.addImport("mvzr", mvzr_dep.module("mvzr")); 74 | } 75 | 76 | // Similar to creating the run step earlier, this exposes a `test` step to 77 | // the `zig build --help` menu, providing a way for the user to request 78 | // running the unit tests. 79 | const test_step = b.step("test", "Run unit tests"); 80 | test_step.dependOn(&run_lib_unit_tests.step); 81 | } 82 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | // This is the default name used by packages depending on this one. For 3 | // example, when a user runs `zig fetch --save `, this field is used 4 | // as the key in the `dependencies` table. Although the user can choose a 5 | // different name, most users will stick with this provided value. 6 | // 7 | // It is redundant to include "zig" in this name because it is already 8 | // within the Zig package namespace. 9 | .name = .ohsnap, 10 | 11 | // This is a [Semantic Version](https://semver.org/). 12 | // In a future version of Zig it will be used for package deduplication. 13 | .version = "0.3.1", 14 | 15 | .fingerprint = 0xf602e222ca736c89, 16 | 17 | // This field is optional. 18 | // This is currently advisory only; Zig does not yet do anything 19 | // with this value. 20 | .minimum_zig_version = "0.14.0", 21 | 22 | // This field is optional. 23 | // Each dependency must either provide a `url` and `hash`, or a `path`. 24 | // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. 25 | // Once all dependencies are fetched, `zig build` no longer requires 26 | // internet connectivity. 27 | 28 | .dependencies = .{ 29 | .diffz = .{ 30 | .url = "https://github.com/mnemnion/diffz/archive/refs/tags/v0.0.4-rc1.tar.gz", 31 | .hash = "1220806da7bb203c300e373429c915bd6c2a80dde3371b3cfceba6fb091b6fe1b28d", 32 | }, 33 | .mvzr = .{ 34 | .url = "https://github.com/mnemnion/mvzr/archive/refs/tags/v0.2.0.tar.gz", 35 | .hash = "1220b59a0a73a39e9cd9d0423e6c4c6a3d9260540f599c9846d70242ce5740c49b6e", 36 | }, 37 | .pretty = .{ 38 | .url = "https://github.com/timfayz/pretty/archive/refs/tags/latest.tar.gz", 39 | .hash = "pretty-0.10.4-AAAAAM9GAQATvLkzFJ6-Y9r_NWCAC2Q4hOKxHSnJXQLD", 40 | }, 41 | }, 42 | .paths = .{ 43 | "build.zig", 44 | "build.zig.zon", 45 | "src", 46 | ".gitignore", 47 | "LICENSE", 48 | "README.md", 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /src/ohsnap.zig: -------------------------------------------------------------------------------- 1 | //! OhSnap! A Prettified Snapshot Testing Library. 2 | //! 3 | //! Based on a core of TigerBeetle's snaptest.zig[^1]. 4 | //! 5 | //! Integrates @timfayz's pretty-printing library, pretty[^2], in order 6 | //! to have general-purpose printing of data structures, and for diffs, 7 | //! diffz[^3]. Last, but not least, for the regex library: the Minimum 8 | //! Viable Zig Regex[^4]. 9 | //! 10 | //! 11 | //! [^1]: https://github.com/tigerbeetle/tigerbeetle/blob/main/src/testing/snaptest.zig. 12 | //! [^2]: https://github.com/timfayz/pretty 13 | //! [^3]: https://github.com/ziglibs/diffz 14 | //! [^4]: https://github.com/mnemnion/mvzr 15 | 16 | const std = @import("std"); 17 | const builtin = @import("builtin"); 18 | const config = @import("config"); 19 | const pretty = @import("pretty"); 20 | const diffz = @import("diffz"); 21 | const mvzr = @import("mvzr"); 22 | const testing = std.testing; 23 | 24 | const assert = std.debug.assert; 25 | const SourceLocation = std.builtin.SourceLocation; 26 | 27 | const DiffList = std.ArrayListUnmanaged(diffz.Diff); 28 | const Diff = diffz.Diff; 29 | 30 | // Generous limits for user regexen 31 | const UserRegex = mvzr.SizedRegex(128, 16); 32 | 33 | // Intended for use in test mode only. 34 | comptime { 35 | assert(builtin.is_test); 36 | } 37 | 38 | // Number of entries in config must be even (module root, directory) 39 | comptime { 40 | if (config.module_name.len != config.root_directory.len) { 41 | @compileError("Every module_name must have a corresponding root_directory."); 42 | } 43 | } 44 | 45 | const OhSnap = @This(); 46 | 47 | pretty_options: pretty.Options = pretty.Options{ 48 | .max_depth = 0, 49 | .struct_max_len = 0, 50 | .array_max_len = 0, 51 | .array_show_prim_type_info = true, 52 | .type_name_max_len = 0, 53 | .type_name_fold_parens = 0, 54 | .str_max_len = 0, 55 | .show_tree_lines = true, 56 | }, 57 | 58 | /// Creates a new Snap using `pretty` formatting. 59 | /// 60 | /// For the update logic to work, *must* be formatted as: 61 | /// 62 | /// ``` 63 | /// try oh.snap(@src(), // This can be on the next line 64 | /// \\Text of the snapshot. 65 | /// ).expectEqual(val); 66 | /// ``` 67 | /// 68 | /// With the `@src()` on the line before the text, which must be 69 | /// in multi-line format. 70 | pub fn snap(ohsnap: OhSnap, location: SourceLocation, text: []const u8) Snap { 71 | return Snap{ 72 | .location = location, 73 | .text = text, 74 | .ohsnap = ohsnap, 75 | }; 76 | } 77 | 78 | // Regex for detecting embedded regexen 79 | const ignore_regex_string = "<\\^[^\n]+?\\$>"; 80 | const regex_finder = mvzr.compile(ignore_regex_string).?; 81 | 82 | pub const Snap = struct { 83 | location: SourceLocation, 84 | text: []const u8, 85 | ohsnap: OhSnap, 86 | 87 | const allocator = std.testing.allocator; 88 | 89 | /// Compare the snapshot with a pretty-printed string. 90 | pub fn expectEqual(snapshot: *const Snap, args: anytype) !void { 91 | const got = try pretty.dump( 92 | allocator, 93 | args, 94 | snapshot.ohsnap.pretty_options, 95 | ); 96 | defer allocator.free(got); 97 | try snapshot.diff(got, true); 98 | } 99 | 100 | /// Compare the snapshot with a .fmt printed string. 101 | pub fn expectEqualFmt(snapshot: *const Snap, args: anytype) !void { 102 | const got = try std.fmt.allocPrint(allocator, "{any}", .{args}); 103 | defer allocator.free(got); 104 | try snapshot.diff(got, true); 105 | } 106 | 107 | /// Show the snapshot diff without testing 108 | pub fn show(snapshot: *const Snap, args: anytype) !void { 109 | const got = try pretty.dump( 110 | allocator, 111 | args, 112 | snapshot.ohsnap.pretty_options, 113 | ); 114 | defer allocator.free(got); 115 | try snapshot.diff(got, false); 116 | } 117 | 118 | /// Show a diff with the .fmt string without testing. 119 | pub fn showFmt(snapshot: *const Snap, args: anytype) !void { 120 | const got = try std.fmt.allocPrint(allocator, "{any}", .{args}); 121 | defer allocator.free(got); 122 | try snapshot.diff(got, false); 123 | } 124 | 125 | /// Compare the snapshot with a given string. 126 | pub fn diff(snapshot: *const Snap, got: []const u8, test_it: bool) !void { 127 | // Check for an update first 128 | const update_idx = std.mem.indexOf(u8, snapshot.text, ""); 129 | if (update_idx) |idx| { 130 | if (idx == 0) { 131 | const match = regex_finder.match(snapshot.text); 132 | if (match) |_| { 133 | return try patchAndUpdate(snapshot, got); 134 | } else { 135 | return try updateSnap(snapshot, got); 136 | } 137 | } else { 138 | // Probably a user mistake but the diff logic will surface that 139 | } 140 | } 141 | 142 | const dmp = diffz{ .diff_timeout = 0 }; 143 | var diffs = try dmp.diff(allocator, snapshot.text, got, false); 144 | defer diffz.deinitDiffList(allocator, &diffs); 145 | if (diffDiffers(diffs) or !test_it) { 146 | try diffz.diffCleanupSemantic(allocator, &diffs); 147 | // Check if we have a regex in the snapshot 148 | const match = regex_finder.match(snapshot.text); 149 | if (match) |_| { 150 | diffs = try regexFixup(&diffs, snapshot, got); 151 | if (test_it) 152 | if (!diffDiffers(diffs)) return; 153 | } 154 | const diff_string = try diffz.diffPrettyFormatXTerm(allocator, diffs); 155 | defer allocator.free(diff_string); 156 | const differs = if (test_it) " differs" else ""; 157 | std.debug.print( 158 | \\Snapshot on line {s}{d}{s}{s}: 159 | \\ 160 | \\{s} 161 | \\ 162 | , 163 | .{ 164 | "\x1b[33m", 165 | snapshot.location.line + 1, 166 | "\x1b[m", 167 | differs, 168 | diff_string, 169 | }, 170 | ); 171 | if (test_it) { 172 | std.debug.print("\n\nTo replace contents, add as the first line of the snap text.\n", .{}); 173 | return try std.testing.expect(false); 174 | } else return; 175 | } 176 | } 177 | 178 | fn updateSnap(snapshot: *const Snap, got: []const u8) !void { 179 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 180 | defer arena.deinit(); 181 | 182 | const arena_allocator = arena.allocator(); 183 | 184 | var maybe_dir_str: ?[]const u8 = null; 185 | { 186 | var i: usize = 0; 187 | while (i < config.module_name.len) : (i += 1) { 188 | if (std.mem.eql(u8, config.module_name[i], snapshot.location.module)) { 189 | maybe_dir_str = config.root_directory[i]; 190 | break; 191 | } 192 | } 193 | } 194 | 195 | const dir_str = maybe_dir_str orelse "src"; 196 | 197 | var mod_dir = try std.fs.cwd().openDir(dir_str, .{}); 198 | defer mod_dir.close(); 199 | 200 | const file_text = 201 | try mod_dir.readFileAlloc(arena_allocator, snapshot.location.file, 1024 * 1024); 202 | var file_text_updated = try std.ArrayList(u8).initCapacity(arena_allocator, file_text.len); 203 | 204 | const line_zero_based = snapshot.location.line - 1; 205 | const range = try snapRange(file_text, line_zero_based); 206 | 207 | const snapshot_prefix = file_text[0..range.start]; 208 | const snapshot_text = file_text[range.start..range.end]; 209 | const snapshot_suffix = file_text[range.end..]; 210 | 211 | const indent = getIndent(snapshot_text); 212 | 213 | try file_text_updated.appendSlice(snapshot_prefix); 214 | { 215 | var lines = std.mem.splitScalar(u8, got, '\n'); 216 | while (lines.next()) |line| { 217 | try file_text_updated.writer().print("{s}\\\\{s}\n", .{ indent, line }); 218 | } 219 | } 220 | try file_text_updated.appendSlice(snapshot_suffix); 221 | 222 | try mod_dir.writeFile(.{ 223 | .sub_path = snapshot.location.file, 224 | .data = file_text_updated.items, 225 | }); 226 | 227 | std.debug.print("Updated {s}\n", .{snapshot.location.file}); 228 | return error.SnapUpdated; 229 | } 230 | 231 | /// Find regex matches and modify the diff accordingly. 232 | fn regexFixup( 233 | diffs: *DiffList, 234 | snapshot: *const Snap, 235 | got: []const u8, 236 | ) !DiffList { 237 | defer diffz.deinitDiffList(allocator, diffs); 238 | var regex_find = regex_finder.iterator(snapshot.text); 239 | var diffs_idx: usize = 0; 240 | var snap_idx: usize = 0; 241 | var got_idx: usize = 0; 242 | var new_diffs = DiffList{}; 243 | errdefer diffz.deinitDiffList(allocator, &new_diffs); 244 | const dummy_diff = Diff.init(.equal, ""); 245 | regex_while: while (regex_find.next()) |found| { 246 | // Find this location in the got string. 247 | const snap_start = found.start; 248 | const snap_end = found.end; 249 | const got_start = diffz.diffIndex(diffs.*, snap_start); 250 | const got_end = diffz.diffIndex(diffs.*, snap_end); 251 | // Check if these are identical (use/mention distinction!) 252 | if (std.mem.eql(u8, found.slice, got[got_start..got_end])) { 253 | // That's fine then 254 | continue :regex_while; 255 | } 256 | // Trim the angle brackets off the regex. 257 | const exclude_regex = found.slice[1 .. found.slice.len - 1]; 258 | const maybe_matcher = UserRegex.compile(exclude_regex); 259 | if (maybe_matcher == null) { 260 | std.debug.print("Issue with mvzr or regex, hard to say. Regex string: {s}\n", .{exclude_regex}); 261 | continue :regex_while; 262 | } 263 | const matcher = maybe_matcher.?; 264 | const maybe_match = matcher.match(got[got_start..got_end]); 265 | // Either way, we zero out the patches, the difference being 266 | // how we represent the match or not-match in the diff list. 267 | while (diffs_idx < diffs.items.len) : (diffs_idx += 1) { 268 | const d = diffs.items[diffs_idx]; 269 | // All patches which are inside one or the other are set to nothing 270 | const in_snap = snap_start <= snap_idx and snap_start < snap_end; 271 | const in_got = got_start <= got_idx and got_idx < got_end; 272 | switch (d.operation) { 273 | .equal => { 274 | // Could easily be in common between the regex and the match. 275 | snap_idx += d.text.len; 276 | got_idx += d.text.len; 277 | if (in_snap and in_got) { 278 | try new_diffs.append(allocator, dummy_diff); 279 | } else { 280 | try new_diffs.append(allocator, try dupe(d)); 281 | } 282 | }, 283 | .insert => { 284 | // Are we in the match? 285 | got_idx += d.text.len; 286 | if (in_got) { 287 | // Yes, replace with dummy equal 288 | try new_diffs.append(allocator, dummy_diff); 289 | } else { 290 | try new_diffs.append(allocator, try dupe(d)); 291 | } 292 | }, 293 | .delete => { 294 | snap_idx += d.text.len; 295 | // Same deal, are we in the match? 296 | if (in_snap) { 297 | try new_diffs.append(allocator, dummy_diff); 298 | } else { 299 | try new_diffs.append(allocator, try dupe(d)); 300 | } 301 | }, 302 | } 303 | 304 | if (got_idx >= got_end and snap_idx >= snap_end) break; 305 | } 306 | // Should always mean we have at least two (but we care about 307 | // having one) diffs rubbed out. 308 | var formatted = try std.ArrayList(u8).initCapacity(allocator, 10); 309 | defer formatted.deinit(); 310 | assert(new_diffs.items[diffs_idx].operation == .equal and new_diffs.items[diffs_idx].text.len == 0); 311 | if (maybe_match) |_| { 312 | // Decorate with cyan for a match. 313 | try formatted.appendSlice("\x1b[36m"); 314 | try formatted.appendSlice(got[got_start..got_end]); 315 | try formatted.appendSlice("\x1b[m"); 316 | new_diffs.items[diffs_idx] = Diff{ 317 | .operation = .equal, 318 | .text = try formatted.toOwnedSlice(), 319 | }; 320 | } else { 321 | // Decorate magenta for no match, and make it an insert (hence, error) 322 | try formatted.appendSlice("\x1b[35m"); 323 | try formatted.appendSlice(got[got_start..got_end]); 324 | try formatted.appendSlice("\x1b[m"); 325 | new_diffs.items[diffs_idx] = Diff{ 326 | .operation = .insert, 327 | .text = try formatted.toOwnedSlice(), 328 | }; 329 | } 330 | diffs_idx += 1; 331 | } // end regex while 332 | while (diffs_idx < diffs.items.len) : (diffs_idx += 1) { 333 | const d = diffs.items[diffs_idx]; 334 | try new_diffs.append(allocator, try dupe(d)); 335 | } 336 | return new_diffs; 337 | } 338 | 339 | fn patchAndUpdate(snapshot: *const Snap, got: []const u8) !void { 340 | const dmp = diffz{ .diff_timeout = 0, .match_threshold = 0.05 }; 341 | var diffs = try dmp.diff(allocator, snapshot.text, got, false); 342 | defer diffz.deinitDiffList(allocator, &diffs); 343 | // Very similar to `regexFixup`, but here we clean up the diffed region, 344 | // then add a paired delete/insert, and use it to patch `got`. 345 | var regex_find = regex_finder.iterator(snapshot.text); 346 | var got_idx: usize = 0; 347 | var new_diffs = DiffList{}; 348 | defer diffz.deinitDiffList(allocator, &new_diffs); 349 | var new_got = try std.ArrayList(u8).initCapacity(allocator, @max(got.len, snapshot.text.len)); 350 | defer new_got.deinit(); 351 | while (regex_find.next()) |found| { 352 | // Find this location in the got string. 353 | const snap_start = found.start; 354 | const snap_end = found.end; 355 | const got_start = diffz.diffIndex(diffs, snap_start); 356 | const got_end = diffz.diffIndex(diffs, snap_end); 357 | try new_got.appendSlice(got[got_idx..got_start]); 358 | try new_got.appendSlice(found.slice); 359 | got_idx = got_end; 360 | } 361 | try new_got.appendSlice(got[got_idx..]); 362 | return try updateSnap(snapshot, new_got.items); 363 | } 364 | 365 | fn dupe(d: Diff) !Diff { 366 | return Diff.init(d.operation, try allocator.dupe(u8, d.text)); 367 | } 368 | }; 369 | 370 | /// Answer whether the diffs differ (pre-regex, if any) 371 | fn diffDiffers(diffs: DiffList) bool { 372 | var all_equal = true; 373 | for (diffs.items) |d| { 374 | switch (d.operation) { 375 | .equal => {}, 376 | .insert, .delete => { 377 | all_equal = false; 378 | break; 379 | }, 380 | } 381 | } 382 | return !all_equal; 383 | } 384 | 385 | const Range = struct { start: usize, end: usize }; 386 | 387 | /// Extracts the range of the snapshot. Assumes that the snapshot is formatted as 388 | /// 389 | /// ``` 390 | /// oh.snap(@src(), 391 | /// \\first line 392 | /// \\second line 393 | /// ).expectEqual(val); 394 | /// ``` 395 | /// 396 | /// or 397 | /// 398 | /// ``` 399 | /// oh.snap( 400 | /// @src(), 401 | /// \\first line 402 | /// \\second line 403 | /// ).expectEqual(val); 404 | /// ``` 405 | /// 406 | /// In the event that a file is modified, we fail the test with a (hopefully informative) 407 | /// error. 408 | fn snapRange(text: []const u8, src_line: u32) !Range { 409 | var offset: usize = 0; 410 | var line_number: u32 = 0; 411 | 412 | var lines = std.mem.splitScalar(u8, text, '\n'); 413 | const snap_start = while (lines.next()) |line| : (line_number += 1) { 414 | if (line_number == src_line) { 415 | if (std.mem.indexOf(u8, line, "@src()") == null) { 416 | std.debug.print( 417 | "Expected snapshot @src() on line {d}. Try running tests again.\n", 418 | .{line_number + 1}, 419 | ); 420 | try testing.expect(false); 421 | } 422 | } 423 | if (line_number == src_line + 1) { 424 | if (!isMultilineString(line)) { 425 | std.debug.print( 426 | "Expected multiline string `\\\\` on line {d}.\n", 427 | .{line_number + 1}, 428 | ); 429 | try testing.expect(false); 430 | } 431 | break offset; 432 | } 433 | offset += line.len + 1; // 1 for \n 434 | } else unreachable; 435 | 436 | lines = std.mem.splitScalar(u8, text[snap_start..], '\n'); 437 | const snap_end = while (lines.next()) |line| { 438 | if (!isMultilineString(line)) { 439 | break offset; 440 | } 441 | offset += line.len + 1; // 1 for \n 442 | } else unreachable; 443 | 444 | return Range{ .start = snap_start, .end = snap_end }; 445 | } 446 | 447 | fn isMultilineString(line: []const u8) bool { 448 | for (line, 0..) |c, i| { 449 | switch (c) { 450 | ' ' => {}, 451 | '\\' => return (i + 1 < line.len and line[i + 1] == '\\'), 452 | else => return false, 453 | } 454 | } 455 | return false; 456 | } 457 | 458 | fn getIndent(line: []const u8) []const u8 { 459 | for (line, 0..) |c, i| { 460 | if (c != ' ') return line[0..i]; 461 | } 462 | return line; 463 | } 464 | 465 | test "snap test" { 466 | // Change either the snapshot or the struct to make these tests fail 467 | const oh = OhSnap{}; 468 | // Simple anon struct 469 | try oh.snap(@src(), 470 | \\ohsnap.test.snap test__struct_<^\d+$> 471 | \\ .foo: *const [10:0]u8 472 | \\ "bazbuxquux" 473 | \\ .baz: comptime_int = 27 474 | ).expectEqual(.{ .foo = "bazbuxquux", .baz = 27 }); 475 | // Type 476 | try oh.snap( 477 | @src(), 478 | \\builtin.Type 479 | \\ .struct: builtin.Type.Struct 480 | \\ .layout: builtin.Type.ContainerLayout 481 | \\ .auto 482 | \\ .backing_integer: ?type 483 | \\ null 484 | \\ .fields: []const builtin.Type.StructField 485 | \\ [0]: builtin.Type.StructField 486 | \\ .name: [:0]const u8 487 | \\ "pretty_options" 488 | \\ .type: type 489 | \\ pretty.Options 490 | \\ .default_value_ptr: ?*const anyopaque 491 | \\ .is_comptime: bool = false 492 | \\ .alignment: comptime_int = 8 493 | \\ .decls: []const builtin.Type.Declaration 494 | \\ [0]: builtin.Type.Declaration 495 | \\ .name: [:0]const u8 496 | \\ "snap" 497 | \\ [1]: builtin.Type.Declaration 498 | \\ .name: [:0]const u8 499 | \\ "Snap" 500 | \\ .is_tuple: bool = false 501 | , 502 | ).expectEqual(@typeInfo(@This())); 503 | } 504 | test "snap regex" { 505 | const RandomField = struct { 506 | const RF = @This(); 507 | str: []const u8 = "arglebargle", 508 | pi: f64 = 3.14159, 509 | rand: u64, 510 | xtra: u16 = 1571, 511 | fn init(rand: u64) RF { 512 | return RF{ .rand = rand }; 513 | } 514 | }; 515 | var prng = std.Random.DefaultPrng.init(blk: { 516 | var seed: u64 = undefined; 517 | try std.posix.getrandom(std.mem.asBytes(&seed)); 518 | break :blk seed; 519 | }); 520 | const rand = prng.random(); 521 | const an_rf = RandomField.init(rand.int(u64)); 522 | const oh = OhSnap{}; 523 | try oh.snap( 524 | @src(), 525 | \\ohsnap.test.snap regex.RandomField 526 | \\ .str: []const u8 527 | \\ "argle<^\w+?$>gle" 528 | \\ .pi: f64 = 3.14159e0 529 | \\ .rand: u64 = <^[0-9]+$> 530 | \\ .xtra: u16 = 1571 531 | , 532 | ).expectEqual(an_rf); 533 | } 534 | 535 | const StampedStruct = struct { 536 | message: []const u8, 537 | tag: u64, 538 | timestamp: isize, 539 | pub fn init(msg: []const u8, tag: u64) StampedStruct { 540 | return StampedStruct{ 541 | .message = msg, 542 | .tag = tag, 543 | .timestamp = std.time.timestamp(), 544 | }; 545 | } 546 | }; 547 | 548 | test "snap with timestamp" { 549 | const oh = OhSnap{}; 550 | const with_stamp = StampedStruct.init( 551 | "frobnicate the turbo-encabulator", 552 | 37337, 553 | ); 554 | try oh.snap( 555 | @src(), 556 | \\ohsnap.StampedStruct 557 | \\ .message: []const u8 558 | \\ "frobnicate the turbo-<^\w+$>" 559 | \\ .tag: u64 = 37337 560 | \\ .timestamp: isize = <^\d+$> 561 | , 562 | ).expectEqual(with_stamp); 563 | } 564 | 565 | const CustomStruct = struct { 566 | foo: u64, 567 | bar: u66, 568 | pub fn format( 569 | self: CustomStruct, 570 | comptime fmt: []const u8, 571 | options: std.fmt.FormatOptions, 572 | writer: anytype, 573 | ) !void { 574 | try writer.print("foo! <<{d}>>, bar! <<{d}>>", .{ self.foo, self.bar }); 575 | _ = fmt; 576 | _ = options; 577 | } 578 | }; 579 | 580 | test "expectEqualFmt" { 581 | const oh = OhSnap{}; 582 | const foobar = CustomStruct{ .foo = 42, .bar = 23 }; 583 | try oh.snap( 584 | @src(), 585 | \\foo! <<42>>, bar! <<23>> 586 | , 587 | ).expectEqualFmt(foobar); 588 | } 589 | 590 | test "regex match" { 591 | const oh = OhSnap{}; 592 | try oh.snap( 593 | @src(), 594 | \\?mvzr.Match 595 | \\ .slice: []const u8 596 | \\ "<^ $\d\.\d{2}$>" 597 | \\ .start: usize = 0 598 | \\ .end: usize = 15 599 | , 600 | ).expectEqual(regex_finder.match( 601 | \\<^ $\d\.\d{2}$> 602 | )); 603 | } 604 | --------------------------------------------------------------------------------