diff --git a/src/natord.zig b/src/natord.zig new file mode 100644 index 0000000..de8582b --- /dev/null +++ b/src/natord.zig @@ -0,0 +1,347 @@ +//! This file is a port of C implementaion that can be found here +//! https://github.com/sourcefrog/natsort. +const std = @import("std"); +const isSpace = std.ascii.isWhitespace; +const isDigit = std.ascii.isDigit; +const Order = std.math.Order; +const testing = std.testing; + +pub fn order(a: []const u8, b: []const u8) Order { + return natOrder(a, b, false); +} + +pub fn orderIgnoreCase(a: []const u8, b: []const u8) Order { + return natOrder(a, b, true); +} + +fn natOrder(a: []const u8, b: []const u8, comptime fold_case: bool) Order { + var ai: usize = 0; + var bi: usize = 0; + + while (true) : ({ + ai += 1; + bi += 1; + }) { + var ca = if (ai == a.len) 0 else a[ai]; + var cb = if (bi == b.len) 0 else b[bi]; + + while (isSpace(ca)) { + ai += 1; + ca = if (ai == a.len) 0 else a[ai]; + } + + while (isSpace(cb)) { + bi += 1; + cb = if (bi == b.len) 0 else b[bi]; + } + + if (isDigit(ca) and isDigit(cb)) { + const fractional = ca == '0' or cb == '0'; + + if (fractional) { + const result = compareLeft(a[ai..], b[bi..]); + if (result != .eq) return result; + } else { + const result = compareRight(a[ai..], b[bi..]); + if (result != .eq) return result; + } + } + + if (ca == 0 and cb == 0) { + return .eq; + } + + if (fold_case) { + ca = std.ascii.toUpper(ca); + cb = std.ascii.toUpper(cb); + } + + if (ca < cb) { + return .lt; + } + + if (ca > cb) { + return .gt; + } + } +} + +fn compareLeft(a: []const u8, b: []const u8) Order { + var i: usize = 0; + while (true) : (i += 1) { + const ca = if (i == a.len) 0 else a[i]; + const cb = if (i == b.len) 0 else b[i]; + + if (!isDigit(ca) and !isDigit(cb)) { + return .eq; + } + if (!isDigit(ca)) { + return .lt; + } + if (!isDigit(cb)) { + return .gt; + } + if (ca < cb) { + return .lt; + } + if (ca > cb) { + return .gt; + } + } + + return .eq; +} + +fn compareRight(a: []const u8, b: []const u8) Order { + var bias = Order.eq; + + var i: usize = 0; + while (true) : (i += 1) { + const ca = if (i == a.len) 0 else a[i]; + const cb = if (i == b.len) 0 else b[i]; + + if (!isDigit(ca) and !isDigit(cb)) { + return bias; + } + if (!isDigit(ca)) { + return .lt; + } + if (!isDigit(cb)) { + return .gt; + } + + if (ca < cb) { + if (bias != .eq) { + bias = .lt; + } + } else if (ca > cb) { + if (bias != .eq) { + bias = .gt; + } + } else if (ca == 0 and cb == 0) { + return bias; + } + } + + return .eq; +} + +const SortContext = struct { + ignore_case: bool = false, + reverse: bool = false, + + fn compare(self: @This(), a: []const u8, b: []const u8) bool { + const ord: std.math.Order = if (self.reverse) .gt else .lt; + if (self.ignore_case) { + return orderIgnoreCase(a, b) == ord; + } else { + return order(a, b) == ord; + } + } +}; + +test "lt" { + try testing.expectEqual(Order.lt, order("a_1", "a_10")); +} + +test "eq" { + try testing.expectEqual(Order.eq, order("a_1", "a_1")); +} + +test "gt" { + try testing.expectEqual(Order.gt, order("a_10", "a_1")); +} + +fn sortAndAssert(context: SortContext, input: [][]const u8, want: []const []const u8) !void { + std.sort.pdq([]const u8, input, context, SortContext.compare); + + for (input, want) |actual, expected| { + try testing.expectEqualStrings(expected, actual); + } +} + +test "sorting" { + const context = SortContext{}; + var items = [_][]const u8{ + "item100", + "item10", + "item1", + }; + const want = [_][]const u8{ + "item1", + "item10", + "item100", + }; + + try sortAndAssert(context, &items, &want); +} + +test "sorting 2" { + const context = SortContext{}; + var items = [_][]const u8{ + "item_30", + "item_15", + "item_3", + "item_2", + "item_10", + }; + const want = [_][]const u8{ + "item_2", + "item_3", + "item_10", + "item_15", + "item_30", + }; + + try sortAndAssert(context, &items, &want); +} + +test "leading zeros" { + const context = SortContext{}; + var items = [_][]const u8{ + "item100", + "item999", + "item001", + "item010", + "item000", + }; + const want = [_][]const u8{ + "item000", + "item001", + "item010", + "item100", + "item999", + }; + + try sortAndAssert(context, &items, &want); +} + +test "dates" { + const context = SortContext{}; + var items = [_][]const u8{ + "2000-1-10", + "2000-1-2", + "1999-12-25", + "2000-3-23", + "1999-3-3", + }; + const want = [_][]const u8{ + "1999-3-3", + "1999-12-25", + "2000-1-2", + "2000-1-10", + "2000-3-23", + }; + + try sortAndAssert(context, &items, &want); +} + +test "fractions" { + const context = SortContext{}; + var items = [_][]const u8{ + "Fractional release numbers", + "1.011.02", + "1.010.12", + "1.009.02", + "1.009.20", + "1.009.10", + "1.002.08", + "1.002.03", + "1.002.01", + }; + const want = [_][]const u8{ + "1.002.01", + "1.002.03", + "1.002.08", + "1.009.02", + "1.009.10", + "1.009.20", + "1.010.12", + "1.011.02", + "Fractional release numbers", + }; + + try sortAndAssert(context, &items, &want); +} + +test "words" { + const context = SortContext{}; + var items = [_][]const u8{ + "fred", + "pic2", + "pic100a", + "pic120", + "pic121", + "jane", + "tom", + "pic02a", + "pic3", + "pic4", + "1-20", + "pic100", + "pic02000", + "10-20", + "1-02", + "1-2", + "x2-y7", + "x8-y8", + "x2-y08", + "x2-g8", + "pic01", + "pic02", + "pic 6", + "pic 7", + "pic 5", + "pic05", + "pic 5 ", + "pic 5 something", + "pic 4 else", + }; + const want = [_][]const u8{ + "1-02", + "1-2", + "1-20", + "10-20", + "fred", + "jane", + "pic01", + "pic02", + "pic02a", + "pic02000", + "pic05", + "pic2", + "pic3", + "pic4", + "pic 4 else", + "pic 5", + "pic 5 ", + "pic 5 something", + "pic 6", + "pic 7", + "pic100", + "pic100a", + "pic120", + "pic121", + "tom", + "x2-g8", + "x2-y08", + "x2-y7", + "x8-y8", + }; + + try sortAndAssert(context, &items, &want); +} + +test "fuzz" { + const Context = struct { + fn testOne(context: @This(), input: []const u8) anyerror!void { + _ = context; + + const a = input[0..(input.len / 2)]; + const b = input[(input.len / 2)..]; + _ = order(a, b); + } + }; + + try std.testing.fuzz(Context{}, Context.testOne, .{}); +}