const std = @import("std"); const Allocator = std.mem.Allocator; /// Maximum accepted glob pattern length in bytes. pub const max_pattern_bytes: usize = 4096; const max_pattern_segments: usize = max_pattern_bytes + 1; /// Error set for compiling a borrowed glob pattern. pub const CompileError = error{ OutOfMemory, PatternTooLong, }; /// Borrowed, pre-split glob pattern for matching workspace paths. pub const Pattern = struct { raw: []const u8, segments: []const []const u8, has_path_separator: bool, /// Compiles a borrowed pattern. Caller owns the segment slice and releases it with deinit. pub fn compile(alloc: Allocator, raw: []const u8) CompileError!Pattern { if (raw.len > max_pattern_bytes) return error.PatternTooLong; const has_path_separator = std.mem.findScalar(u8, raw, '/') != null; if (!has_path_separator) { return .{ .raw = raw, .segments = &.{}, .has_path_separator = false, }; } var segments: std.ArrayList([]const u8) = .empty; errdefer segments.deinit(alloc); try appendSegments(alloc, raw, &segments); return .{ .raw = raw, .segments = try segments.toOwnedSlice(alloc), .has_path_separator = true, }; } /// Frees the owned segment slice. The raw pattern bytes are borrowed. pub fn deinit(self: *Pattern, alloc: Allocator) void { if (self.has_path_separator) alloc.free(self.segments); self.* = .{ .raw = "", .segments = &.{}, .has_path_separator = false, }; } /// Returns true when the pattern matches a path. Patterns without slash match the basename. pub fn matchesPath(self: Pattern, candidate_path: []const u8) bool { if (!self.has_path_separator) { return matchSegment(self.raw, std.fs.path.basename(candidate_path)); } return matchSegmented(self.segments, candidate_path); } /// Returns true when a slash-free pattern matches a single basename. pub fn matchesBasename(self: Pattern, basename: []const u8) bool { if (self.has_path_separator) return false; return matchSegment(self.raw, basename); } }; fn appendSegments(alloc: Allocator, path: []const u8, segments: *std.ArrayList([]const u8)) Allocator.Error!void { var rest = path; while (rest.len > 0) { const slash = std.mem.findScalar(u8, rest, '/') orelse { try segments.append(alloc, rest); return; }; try segments.append(alloc, rest[0..slash]); rest = rest[slash + 1 ..]; } } fn matchSegmented(pattern_segments: []const []const u8, candidate_path: []const u8) bool { const segment_count = pattern_segments.len; std.debug.assert(segment_count <= max_pattern_segments); var previous: [max_pattern_segments + 1]bool = [_]bool{false} ** (max_pattern_segments + 1); var current: [max_pattern_segments + 1]bool = [_]bool{false} ** (max_pattern_segments + 1); previous[0] = true; for (pattern_segments, 0..) |segment, index| { previous[index + 1] = previous[index] and isDoubleStar(segment); } var candidate_it = SegmentIterator.init(candidate_path); while (candidate_it.next()) |candidate_segment| { @memset(current[0 .. segment_count + 1], false); for (pattern_segments, 0..) |pattern_segment, index| { current[index + 1] = if (isDoubleStar(pattern_segment)) current[index] or previous[index + 1] else previous[index] and matchSegment(pattern_segment, candidate_segment); } @memcpy(previous[0 .. segment_count + 1], current[0 .. segment_count + 1]); } return previous[segment_count]; } const SegmentIterator = struct { rest: []const u8, fn init(path: []const u8) SegmentIterator { return .{ .rest = path }; } fn next(self: *SegmentIterator) ?[]const u8 { if (self.rest.len == 0) return null; const slash = std.mem.findScalar(u8, self.rest, '/') orelse { const segment = self.rest; self.rest = ""; return segment; }; const segment = self.rest[0..slash]; self.rest = self.rest[slash + 1 ..]; return segment; } }; fn isDoubleStar(segment: []const u8) bool { return std.mem.eql(u8, segment, "**"); } fn matchSegment(pattern: []const u8, candidate: []const u8) bool { var pattern_index: usize = 0; var candidate_index: usize = 0; var star_pattern_index: ?usize = null; var star_candidate_index: usize = 0; while (candidate_index < candidate.len) { if (pattern_index < pattern.len) { const token = pattern[pattern_index]; if (token == '*') { while (pattern_index < pattern.len and pattern[pattern_index] == '*') { pattern_index += 1; } if (pattern_index == pattern.len) return true; star_pattern_index = pattern_index; star_candidate_index = candidate_index; continue; } if (token == '?' or token == candidate[candidate_index]) { if (candidate[candidate_index] == '/') return false; pattern_index += 1; candidate_index += 1; continue; } } const retry_pattern_index = star_pattern_index orelse return false; star_candidate_index += 1; candidate_index = star_candidate_index; pattern_index = retry_pattern_index; } while (pattern_index < pattern.len and pattern[pattern_index] == '*') { pattern_index += 1; } return pattern_index == pattern.len; } fn expectCompiledMatch(pattern: []const u8, candidate_path: []const u8, expected: bool) !void { var compiled = try Pattern.compile(std.testing.allocator, pattern); defer compiled.deinit(std.testing.allocator); try std.testing.expectEqual(expected, compiled.matchesPath(candidate_path)); } test "glob pattern matches recursive and segment wildcards" { try expectCompiledMatch("**/*.zig", "main.zig", true); try expectCompiledMatch("**/*.zig", "src/core/main.zig", true); try expectCompiledMatch("*.zig", "src/main.zig", true); try expectCompiledMatch("file?.zig", "dir/file1.zig", true); try expectCompiledMatch("file?.zig", "dir/file12.zig", false); try expectCompiledMatch("foo\\bar.txt", "src/foo\\bar.txt", true); try expectCompiledMatch("src/*.zig", "src/main.zig", true); try expectCompiledMatch("src/*.zig", "src/core/main.zig", false); try expectCompiledMatch("src/*.zig", "lib/src/main.zig", false); } test "glob pattern preserves globstar zero-or-more segment semantics" { try expectCompiledMatch("**", "", true); try expectCompiledMatch("src/", "src", true); try expectCompiledMatch("src/**", "src", true); try expectCompiledMatch("src/**", "src/core/main.zig", true); try expectCompiledMatch("src/**/main.zig", "src/main.zig", true); try expectCompiledMatch("src/**/main.zig", "src/core/nested/main.zig", true); try expectCompiledMatch("src/**/main.zig", "lib/src/core/main.zig", false); } test "glob pattern treats backslashes literally" { try expectCompiledMatch("dir\\*.zig", "dir\\main.zig", true); try expectCompiledMatch("dir\\*.zig", "dir/main.zig", false); } test "glob pattern handles adversarial repeated-star nonmatches without recursion" { const alloc = std.testing.allocator; const star_count = 1024; var consecutive = try alloc.alloc(u8, star_count + 1); defer alloc.free(consecutive); @memset(consecutive[0..star_count], '*'); consecutive[star_count] = 'z'; const candidate = try alloc.alloc(u8, star_count); defer alloc.free(candidate); @memset(candidate, 'a'); var compiled_consecutive = try Pattern.compile(alloc, consecutive); defer compiled_consecutive.deinit(alloc); try std.testing.expect(!compiled_consecutive.matchesPath(candidate)); var separated: std.Io.Writer.Allocating = .init(alloc); defer separated.deinit(); var i: usize = 0; while (i < 512) : (i += 1) { try separated.writer.writeAll("*a"); } try separated.writer.writeByte('z'); const separated_pattern = try separated.toOwnedSlice(); defer alloc.free(separated_pattern); var compiled_separated = try Pattern.compile(alloc, separated_pattern); defer compiled_separated.deinit(alloc); try std.testing.expect(!compiled_separated.matchesPath(candidate)); } test "glob pattern rejects patterns beyond the maximum accepted length" { const alloc = std.testing.allocator; const accepted = try alloc.alloc(u8, max_pattern_bytes); defer alloc.free(accepted); @memset(accepted, 'a'); var accepted_pattern = try Pattern.compile(alloc, accepted); defer accepted_pattern.deinit(alloc); try std.testing.expect(accepted_pattern.matchesPath(accepted)); const rejected = try alloc.alloc(u8, max_pattern_bytes + 1); defer alloc.free(rejected); @memset(rejected, 'a'); try std.testing.expectError(error.PatternTooLong, Pattern.compile(alloc, rejected)); }