From 101f8c69a1c864df33dbd26dded8dc30ebc3d727 Mon Sep 17 00:00:00 2001 From: "Yukihiro \"Matz\" Matsumoto" Date: Sun, 22 Mar 2026 22:13:29 +0900 Subject: [PATCH] mruby-regexp: implement fixed-length lookbehind assertions Add (?<=...) positive and (? --- mrbgems/mruby-regexp/README.md | 6 +- mrbgems/mruby-regexp/include/re_internal.h | 2 + mrbgems/mruby-regexp/src/re_compile.c | 78 ++++++++++++++++++++++ mrbgems/mruby-regexp/src/re_exec.c | 22 ++++++ mrbgems/mruby-regexp/test/regexp.rb | 35 ++++++++++ 5 files changed, 142 insertions(+), 1 deletion(-) diff --git a/mrbgems/mruby-regexp/README.md b/mrbgems/mruby-regexp/README.md index 363206d10..1a29ed813 100644 --- a/mrbgems/mruby-regexp/README.md +++ b/mrbgems/mruby-regexp/README.md @@ -21,6 +21,8 @@ simulation) with backtracking fallback. - `\1`-`\9` backreferences - `(?=...)` positive lookahead - `(?!...)` negative lookahead +- `(?<=...)` positive lookbehind (fixed-length only) +- `(?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 (?p[2] == '!'); + next_char(c); next_char(c); next_char(c); /* skip ?<= or ?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; diff --git a/mrbgems/mruby-regexp/src/re_exec.c b/mrbgems/mruby-regexp/src/re_exec.c index 9ab311bc2..d45a1956e 100644 --- a/mrbgems/mruby-regexp/src/re_exec.c +++ b/mrbgems/mruby-regexp/src/re_exec.c @@ -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; } diff --git a/mrbgems/mruby-regexp/test/regexp.rb b/mrbgems/mruby-regexp/test/regexp.rb index d09a8e97e..9b48ccc1f 100644 --- a/mrbgems/mruby-regexp/test/regexp.rb +++ b/mrbgems/mruby-regexp/test/regexp.rb @@ -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 (?