diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1f7f2e0..2e4cee5 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -10,14 +10,14 @@ jobs: strategy: fail-fast: false matrix: - zig: [0.13.0, master] + zig: [0.16.0] os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} name: Zig ${{ matrix.zig }} on ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Setup Zig - uses: korandoru/setup-zig@v1 + uses: mlugg/setup-zig@v2 with: zig-version: ${{ matrix.zig }} - run: zig build test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 06cbd5a..3fc2e4e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,8 +3,6 @@ name: docs on: push: branches: ["main"] - pull_request: - branches: ["main"] workflow_dispatch: jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba3e353 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## Unreleased + +## [v0.4.0] + +This release contains performance improvements, breaking changes in public API, and upgrades the +library to Zig 0.16. + +## Changed +- Use `Sqids.init` to create a new Sqids encoder/decoder instance. + There is no need for an allocator anymore. + User is responsible for managing the blocklist. + There is no need to deinit instances. +- `Sqids.encode` and `Sqids.decode` now both take an allocator. Caller owns the memory. + +## Added +- `blocklist_from_words` is now a public function, intended for users to create minimal blocklists for their input alphabet. +- `Sqids.default` is a handy shortcut to a Sqids instance with default alphabet, default blocklist + and no minimum length. + +## Removed +- `Sqids.deinit` + +[0.4.0]: https://github.com/sqids/sqids-zig/compare/v0.3.0...v0.4.0 diff --git a/README.md b/README.md index d28c5ef..77d85b2 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,12 @@ const sqids = @import("sqids"); Simple encode & decode: ```zig -const s = try sqids.Sqids.init(allocator, .{}) -defer s.deinit(); +const s: sqids.Sqids = .default; -const id = try s.encode(&.{1, 2, 3}); +const id = try s.encode(allocator, &.{1, 2, 3}); defer allocator.free(id); // Caller owns the memory. -const numbers = try s.decode(id); +const numbers = try s.decode(allocator, id); defer allocator.free(numbers); // Caller owns the memory. ``` @@ -93,24 +92,26 @@ The `sqids.Options` struct is used at initialization to customize the encoder. Enforce a *minimum* length for IDs: ```zig -const s = try sqids.Sqids.init(allocator, .{.min_length = 10}); -const id = try s.encode(&.{1, 2, 3}); // "86Rf07xd4z" +const s = try sqids.Sqids.init(.{.min_length = 10}); +const id = try s.encode(allocator, &.{1, 2, 3}); // "86Rf07xd4z" ``` Randomize IDs by providing a custom alphabet: ```zig -const s = try sqids.Sqids.init(allocator, .{.alphabet = "FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE"}); -const id = try s.encode(&.{1, 2, 3}); // "B4aajs" +const s = try sqids.Sqids.init(.{.alphabet = "FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE"}); +const id = try s.encode(allocator, &.{1, 2, 3}); // "B4aajs" ``` Prevent specific words from appearing anywhere in the auto-generated IDs: ```zig -const s = try sqids.Sqids.init(allocator, .{.blocklist = &.{"86Rf07"}}); -const id = try s.encode(&.{1, 2, 3}); // "se8ojk" +const s = try sqids.Sqids.init(.{.blocklist = &.{"86Rf07"}}); +const id = try s.encode(allocator, &.{1, 2, 3}); // "se8ojk" ``` +Use `sqids.blocklist_from_words` to create the minimal blocklist consistent with the working alphabet. + ## 📝 License [MIT](LICENSE) diff --git a/benchmark/bench.zig b/benchmark/bench.zig index 57a1206..de90a64 100644 --- a/benchmark/bench.zig +++ b/benchmark/bench.zig @@ -7,22 +7,21 @@ const numbers = numbers_file.numbers; const Sqids = sqids.Sqids; -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - const allocator = gpa.allocator(); - - const opts = sqids.Options{ .blocklist = undefined }; +pub fn main(init: std.process.Init) !void { + const allocator = init.arena.allocator(); var ids = try allocator.alloc([]const u8, numbers.len); + defer allocator.free(ids); // Using the default Sqids, encode the numbers to a Sqids ID. - const s = try Sqids.init(allocator, opts); - defer s.deinit(); + const s = try Sqids.init(.{ .blocklist = &.{} }); for (numbers, 0..) |ns, i| { - const id = try s.encode(&ns); + const id = try s.encode(allocator, &ns); ids[i] = id; } - allocator.free(ids); + for (ids) |id| { + allocator.free(id); + } } diff --git a/benchmark/gen_bench_encoding_exes.py b/benchmark/gen_bench_encoding_exes.py index 0ebe562..8c0d5b0 100644 --- a/benchmark/gen_bench_encoding_exes.py +++ b/benchmark/gen_bench_encoding_exes.py @@ -47,6 +47,6 @@ def run(cmd): msg += "sudo poop \\\n" for cid in commit_ids: - msg += f"\tzig-out/bin/benchmark_random_uuids-{cid} \\\n" + msg += f"\tzig-out/bin/bench-encode-random-uuids-{cid} \\\n" print(msg) diff --git a/build.zig b/build.zig index 23d77ab..99408ca 100644 --- a/build.zig +++ b/build.zig @@ -38,12 +38,25 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&b.addRunArtifact(tests).step); test_step.dependOn(&b.addRunArtifact(root_tests).step); - const exe_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .optimize = optimize, - .target = target, + // const exe_module = b.createModule(.{ + // .root_source_file = b.path("src/main.zig"), + // .optimize = optimize, + // .target = target, + // }); + // exe_module.addImport("sqids", sqids_module); + const exe = b.addExecutable(.{ + .name = "main", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .optimize = optimize, + .target = target, + .imports = &.{.{ .name = "sqids", .module = sqids_module }}, + }), }); - exe_module.addImport("sqids", sqids_module); + const run_step = b.step("run", "Run the demo program"); + // const exe_artifact = b.addInstallArtifact(exe, .{}); + const exe_artifact = b.addRunArtifact(exe); + run_step.dependOn(&exe_artifact.step); const bench_module = b.createModule(.{ .root_source_file = b.path("benchmark/bench.zig"), .target = target, .optimize = optimize }); bench_module.addImport("sqids", sqids_module); diff --git a/build.zig.zon b/build.zig.zon index dfb7e9a..3c795b4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,8 @@ .{ .name = .sqids, .fingerprint = 0x2448d471e1087204, - .version = "0.3.0", - .minimum_zig_version = "0.14.0", + .version = "0.4.0", + .minimum_zig_version = "0.16.0", .dependencies = .{}, .paths = .{ "build.zig", diff --git a/src/blocklist.zig b/src/blocklist.zig index 557762a..0782883 100644 --- a/src/blocklist.zig +++ b/src/blocklist.zig @@ -1,4 +1,4 @@ -pub const default_blocklist = .{ +pub const default_blocklist = [_][]const u8{ "0rgasm", "1d10t", "1d1ot", diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..191e908 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const sqids = @import("sqids"); +const mem = std.mem; + +const Sqids = sqids.Sqids; + +pub fn main(init: std.process.Init) !void { + var arena = init.arena; + const allocator = arena.allocator(); + + const numbers = &.{ 1, 2, 3 }; + + // Using the default Sqids, encode the numbers to a Sqids ID. + const s: Sqids = .default; + const id = try s.encode(allocator, numbers); + defer allocator.free(id); + + // Print to stdout. + std.debug.print("{s}\n", .{id}); +} diff --git a/src/root.zig b/src/root.zig index 5562e01..e9ec4b0 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,8 +1,10 @@ //! Module sqids-zig implements encoding and decoding of sqids identifiers. See sqids.org. const std = @import("std"); const mem = std.mem; +const ascii = std.ascii; const testing = std.testing; const ArrayList = std.ArrayList; +const ArrayListUnmanaged = std.ArrayListUnmanaged; pub const Error = error{ TooShortAlphabet, @@ -11,36 +13,45 @@ pub const Error = error{ ReachedMaxAttempts, }; -const blocklist_module = @import("blocklist.zig"); -pub const default_blocklist = blocklist_module.default_blocklist; - /// The default alphabet for sqids. pub const default_alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +/// The default blocklist for sqids +pub const default_blocklist = &@import("blocklist.zig").default_blocklist; + /// Options controls the configuration of the sqid encoder. pub const Options = struct { alphabet: []const u8 = default_alphabet, - blocklist: []const []const u8 = &default_blocklist, + blocklist: []const []const u8 = default_blocklist, min_length: u8 = 0, }; /// Sqids encoder. -/// Must be initialized with init and free with deinit methods. +/// +/// Use Sqids.default, or create with init to get a valid instance. +/// +/// Smaller blocklist makes encoding more efficient, as the blocklist is traversed for each +/// generated ID. To get a smaller blocklist with only words valid in the used alphabet, use +/// blocklist_from_words. pub const Sqids = struct { - allocator: mem.Allocator, alphabet: []const u8, - arena: std.heap.ArenaAllocator, blocklist: []const []const u8, min_length: u8, - pub fn init(allocator: mem.Allocator, opts: Options) !Sqids { + pub const default = Sqids{ + .alphabet = default_alphabet, + .blocklist = default_blocklist, + .min_length = 0, + }; + + pub fn init(opts: Options) !Sqids { // Check alphabet. // TODO(lvignoli): it would be better to "parse not validate", for both the alphabet and the blocklist. if (opts.alphabet.len < 3) { return Error.TooShortAlphabet; } for (opts.alphabet) |c| { - if (!std.ascii.isASCII(c)) { + if (!std.ascii.isAscii(c)) { return Error.NonASCIICharacter; } if (mem.count(u8, opts.alphabet, &.{c}) > 1) { @@ -48,50 +59,59 @@ pub const Sqids = struct { } } - // Create blocklist from provided words. - // We use an arena to manage the memory of the blocklist - var arena = std.heap.ArenaAllocator.init(allocator); - const b = try blocklist_from_words(arena.allocator(), opts.alphabet, opts.blocklist); return Sqids{ - .allocator = allocator, .alphabet = opts.alphabet, - .arena = arena, - .blocklist = b, + .blocklist = opts.blocklist, .min_length = opts.min_length, }; } - pub fn deinit(self: Sqids) void { - self.arena.deinit(); - } - /// Encodes a list of numbers into a sqids ID. Caller owns the memory. - pub fn encode(self: Sqids, numbers: []const u64) ![]const u8 { + pub fn encode(self: Sqids, gpa: std.mem.Allocator, numbers: []const u64) ![]const u8 { if (numbers.len == 0) { return ""; } - const alphabet = try self.allocator.dupe(u8, self.alphabet); - defer self.allocator.free(alphabet); + // Allocate ID buffer and working alphabet. + const estimated_buffer_size = estimateEncodingBufferSize(self.alphabet, numbers, self.min_length); + const buf = try gpa.alloc(u8, estimated_buffer_size); + errdefer gpa.free(buf); + + var alphabet_buffer: [128]u8 = undefined; + @memcpy(alphabet_buffer[0..self.alphabet.len], self.alphabet); + const alphabet = alphabet_buffer[0..self.alphabet.len]; shuffle(alphabet); + const increment = 0; - return try encodeNumbers( - self.allocator, + + // We ignore the returned value, as we know we have allocated the correct length. + const n = try encodeNumbers( + buf, numbers, alphabet, increment, self.min_length, self.blocklist, ); + if (n != estimated_buffer_size) { + @branchHint(.cold); + @panic("This should not happenned"); + // I am not quite sure it is unreachable, Latchezar labelled his function an + // estimation..., so we panic here for now, but we should do better. + } + + return buf; } /// Decodes an ID into numbers using alphabet. Caller owns the memory. - pub fn decode(self: Sqids, id: []const u8) ![]const u64 { - return try decodeID(self.allocator, id, self.alphabet); + pub fn decode(self: Sqids, gpa: std.mem.Allocator, id: []const u8) ![]const u64 { + return try decodeID(gpa, id, self.alphabet); } }; -/// blocklist_from_words constructs a sanitized blocklist from a list of words. -fn blocklist_from_words( +/// blocklist_from_words allocates a sanitized blocklist from a list of words. +/// +/// Caller owns the memory. +pub fn blocklist_from_words( allocator: mem.Allocator, alphabet: []const u8, words: []const []const u8, @@ -104,7 +124,8 @@ fn blocklist_from_words( const lowercase_alphabet = try std.ascii.allocLowerString(allocator, alphabet); defer allocator.free(lowercase_alphabet); - var filtered_blocklist = ArrayList([]const u8).init(allocator); + var filtered_blocklist = try ArrayList([]const u8).initCapacity(allocator, words.len); + errdefer filtered_blocklist.deinit(allocator); for (words) |word| { if (word.len < 3) { @@ -115,10 +136,10 @@ fn blocklist_from_words( allocator.free(lowercased_word); continue; } - try filtered_blocklist.append(lowercased_word); + filtered_blocklist.appendAssumeCapacity(lowercased_word); } - return try filtered_blocklist.toOwnedSlice(); + return try filtered_blocklist.toOwnedSlice(allocator); } fn validInAlphabet(word: []const u8, alphabet: []const u8) bool { @@ -132,20 +153,21 @@ fn validInAlphabet(word: []const u8, alphabet: []const u8) bool { /// encodeNumbers performs the actual encoding processing. fn encodeNumbers( - allocator: mem.Allocator, + buf: []u8, numbers: []const u64, original_alphabet: []const u8, increment: u64, min_length: u64, blocklist: []const []const u8, -) ![]u8 { - var alphabet = try allocator.dupe(u8, original_alphabet); - defer allocator.free(alphabet); - - if (increment > alphabet.len) { +) !usize { + if (increment > original_alphabet.len) { return Error.ReachedMaxAttempts; } + var alphabet_buffer: [128]u8 = undefined; + @memcpy(alphabet_buffer[0..original_alphabet.len], original_alphabet); + var alphabet = alphabet_buffer[0..original_alphabet.len]; + // Get semi-random offset. var offset: u64 = numbers.len; for (numbers, 0..) |n, i| { @@ -161,40 +183,49 @@ fn encodeNumbers( mem.reverse(u8, alphabet); // Build the ID. - var ret = ArrayList(u8).init(allocator); - defer ret.deinit(); + var ret: ArrayList(u8) = .initBuffer(buf); - try ret.append(prefix); + ret.appendAssumeCapacity(prefix); for (numbers, 0..) |n, i| { - const x = try toID(allocator, n, alphabet[1..]); - defer allocator.free(x); - try ret.appendSlice(x); + // NOTE(lvignoli): In the reference implementation, the ID letters are inserted + // at index 0 in a helper buffer, which then extend the main squid ID buffer. + // Here, we append them to the squid ID buffer for efficiency, so we reverse the + // slice corresponding to the current number at the end. + const start = ret.items.len; + var result = n; + while (true) { + ret.appendAssumeCapacity(alphabet[1 + result % (alphabet.len - 1)]); + result = result / (alphabet.len - 1); + if (result == 0) break; + } + mem.reverse(u8, ret.items[start..]); if (i < numbers.len - 1) { - try ret.append(alphabet[0]); + ret.appendAssumeCapacity(alphabet[0]); shuffle(alphabet); } } // Handle min_length requirements. if (min_length > ret.items.len) { - try ret.append(alphabet[0]); + ret.appendAssumeCapacity(alphabet[0]); while (min_length > ret.items.len) { shuffle(alphabet); const n = @min(min_length - ret.items.len, alphabet.len); - try ret.appendSlice(alphabet[0..n]); + ret.appendSliceAssumeCapacity(alphabet[0..n]); } } - var ID = try ret.toOwnedSlice(); + const ID = ret.items; + var len = ID.len; // Handle blocklist. - const blocked = try isBlockedID(allocator, blocklist, ID); + const blocked = try isBlockedID(blocklist, ID); if (blocked) { - allocator.free(ID); // Freeing the old ID string. - ID = try encodeNumbers( - allocator, + @memset(buf, undefined); + len = try encodeNumbers( + buf, numbers, original_alphabet, increment + 1, @@ -202,35 +233,57 @@ fn encodeNumbers( blocklist, ); } - return ID; + return len; } -/// isBlockedID returns true if id collides with the blocklist. -fn isBlockedID( - allocator: mem.Allocator, - blocklist: []const []const u8, - id: []const u8, -) !bool { - const lower_id = try std.ascii.allocLowerString(allocator, id); - defer allocator.free(lower_id); +/// Estimate the size of the buffer necessary for encoding. +/// It is a an overestimation, so it is safe to assume capacity when constructing +/// the ID. +/// +/// Ported from github.com/sqids/sqids-c, by Latchezar Tzvetkoff. +fn estimateEncodingBufferSize( + alphabet: []const u8, + numbers: []const u64, + min_length: u64, +) usize { + var r: f64 = 0; // f64 as working type up to final usize cast + + const log2len = @log2(@as(f64, @floatFromInt(alphabet.len)) - 1); + + for (numbers) |n| { + const x = @as(f64, @floatFromInt(n)); + switch (n) { + 0 => r += 2, + std.math.maxInt(u64) => r += @ceil(@log2(x) / log2len) + 1, + else => r += @ceil(@log2(x + 1) / log2len) + 1, + } + } + var res = @as(usize, @intFromFloat(r)); + res = @max(res, min_length); + + return res; +} + +/// isBlockedID returns true if id collides with the blocklist. +/// Collisions ignore case. +fn isBlockedID(blocklist: []const []const u8, id: []const u8) !bool { for (blocklist) |word| { - if (word.len > lower_id.len) { + if (word.len > id.len) { continue; } - if (lower_id.len <= 3 or word.len <= 3) { + if (id.len <= 3 or word.len <= 3) { if (mem.eql(u8, id, word)) { return true; } } else if (containsNumber(word)) { - if (mem.startsWith(u8, lower_id, word) or mem.endsWith(u8, lower_id, word)) { + if (ascii.startsWithIgnoreCase(id, word) or ascii.endsWithIgnoreCase(id, word)) { return true; } - } else if (mem.indexOf(u8, lower_id, word)) |_| { + } else if (ascii.indexOfIgnoreCase(id, word)) |_| { return true; } } - return false; } @@ -245,7 +298,7 @@ fn containsNumber(s: []const u8) bool { /// decodeID decodes an ID into numbers using alphabet. Caller owns the memory. fn decodeID( - allocator: mem.Allocator, + gpa: mem.Allocator, to_decode_id: []const u8, decoding_alphabet: []const u8, ) ![]const u64 { @@ -254,8 +307,11 @@ fn decodeID( return &.{}; } - const alphabet = try allocator.dupe(u8, decoding_alphabet); - defer allocator.free(alphabet); + // Everything is ASCII, so the alphabet is 128 character at max. + var buffer: [128]u8 = undefined; + @memcpy(buffer[0..decoding_alphabet.len], decoding_alphabet); + var alphabet = buffer[0..decoding_alphabet.len]; + shuffle(alphabet); // If a character is not in the alphabet, return an empty array. @@ -273,8 +329,8 @@ fn decodeID( mem.rotate(u8, alphabet, offset); mem.reverse(u8, alphabet); - var ret = ArrayList(u64).init(allocator); - defer ret.deinit(); + var ret: ArrayList(u64) = .empty; + defer ret.deinit(gpa); while (id.len > 0) { const separator = alphabet[0]; @@ -287,10 +343,10 @@ fn decodeID( // If empty, we are done (the rest is junk characters). if (left.len == 0) { - return try ret.toOwnedSlice(); + return try ret.toOwnedSlice(gpa); } - try ret.append(toNumber(left, alphabet[1..])); + try ret.append(gpa, toNumber(left, alphabet[1..])); // If there is still numbers to decode from the ID, shuffle the alphabet. if (right.len > 0) { @@ -301,30 +357,7 @@ fn decodeID( id = right; } - return try ret.toOwnedSlice(); -} - -/// toID generates a new ID string for number using alphabet. -fn toID( - allocator: mem.Allocator, - number: u64, - alphabet: []const u8, -) ![]const u8 { - // NOTE(lvignoli): In the reference implementation, the letters are inserted at index 0. - // Here we append them for efficiency, so we reverse the ID at the end. - var result: u64 = number; - var id = std.ArrayList(u8).init(allocator); - - while (true) { - try id.append(alphabet[result % alphabet.len]); - result = result / alphabet.len; - if (result == 0) break; - } - - const value: []u8 = try id.toOwnedSlice(); - mem.reverse(u8, value); - - return value; + return try ret.toOwnedSlice(gpa); } /// toNumber converts a string to an integer using the given alphabet. @@ -377,9 +410,9 @@ test "encode" { }; for (cases) |case| { - const sqids = try Sqids.init(allocator, .{ .alphabet = case.alphabet }); - defer sqids.deinit(); - const id = try sqids.encode(case.numbers); + const sqids = try Sqids.init(.{ .alphabet = case.alphabet }); + + const id = try sqids.encode(allocator, case.numbers); defer allocator.free(id); try testing.expectEqualStrings(case.expected, id); } @@ -389,24 +422,22 @@ test "non-empty blocklist" { const allocator = testing.allocator; const blocklist: []const []const u8 = &.{"ArUO"}; - const sqids = try Sqids.init(allocator, .{ .blocklist = blocklist }); - defer sqids.deinit(); + const sqids = try Sqids.init(.{ .blocklist = blocklist }); - const actual_numbers = try sqids.decode("ArUO"); + const actual_numbers = try sqids.decode(allocator, "ArUO"); defer allocator.free(actual_numbers); try testing.expectEqualSlices(u64, &.{100000}, actual_numbers); - const got_id = try sqids.encode(&.{100000}); + const got_id = try sqids.encode(allocator, &.{100000}); defer allocator.free(got_id); try testing.expectEqualStrings("QyG4", got_id); } test "decode" { const allocator = testing.allocator; - const sqids = try Sqids.init(allocator, .{ .alphabet = "0123456789abcdef" }); - defer sqids.deinit(); + const sqids = try Sqids.init(.{ .alphabet = "0123456789abcdef" }); - const numbers = try sqids.decode("489158"); + const numbers = try sqids.decode(allocator, "489158"); defer allocator.free(numbers); try testing.expectEqualSlices(u64, &.{ 1, 2, 3 }, numbers); } diff --git a/src/tests/alphabet.zig b/src/tests/alphabet.zig index a98c642..1f597f5 100644 --- a/src/tests/alphabet.zig +++ b/src/tests/alphabet.zig @@ -5,18 +5,16 @@ const testing = std.testing; const utils = @import("utils.zig"); const sqids = @import("sqids"); -const Squids = sqids.Sqids; +const Sqids = sqids.Sqids; const testing_allocator = testing.allocator; test "simple" { - const s = try Squids.init(testing_allocator, .{ .alphabet = "0123456789abcdef" }); - defer s.deinit(); + const s = try Sqids.init(.{ .alphabet = "0123456789abcdef" }); try utils.expectEncodeDecodeWithID(testing_allocator, s, &.{ 1, 2, 3 }, "489158"); } test "short" { - const s = try Squids.init(testing_allocator, .{ .alphabet = "abc" }); - defer s.deinit(); + const s = try Sqids.init(.{ .alphabet = "abc" }); try utils.expectEncodeDecode(testing_allocator, s, &.{ 1, 2, 3 }); } @@ -24,22 +22,21 @@ test "long" { const alphabet = \\abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_+|{}[];:\'"/?.>,<`~ ; - const s = try Squids.init(testing_allocator, .{ .alphabet = alphabet }); - defer s.deinit(); + const s = try Sqids.init(.{ .alphabet = alphabet }); try utils.expectEncodeDecode(testing_allocator, s, &.{ 1, 2, 3 }); } test "multibyte alphabet" { - const err = Squids.init(testing_allocator, .{ .alphabet = "ë1092" }) catch |err| err; + const err = Sqids.init(.{ .alphabet = "ë1092" }) catch |err| err; try testing.expectError(sqids.Error.NonASCIICharacter, err); } test "repeating alphabet characters" { - const err = Squids.init(testing_allocator, .{ .alphabet = "aabcdefg" }) catch |err| err; + const err = Sqids.init(.{ .alphabet = "aabcdefg" }) catch |err| err; try testing.expectError(sqids.Error.RepeatingAlphabetCharacter, err); } test "too short of an alphabet" { - const err = Squids.init(testing_allocator, .{ .alphabet = "ab" }) catch |err| err; + const err = Sqids.init(.{ .alphabet = "ab" }) catch |err| err; try testing.expectError(sqids.Error.TooShortAlphabet, err); } diff --git a/src/tests/blocklist.zig b/src/tests/blocklist.zig index e6828bb..c7dc4ce 100644 --- a/src/tests/blocklist.zig +++ b/src/tests/blocklist.zig @@ -5,27 +5,24 @@ const testing = std.testing; const utils = @import("utils.zig"); const sqids = @import("sqids"); -const Squids = sqids.Sqids; +const Sqids = sqids.Sqids; const testing_allocator = testing.allocator; test "if no custom blocklist param, use the default blocklist" { - const s = try Squids.init(testing_allocator, .{}); - defer s.deinit(); + const s: Sqids = .default; try utils.expectDecode(testing_allocator, s, "aho1e", &.{4572721}); try utils.expectEncode(testing_allocator, s, &.{4572721}, "JExTR"); } test "if an empty blocklist param passed, don't use any blocklist" { - const s = try Squids.init(testing_allocator, .{ .blocklist = &.{} }); - defer s.deinit(); + const s = try Sqids.init(.{ .blocklist = &.{} }); try utils.expectEncodeDecodeWithID(testing_allocator, s, &.{4572721}, "aho1e"); } test "if a non-empty blocklist param passed, use only that" { - const s = try Squids.init(testing_allocator, .{ .blocklist = &.{"ArUO"} }); - defer s.deinit(); + const s = try Sqids.init(.{ .blocklist = &.{"ArUO"} }); try utils.expectEncodeDecodeWithID(testing_allocator, s, &.{4572721}, "aho1e"); @@ -35,7 +32,7 @@ test "if a non-empty blocklist param passed, use only that" { } test "blocklist" { - const s = try Squids.init(testing_allocator, .{ + const s = try Sqids.init(.{ .blocklist = &.{ "JSwXFaosAN", // normal result of 1st encoding, let's block that word on purpose "OCjV9JK64o", // result of 2nd encoding @@ -44,13 +41,12 @@ test "blocklist" { "7tE6", // result of 4th encoding is `7tE6jdAHLe`, let's block the prefix }, }); - defer s.deinit(); try utils.expectEncodeDecodeWithID(testing_allocator, s, &.{ 1_000_000, 2_000_000 }, "1aYeB7bRUt"); } test "decoding blocklist words should still work" { - const s = try Squids.init(testing_allocator, .{ + const s = try Sqids.init(.{ .blocklist = &.{ "86Rf07", "se8ojk", @@ -59,7 +55,6 @@ test "decoding blocklist words should still work" { "5sQRZO", }, }); - defer s.deinit(); try utils.expectDecode(testing_allocator, s, "86Rf07", &.{ 1, 2, 3 }); try utils.expectDecode(testing_allocator, s, "se8ojk", &.{ 1, 2, 3 }); @@ -69,33 +64,30 @@ test "decoding blocklist words should still work" { } test "match against a short blocklist word" { - const s = try Squids.init(testing_allocator, .{ + const s = try Sqids.init(.{ .blocklist = &.{"pnd"}, }); - defer s.deinit(); try utils.expectEncodeDecode(testing_allocator, s, &.{1000}); } test "blocklist filtering in constructor" { - const s = try Squids.init(testing_allocator, .{ + const s = try Sqids.init(.{ .alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", .blocklist = &.{"sxnzkl"}, }); - defer s.deinit(); try utils.expectEncodeDecodeWithID(testing_allocator, s, &.{ 1, 2, 3 }, "IBSHOZ"); // without blocklist, would've been "SXNZKL" } test "max encoding attempts" { // Setup encoder such that alphabet.len == min_length == blocklist.len - const s = try Squids.init(testing_allocator, .{ + const s = try Sqids.init(.{ .alphabet = "abc", .min_length = 3, .blocklist = &.{ "cab", "abc", "bca" }, }); - defer s.deinit(); - const err = s.encode(&.{0}) catch |err| err; + const err = s.encode(testing_allocator, &.{0}) catch |err| err; try testing.expectError(sqids.Error.ReachedMaxAttempts, err); } diff --git a/src/tests/encoding.zig b/src/tests/encoding.zig index 8a3defd..349263e 100644 --- a/src/tests/encoding.zig +++ b/src/tests/encoding.zig @@ -4,13 +4,11 @@ const testing = std.testing; const utils = @import("utils.zig"); -const sqids = @import("sqids"); -const Squids = sqids.Sqids; +const Sqids = @import("sqids").Sqids; const testing_allocator = testing.allocator; test "default encoder: encode incremental numbers" { - const s = try Squids.init(testing_allocator, .{}); - defer s.deinit(); + const s: Sqids = .default; var cases = std.StringHashMap([]const u64).init(testing_allocator); defer cases.deinit(); @@ -64,8 +62,7 @@ test "default encoder: encode incremental numbers" { } test "default encoder: multi input" { - const s = try Squids.init(testing_allocator, .{}); - defer s.deinit(); + const s: Sqids = .default; const numbers = [2][]const u64{ &.{ 0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, std.math.maxInt(u64) }, @@ -84,9 +81,9 @@ test "default encoder: multi input" { }; for (numbers) |n| { - const tmp_id = try s.encode(n); + const tmp_id = try s.encode(testing_allocator, n); defer testing_allocator.free(tmp_id); - const dec_output = try s.decode(tmp_id); + const dec_output = try s.decode(testing_allocator, tmp_id); defer testing_allocator.free(dec_output); try testing.expectEqualSlices(u64, n, dec_output); @@ -94,28 +91,25 @@ test "default encoder: multi input" { } test "default encoder: encoding no numbers" { - const s = try Squids.init(testing_allocator, .{}); - defer s.deinit(); + const s: Sqids = .default; - const output = try s.encode(&.{}); + const output = try s.encode(testing_allocator, &.{}); try testing.expectEqualStrings("", output); testing_allocator.free(output); } test "default encoder: decoding empty string" { - const s = try Squids.init(testing_allocator, .{}); - defer s.deinit(); + const s: Sqids = .default; - const output = try s.decode(""); + const output = try s.decode(testing_allocator, ""); try testing.expectEqualSlices(u64, &.{}, output); testing_allocator.free(output); } test "default encoder: decoding ID with invalid character" { - const s = try Squids.init(testing_allocator, .{}); - defer s.deinit(); + const s: Sqids = .default; - const output = try s.decode("*"); + const output = try s.decode(testing_allocator, "*"); try testing.expectEqualSlices(u64, &.{}, output); testing_allocator.free(output); } diff --git a/src/tests/minlength.zig b/src/tests/minlength.zig index 0cf6fc2..14bc89d 100644 --- a/src/tests/minlength.zig +++ b/src/tests/minlength.zig @@ -5,7 +5,7 @@ const testing = std.testing; const utils = @import("utils.zig"); const sqids = @import("sqids"); -const Squids = sqids.Sqids; +const Sqids = sqids.Sqids; const testing_allocator = testing.allocator; test "min length: incremental min length" { @@ -36,10 +36,9 @@ test "min length: incremental min length" { while (it.next()) |e| { const min_length = e.key_ptr.*; const id = e.value_ptr.*; - const s = try Squids.init(testing_allocator, .{ .min_length = min_length }); - defer s.deinit(); + const s = try Sqids.init(.{ .min_length = min_length }); - const got_id = try s.encode(&numbers); + const got_id = try s.encode(testing_allocator, &numbers); defer testing_allocator.free(got_id); try testing.expect(min_length == got_id.len); @@ -48,22 +47,22 @@ test "min length: incremental min length" { } test "min length: incremental numbers" { - const s = try Squids.init(testing_allocator, .{ .min_length = sqids.default_alphabet.len }); - defer s.deinit(); - - var ids = std.StringArrayHashMap([]const u64).init(testing_allocator); - defer ids.deinit(); - - try ids.put("SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu", &.{ 0, 0 }); - try ids.put("n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc", &.{ 0, 1 }); - try ids.put("tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ", &.{ 0, 2 }); - try ids.put("eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE", &.{ 0, 3 }); - try ids.put("rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX", &.{ 0, 4 }); - try ids.put("sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2", &.{ 0, 5 }); - try ids.put("uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0", &.{ 0, 6 }); - try ids.put("74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy", &.{ 0, 7 }); - try ids.put("30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS", &.{ 0, 8 }); - try ids.put("moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin", &.{ 0, 9 }); + const ta = testing_allocator; + const s = try Sqids.init(.{ .min_length = sqids.default_alphabet.len }); + + var ids: std.array_hash_map.String([]const u64) = .empty; + defer ids.deinit(ta); + + try ids.put(ta, "SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu", &.{ 0, 0 }); + try ids.put(ta, "n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc", &.{ 0, 1 }); + try ids.put(ta, "tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ", &.{ 0, 2 }); + try ids.put(ta, "eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE", &.{ 0, 3 }); + try ids.put(ta, "rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX", &.{ 0, 4 }); + try ids.put(ta, "sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2", &.{ 0, 5 }); + try ids.put(ta, "uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0", &.{ 0, 6 }); + try ids.put(ta, "74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy", &.{ 0, 7 }); + try ids.put(ta, "30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS", &.{ 0, 8 }); + try ids.put(ta, "moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin", &.{ 0, 9 }); var it = ids.iterator(); while (it.next()) |e| { @@ -86,15 +85,15 @@ test "min length: various" { }; for (min_lengths) |min_length| { - const s = try Squids.init(testing_allocator, .{ .min_length = min_length }); - defer s.deinit(); + const s = try Sqids.init(.{ .min_length = min_length }); + for (numbers) |ns| { - const id = try s.encode(ns); + const id = try s.encode(testing_allocator, ns); defer testing_allocator.free(id); try testing.expect(id.len >= min_length); - const got_numbers = try s.decode(id); + const got_numbers = try s.decode(testing_allocator, id); defer testing_allocator.free(got_numbers); try testing.expectEqualSlices(u64, ns, got_numbers); diff --git a/src/tests/test_runner.zig b/src/tests/test_runner.zig index 4e76de5..33062ed 100644 --- a/src/tests/test_runner.zig +++ b/src/tests/test_runner.zig @@ -1,6 +1,16 @@ -// This is for the development branch of Zig. -// See https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b for a version that works -// on Zig 0.14.1. +// This is for the Zig 0.16. + +// See https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b/eb15512d6ae49663fa9df6c7a9725b20dab43edd +// for a version that workson Zig 0.15.2. + +// See https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b/1f317ebc9cd09bc50fd5591d09c34255e15d1d85 +// for a version that workson Zig 0.14.1. + +// in your build.zig, you can specify a custom test runner: +// const tests = b.addTest(.{ +// .root_module = $MODULE_BEING_TESTED, +// .test_runner = .{ .path = b.path("test_runner.zig"), .mode = .simple }, +// }); // in your build.zig, you can specify a custom test runner: // const tests = b.addTest(.{ @@ -9,6 +19,7 @@ // }); const std = @import("std"); +const Io = std.Io; const builtin = @import("builtin"); const Allocator = std.mem.Allocator; @@ -18,16 +29,23 @@ const BORDER = "=" ** 80; // use in custom panic handler var current_test: ?[]const u8 = null; -pub fn main() !void { +pub fn main(init: std.process.Init) !void { var mem: [8192]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&mem); const allocator = fba.allocator(); - const env = Env.init(allocator); - defer env.deinit(allocator); + const env = Env.init(init.environ_map); + + std.testing.io_instance = .init(init.gpa, .{ + .argv0 = .init(init.minimal.args), + .environ = init.minimal.environ, + }); + defer std.testing.io_instance.deinit(); - var slowest = SlowTracker.init(allocator, 5); + const io = std.testing.io; + + var slowest = SlowTracker.init(allocator, io, 5); defer slowest.deinit(); var pass: usize = 0; @@ -52,7 +70,7 @@ pub fn main() !void { } var status = Status.pass; - slowest.startTiming(); + slowest.startTiming(io); const is_unnamed_test = isUnnamed(t); if (env.filter) |f| { @@ -78,7 +96,7 @@ pub fn main() !void { const result = t.func(); current_test = null; - const ns_taken = slowest.endTiming(friendly_name); + const ns_taken = slowest.endTiming(io, friendly_name); if (std.testing.allocator_instance.deinit() == .leak) { leak += 1; @@ -97,7 +115,7 @@ pub fn main() !void { fail += 1; Printer.status(.fail, "\n{s}\n\"{s}\" - {s}\n{s}\n", .{ BORDER, friendly_name, @errorName(err), BORDER }); if (@errorReturnTrace()) |trace| { - std.debug.dumpStackTrace(trace.*); + std.debug.dumpErrorReturnTrace(trace); } if (env.fail_first) { break; @@ -134,7 +152,7 @@ pub fn main() !void { Printer.fmt("\n", .{}); try slowest.display(); Printer.fmt("\n", .{}); - std.posix.exit(if (fail == 0) 0 else 1); + std.process.exit(if (fail == 0) 0 else 1); } const Printer = struct { @@ -161,19 +179,22 @@ const Status = enum { }; const SlowTracker = struct { - const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); max: usize, slowest: SlowestQueue, - timer: std.time.Timer, + start: Io.Timestamp, + allocator: Allocator, - fn init(allocator: Allocator, count: u32) SlowTracker { - const timer = std.time.Timer.start() catch @panic("failed to start timer"); - var slowest = SlowestQueue.init(allocator, {}); - slowest.ensureTotalCapacity(count) catch @panic("OOM"); + const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); + + fn init(allocator: Allocator, io: Io, count: u32) SlowTracker { + const timestamp = Io.Clock.awake.now(io); + var slowest: SlowestQueue = .empty; + slowest.ensureTotalCapacity(allocator, count) catch @panic("OOM"); return .{ .max = count, - .timer = timer, + .start = timestamp, .slowest = slowest, + .allocator = allocator, }; } @@ -182,24 +203,26 @@ const SlowTracker = struct { name: []const u8, }; - fn deinit(self: SlowTracker) void { - self.slowest.deinit(); + fn deinit(self: *SlowTracker) void { + self.slowest.deinit(self.allocator); } - fn startTiming(self: *SlowTracker) void { - self.timer.reset(); + fn startTiming(self: *SlowTracker, io: Io) void { + self.start = Io.Clock.awake.now(io); } - fn endTiming(self: *SlowTracker, test_name: []const u8) u64 { - var timer = self.timer; - const ns = timer.lap(); + fn endTiming(self: *SlowTracker, io: Io, test_name: []const u8) u64 { + const timestamp = Io.Clock.awake.now(io); + const start = self.start; + self.start = timestamp; + const ns: u64 = @intCast(start.durationTo(timestamp).toNanoseconds()); var slowest = &self.slowest; if (slowest.count() < self.max) { // Capacity is fixed to the # of slow tests we want to track // If we've tracked fewer tests than this capacity, than always add - slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); + slowest.push(self.allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); return ns; } @@ -214,8 +237,8 @@ const SlowTracker = struct { } // the previous fastest of our slow tests, has been pushed off. - _ = slowest.removeMin(); - slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); + _ = slowest.popMin(); + slowest.push(self.allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); return ns; } @@ -223,7 +246,7 @@ const SlowTracker = struct { var slowest = self.slowest; const count = slowest.count(); Printer.fmt("Slowest {d} test{s}: \n", .{ count, if (count != 1) "s" else "" }); - while (slowest.removeMinOrNull()) |info| { + while (slowest.popMin()) |info| { const ms = @as(f64, @floatFromInt(info.ns)) / 1_000_000.0; Printer.fmt(" {d:.2}ms\t{s}\n", .{ ms, info.name }); } @@ -240,34 +263,20 @@ const Env = struct { fail_first: bool, filter: ?[]const u8, - fn init(allocator: Allocator) Env { + fn init(map: *const std.process.Environ.Map) Env { return .{ - .verbose = readEnvBool(allocator, "TEST_VERBOSE", true), - .fail_first = readEnvBool(allocator, "TEST_FAIL_FIRST", false), - .filter = readEnv(allocator, "TEST_FILTER"), + .verbose = readEnvBool(map, "TEST_VERBOSE", true), + .fail_first = readEnvBool(map, "TEST_FAIL_FIRST", false), + .filter = readEnv(map, "TEST_FILTER"), }; } - fn deinit(self: Env, allocator: Allocator) void { - if (self.filter) |f| { - allocator.free(f); - } - } - - fn readEnv(allocator: Allocator, key: []const u8) ?[]const u8 { - const v = std.process.getEnvVarOwned(allocator, key) catch |err| { - if (err == error.EnvironmentVariableNotFound) { - return null; - } - std.log.warn("failed to get env var {s} due to err {}", .{ key, err }); - return null; - }; - return v; + fn readEnv(map: *const std.process.Environ.Map, key: []const u8) ?[]const u8 { + return map.get(key); } - fn readEnvBool(allocator: Allocator, key: []const u8, deflt: bool) bool { - const value = readEnv(allocator, key) orelse return deflt; - defer allocator.free(value); + fn readEnvBool(map: *const std.process.Environ.Map, key: []const u8, deflt: bool) bool { + const value = readEnv(map, key) orelse return deflt; return std.ascii.eqlIgnoreCase(value, "true"); } }; diff --git a/src/tests/utils.zig b/src/tests/utils.zig index d1638fb..4f87583 100644 --- a/src/tests/utils.zig +++ b/src/tests/utils.zig @@ -2,16 +2,15 @@ const std = @import("std"); const mem = std.mem; const testing = std.testing; -const sqids = @import("sqids"); -const Squids = sqids.Sqids; +const Sqids = @import("sqids").Sqids; pub fn expectEncode( allocator: mem.Allocator, - s: Squids, + s: Sqids, numbers: []const u64, id: []const u8, ) !void { - const got = try s.encode(numbers); + const got = try s.encode(allocator, numbers); defer allocator.free(got); try testing.expectEqualStrings(id, got); @@ -19,11 +18,11 @@ pub fn expectEncode( pub fn expectDecode( allocator: mem.Allocator, - s: Squids, + s: Sqids, id: []const u8, numbers: []const u64, ) !void { - const got = try s.decode(id); + const got = try s.decode(allocator, id); defer allocator.free(got); try testing.expectEqualSlices(u64, numbers, got); @@ -31,12 +30,12 @@ pub fn expectDecode( pub fn expectEncodeDecode( allocator: mem.Allocator, - s: Squids, + s: Sqids, numbers: []const u64, ) !void { - const id = try s.encode(numbers); + const id = try s.encode(allocator, numbers); defer allocator.free(id); - const obtained_numbers = try s.decode(id); + const obtained_numbers = try s.decode(allocator, id); defer allocator.free(obtained_numbers); try testing.expectEqualSlices(u64, numbers, obtained_numbers); @@ -44,17 +43,17 @@ pub fn expectEncodeDecode( pub fn expectEncodeDecodeWithID( allocator: mem.Allocator, - s: Squids, + s: Sqids, numbers: []const u64, id: []const u8, ) !void { // Test encoding. - const obtained_id = try s.encode(numbers); + const obtained_id = try s.encode(allocator, numbers); defer allocator.free(obtained_id); try testing.expectEqualStrings(id, obtained_id); // Test decoding back. - const obtained_numbers = try s.decode(obtained_id); + const obtained_numbers = try s.decode(allocator, obtained_id); defer allocator.free(obtained_numbers); try testing.expectEqualSlices(u64, numbers, obtained_numbers); }