mirror of
https://github.com/mruby/mruby
synced 2026-06-08 16:11:16 +00:00
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:
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user