From a945c035ce9e3d5fda81a05f95f3ea1788a19ece Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 13:43:52 -0300 Subject: A ^ after | anchors its branch: ^def|^ finds lines starting either way mvzr takes ^ only at its pattern's start, so in ^def|^ the second branch never matched mid-text. An alternation whose every top-level branch starts with ^ is now compiled as ^(def| ); a mix of anchored and unanchored branches is refused rather than silently wrong. Co-Authored-By: Claude Opus 5.5 --- src/regexp.zig | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) (limited to 'src') diff --git a/src/regexp.zig b/src/regexp.zig index 27b3c0ba..ca396ff1 100644 --- a/src/regexp.zig +++ b/src/regexp.zig @@ -48,6 +48,11 @@ pub const Regex = struct { /// ends of the whole text and so never match where sam would. pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex { if (pat.len == 0) return error.Bad; + // mvzr takes `^` only at its pattern's start, so `^def|^ ` (a `^` + // after a `|`) is written `^(def| )` for it: the same lines. A mix, + // `^a|b`, has no such spelling and is refused rather than wrong. + var joined: [258]u8 = undefined; + if (try anchoredAlternation(pat, &joined)) |whole| return compile(whole); var buf: [256]u8 = undefined; var len: usize = 0; var spans = false; @@ -97,6 +102,56 @@ pub const Regex = struct { }; } + /// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at + /// its top level and every branch starts with `^`; null when it is not + /// one, or no branch does. + fn anchoredAlternation(pat: []const u8, out: *[258]u8) error{Bad}!?[]const u8 { + var bars: [16]usize = undefined; + var n: usize = 0; + var depth: usize = 0; + var in_class = false; + var i: usize = 0; + while (i < pat.len) : (i += 1) { + const c = pat[i]; + if (c == '\\') { + i += 1; + } else if (in_class) { + in_class = c != ']'; + } else if (c == '[') { + in_class = true; + } else if (c == '(') { + depth += 1; + } else if (c == ')') { + depth -|= 1; + } else if (c == '|' and depth == 0) { + if (n == bars.len) return error.Bad; + bars[n] = i; + n += 1; + } + } + if (n == 0) return null; + var anchored: usize = 0; + var from: usize = 0; + for (0..n + 1) |k| { + const to = if (k < n) bars[k] else pat.len; + anchored += @intFromBool(to > from and pat[from] == '^'); + from = to + 1; + } + if (anchored == 0) return null; + if (anchored != n + 1) return error.Bad; + var w = std.Io.Writer.fixed(out); + w.writeAll("^(") catch return error.Bad; + from = 0; + for (0..n + 1) |k| { + const to = if (k < n) bars[k] else pat.len; + if (k > 0) w.writeByte('|') catch return error.Bad; + w.writeAll(pat[from + 1 .. to]) catch return error.Bad; + from = to + 1; + } + w.writeByte(')') catch return error.Bad; + return w.buffered(); + } + pub const Match = struct { start: usize, end: usize }; /// The first match that starts in `from..=last` and ends by `hi`, as @@ -185,6 +240,16 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li try std.testing.expectError(error.Bad, Regex.compile("a\\")); } +test "a ^ after | anchors that branch: ^def|^ finds a line that starts either way, and a mix is refused" { + const text = "x def\n a\ndef b\n"; + var both = try Regex.compile("^def|^ "); + try std.testing.expectEqual(@as(usize, 6), (try both.find(text, 0, text.len, text.len)).?.start); + try std.testing.expectEqual(@as(usize, 9), (try both.find(text, 7, text.len, text.len)).?.start); + try std.testing.expectError(error.Bad, Regex.compile("^def|x")); + var plain = try Regex.compile("a|b"); + try std.testing.expectEqual(@as(usize, 7), (try plain.find(text, 0, text.len, text.len)).?.start); +} + test "a quoted backslash before n is no newline, and a class ends where mvzr ends it" { // `\\n` is a backslash then an n: the pattern stays on one line, so its // `.` is not made [^\n] and does match within the line. -- cgit v1.3