diff options
Diffstat (limited to 'src/glob.zig')
| -rw-r--r-- | src/glob.zig | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/src/glob.zig b/src/glob.zig new file mode 100644 index 0000000..83d9fcb --- /dev/null +++ b/src/glob.zig @@ -0,0 +1,121 @@ +//! Minimal glob matcher for tool/extension allow- and deny-lists. +//! +//! Patterns and names are dot-delimited segments (e.g. `std.git.status`). +//! Matching operates on whole segments, never on individual characters. +//! +//! Supported segment wildcards: +//! - `*` matches exactly one segment. `std.*` matches `std.read` but +//! not `std.git.status` (greedy within a segment, but not +//! dot-agnostic). +//! - `**` matches one or more segments. `std.**` matches both +//! `std.read` and `std.git.status`. +//! +//! A wildcard is only recognized as a whole segment: `std.*` and `std.**` +//! are wildcards, but `std.rea*` is a literal segment (the `*` has no +//! special meaning mid-segment). Everything else is matched literally. + +const std = @import("std"); + +/// Returns true if `name` matches `pattern` under the segment rules above. +pub fn match(pattern: []const u8, name: []const u8) bool { + var pat_it = std.mem.splitScalar(u8, pattern, '.'); + var name_it = std.mem.splitScalar(u8, name, '.'); + return matchSegments(&pat_it, &name_it); +} + +const SegmentIter = std.mem.SplitIterator(u8, .scalar); + +fn matchSegments(pat_it: *SegmentIter, name_it: *SegmentIter) bool { + while (pat_it.next()) |seg| { + if (std.mem.eql(u8, seg, "**")) { + // `**` matches one or more remaining segments. Try the + // shortest match first (one segment), then extend. + const rest_pat = pat_it.*; // pattern after `**` + while (name_it.next()) |_| { + var pat_copy = rest_pat; + var name_copy = name_it.*; + if (matchSegments(&pat_copy, &name_copy)) return true; + } + // `**` requires at least one segment, and the trailing + // pattern must have matched the remainder above. + return false; + } + + const name_seg = name_it.next() orelse return false; + if (std.mem.eql(u8, seg, "*")) { + // Matches exactly one (any) segment. + continue; + } + if (!std.mem.eql(u8, seg, name_seg)) return false; + } + + // Pattern exhausted: match iff name is also exhausted. + return name_it.next() == null; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "literal match" { + try testing.expect(match("std.read", "std.read")); + try testing.expect(!match("std.read", "std.write")); + try testing.expect(!match("std.read", "std.read.more")); + try testing.expect(!match("std.read.more", "std.read")); +} + +test "single star matches exactly one segment" { + try testing.expect(match("std.*", "std.read")); + try testing.expect(match("std.*", "std.write")); + try testing.expect(!match("std.*", "std.git.status")); + try testing.expect(!match("std.*", "std")); + try testing.expect(!match("std.*", "other.read")); +} + +test "double star is dot-agnostic, one or more segments" { + try testing.expect(match("std.**", "std.read")); + try testing.expect(match("std.**", "std.git.status")); + try testing.expect(!match("std.**", "std")); + try testing.expect(!match("std.**", "other.read")); +} + +test "bare single star matches one segment" { + try testing.expect(match("*", "std")); + try testing.expect(!match("*", "std.read")); + // An empty name is a single empty segment, which `*` matches. + try testing.expect(match("*", "")); +} + +test "bare double star matches everything non-empty" { + try testing.expect(match("**", "std")); + try testing.expect(match("**", "std.read")); + try testing.expect(match("**", "std.git.status")); +} + +test "star in middle" { + try testing.expect(match("*.read", "std.read")); + try testing.expect(match("*.*", "std.read")); + try testing.expect(!match("*.*", "std.git.status")); + try testing.expect(match("std.*.status", "std.git.status")); + try testing.expect(!match("std.*.status", "std.status")); +} + +test "double star in middle" { + try testing.expect(match("std.**.status", "std.git.status")); + try testing.expect(match("std.**.status", "std.a.b.status")); + try testing.expect(!match("std.**.status", "std.status")); + try testing.expect(match("**.status", "std.git.status")); +} + +test "wildcard only recognized as whole segment" { + // A `*` embedded in a segment is a literal, not a wildcard. + try testing.expect(match("std.rea*", "std.rea*")); + try testing.expect(!match("std.rea*", "std.read")); +} + +test "empty pattern only matches empty name" { + try testing.expect(match("", "")); + try testing.expect(!match("", "x")); +} |
