mruby-regexp: implement fixed-length lookbehind assertions

Add (?<=...) positive and (?<!...) negative lookbehind support.
The sub-pattern must have a fixed byte length (no quantifiers or
alternation), computed at compile time and stored in the instruction.
At execution time, the engine backs up by that many bytes and runs
the sub-pattern forward. Maximum lookbehind length is 255 bytes.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yukihiro "Matz" Matsumoto
2026-03-22 22:13:29 +09:00
parent 4ded345ebb
commit 101f8c69a1
5 changed files with 142 additions and 1 deletions
+5 -1
View File
@@ -21,6 +21,8 @@ simulation) with backtracking fallback.
- `\1`-`\9` backreferences
- `(?=...)` positive lookahead
- `(?!...)` negative lookahead
- `(?<=...)` positive lookbehind (fixed-length only)
- `(?<!...)` negative lookbehind (fixed-length only)
### Anchors
@@ -102,7 +104,9 @@ pattern analysis.
## Limitations
- **No lookbehind**: `(?<=...)` and `(?<!...)` are not supported.
- **Fixed-length lookbehind only**: `(?<=...)` and `(?<!...)`
require a fixed-length pattern (no `*`, `+`, `?`, or alternation).
Maximum 255 bytes.
- **No Unicode properties**: `\p{Alpha}`, `\p{L}`, etc. are not
supported.
- **ASCII case folding only**: The `i` flag handles ASCII letters
@@ -32,6 +32,8 @@ enum re_opcode {
RE_BACKREF, /* backreference: operand = group number */
RE_LOOKAHEAD, /* positive lookahead: offset = end of sub-pattern */
RE_NEG_LOOKAHEAD, /* negative lookahead: offset = end of sub-pattern */
RE_LOOKBEHIND, /* positive lookbehind: a = byte length, offset = end */
RE_NEG_LOOKBEHIND, /* negative lookbehind: a = byte length, offset = end */
};
/* Bytecode instruction (4 bytes each for alignment) */
+78
View File
@@ -282,6 +282,59 @@ parse_quantifier(re_compiler *c, int *min_out, int *max_out)
return TRUE;
}
/*
* Compute the fixed byte length consumed by bytecode in range [start, end).
* Returns -1 if the pattern has variable length (quantifiers, alternation
* with different-length branches, etc.).
* Used for lookbehind: we need to know exactly how far back to look.
*/
static int
compute_fixed_len(re_compiler *c, uint32_t start, uint32_t end)
{
int len = 0;
uint32_t pc = start;
while (pc < end) {
re_inst inst = c->code[pc];
switch (inst.op) {
case RE_CHAR:
case RE_CLASS:
case RE_NCLASS:
len += 1;
pc++;
break;
case RE_ANY:
case RE_ANY_NL:
/* . matches one character which can be 1-4 bytes in UTF-8.
For ASCII-only mode this is 1 byte; for safety, only allow
if we can determine it's ASCII context. Return -1 for now. */
return -1;
case RE_SAVE:
pc++;
break; /* zero-width */
case RE_BOL: case RE_EOL: case RE_BOT: case RE_EOT: case RE_EOTNL:
case RE_WBOUND: case RE_NWBOUND:
pc++;
break; /* zero-width assertions */
case RE_JMP:
pc = inst.offset;
break;
case RE_SPLIT: {
/* alternation: both branches must have the same fixed length */
/* branch 1: pc+1 to next JMP before branch 2 */
/* branch 2: inst.offset to ... */
/* For simplicity, reject alternation in lookbehind */
return -1;
}
case RE_MATCH:
return len;
default:
return -1; /* unknown/variable-length instruction */
}
}
return len;
}
/* Compile a single atom (character, class, group, etc.) */
static void
compile_atom(re_compiler *c)
@@ -315,6 +368,31 @@ compile_atom(re_compiler *c)
c->has_nongreedy = TRUE; /* needs backtracking engine */
break; /* done with this atom */
}
else if (c->p[1] == '<' && c->p + 2 < c->src_end && (c->p[2] == '=' || c->p[2] == '!')) {
/* lookbehind (?<=...) or (?<!...) */
mrb_bool negative = (c->p[2] == '!');
next_char(c); next_char(c); next_char(c); /* skip ?<= or ?<! */
uint32_t lb_pos = emit(c, negative ? RE_NEG_LOOKBEHIND : RE_LOOKBEHIND, 0, 0);
uint32_t sub_start = c->code_len;
compile_alt(c);
emit(c, RE_MATCH, 0, 0);
c->code[lb_pos].offset = (uint16_t)c->code_len;
/* compute fixed byte length of lookbehind sub-pattern */
int fixed_len = compute_fixed_len(c, sub_start, c->code_len);
if (fixed_len < 0) {
compile_error(c, "lookbehind must be fixed length");
}
if (fixed_len > 255) {
compile_error(c, "lookbehind too long (max 255 bytes)");
}
c->code[lb_pos].a = (uint8_t)fixed_len;
if (peek(c) != ')') compile_error(c, "unmatched '('");
next_char(c);
c->has_nongreedy = TRUE; /* needs backtracking engine */
break;
}
else if (c->p[1] == '<' && c->p + 2 < c->src_end && c->p[2] != '=' && c->p[2] != '!') {
next_char(c); next_char(c); /* skip ?< */
cap_name = c->p;
+22
View File
@@ -452,6 +452,28 @@ bt_match(const mrb_regexp_pattern *pat, const char *str, const char *str_end,
pc = inst.offset;
break;
case RE_LOOKBEHIND:
{
int lb_len = inst.a;
if (sp - str < lb_len) return FALSE; /* not enough text before */
if (!bt_match(pat, str, str_end, sp - lb_len, pc + 1, captures, ncap, steps))
return FALSE;
pc = inst.offset;
}
break;
case RE_NEG_LOOKBEHIND:
{
int lb_len = inst.a;
if (sp - str >= lb_len) {
if (bt_match(pat, str, str_end, sp - lb_len, pc + 1, captures, ncap, steps))
return FALSE;
}
/* if not enough text before, negative lookbehind succeeds */
pc = inst.offset;
}
break;
default:
return FALSE;
}
+35
View File
@@ -377,6 +377,41 @@ assert("Regexp - lookahead does not consume") do
assert_nil /foo(?=baz)/.match("foobar")
end
assert("Regexp - positive lookbehind (?<=...)") do
md = Regexp.new("(?<=@)\\w+").match("user@host")
assert_equal "host", md[0]
assert_nil Regexp.new("(?<=@)\\w+").match("user_host")
end
assert("Regexp - negative lookbehind (?<!...)") do
md = Regexp.new("(?<!\\d)px").match("12px auto")
assert_nil md # preceded by digit
md = Regexp.new("(?<!\\d)em").match("12px 1.5em auto")
assert_nil md # preceded by digit
md = Regexp.new("(?<!\\d)px").match("top px")
assert_equal "px", md[0]
end
assert("Regexp - lookbehind with literal string") do
md = Regexp.new("(?<=foo)bar").match("foobar")
assert_equal "bar", md[0]
assert_nil Regexp.new("(?<=foo)bar").match("bazbar")
end
assert("Regexp - lookbehind at string start") do
# lookbehind should fail if not enough text before
assert_nil Regexp.new("(?<=abc)d").match("d")
# but should work at correct position
md = Regexp.new("(?<=abc)d").match("abcd")
assert_equal "d", md[0]
end
assert("Regexp - negative lookbehind at string start") do
# negative lookbehind succeeds when not enough text before
md = Regexp.new("(?<!x)a").match("a")
assert_equal "a", md[0]
end
assert("$1-$9 global variables") do
/(\w+)\s(\w+)/ =~ "hello world"
assert_equal "hello", $1