Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions mrbgems/mruby-regexp/include/re_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ typedef struct mrb_regexp_pattern {
uint8_t first_bytes[16]; /* bitmap of possible first bytes (128-bit, ASCII) */
mrb_bool has_first_bytes; /* true if first_bytes is usable for skipping */
mrb_bool is_literal; /* true if pattern is pure literal (no metacharacters) */
uint8_t loop_depth; /* deepest nesting of repetitions whose body can
match empty (see RE_MAX_PASS) */
/* Cached VM state for pike_vm (avoids malloc per mrb_re_exec call) */
uint32_t *cached_visited; /* generation-based visited array */
void *cached_threads[2]; /* curr/next thread lists */
Expand Down Expand Up @@ -133,6 +135,23 @@ typedef struct {
const char *sp;
} re_thread_cache;

/* A pike_vm step walks a repetition's body once per nesting level, so that a
loop's final empty iteration can finish even when the closure resumed
inside the body and already marked that iteration's tail (see add_thread).
The cap keeps a pathologically nested pattern from growing the thread lists
with the square of the program; past it, such a pattern keeps the older,
stale-capture behaviour rather than costing memory. */
#define RE_MAX_PASS 4
#define RE_PASS_SPAN(depth) \
((uint32_t)((depth) < RE_MAX_PASS ? (depth) : RE_MAX_PASS) + 1)

/* Capacity of one pike_vm thread list, shared by the VM and by the cache the
compiler pre-allocates for it so the two cannot drift. An instruction
enqueues at most one thread per pass, and threads waiting on a later sp are
carried over from the previous step on top of that. */
#define RE_LIST_CAPA(code_len, depth) \
((int)(code_len) * (int)(RE_PASS_SPAN(depth) + 1) + 16)

/* Compile a pattern string into bytecode */
mrb_regexp_pattern* mrb_re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags);

Expand Down
68 changes: 67 additions & 1 deletion mrbgems/mruby-regexp/src/re_compile.c
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,70 @@ first_set_walk(const re_inst *code, uint32_t code_len,
return FALSE;
}

/* TRUE when an epsilon-only path runs from pc to goal, so the repetition that
goal closes can complete an iteration without consuming. seen[] is marked
with `mark` rather than cleared, so one buffer serves every edge. */
static mrb_bool
epsilon_path(const re_inst *code, uint32_t pc, uint32_t goal,
uint32_t *seen, uint32_t mark)
{
while (pc != goal) {
if (pc > goal || seen[pc] == mark) return FALSE;
seen[pc] = mark;
switch (code[pc].op) {
case RE_SAVE:
case RE_BOL: case RE_EOL: case RE_BOT: case RE_EOT: case RE_EOTNL:
case RE_WBOUND: case RE_NWBOUND:
pc++;
break;
case RE_JMP:
pc = code[pc].offset;
break;
case RE_SPLIT:
case RE_SPLITNG:
if (epsilon_path(code, code[pc].offset, goal, seen, mark)) return TRUE;
pc++;
break;
default:
return FALSE; /* consumes input, or is an assertion this walk cannot judge */
}
}
return TRUE;
}

/* Find the repetitions whose body can match empty and mark the backward edge
that closes each one, so the Pike VM knows which loops need the empty-
iteration handling in add_thread() and which can stay on the cheap path.
Returns how deeply those loops nest, which bounds the VM's epsilon passes
and the thread lists sized from them; see RE_MAX_PASS and RE_LIST_CAPA. */
static uint8_t
mark_empty_loops(mrb_state *mrb, re_inst *code, uint32_t code_len)
{
int32_t *delta = (int32_t*)mrb_calloc(mrb, code_len + 1, sizeof(int32_t));
uint32_t *seen = (uint32_t*)mrb_calloc(mrb, code_len + 1, sizeof(uint32_t));
uint32_t mark = 0;

for (uint32_t pc = 0; pc < code_len; pc++) {
re_inst in = code[pc];
if (in.op != RE_JMP && in.op != RE_SPLIT && in.op != RE_SPLITNG) continue;
code[pc].a = 0; /* this pass owns `a` on the edge opcodes */
if (in.offset > pc) continue; /* forward edge: alternation, not a loop */
if (!epsilon_path(code, in.offset, pc, seen, ++mark)) continue;
code[pc].a = 1;
delta[in.offset]++;
delta[pc + 1]--; /* the closing edge itself still sits inside the loop */
}

int32_t depth = 0, max = 0;
for (uint32_t pc = 0; pc < code_len; pc++) {
depth += delta[pc];
if (depth > max) max = depth;
}
mrb_free(mrb, seen);
mrb_free(mrb, delta);
return max > UINT8_MAX ? UINT8_MAX : (uint8_t)max;
}

static mrb_bool
compute_first_set(const re_inst *code, uint32_t code_len,
const re_charclass *classes, uint8_t *bm)
Expand Down Expand Up @@ -1583,9 +1647,11 @@ mrb_re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags)
}
}

pat->loop_depth = mark_empty_loops(mrb, pat->code, pat->code_len);

/* Pre-allocate VM state cache for pike_vm */
{
int list_capa = (int)pat->code_len * 2 + 16;
int list_capa = RE_LIST_CAPA(pat->code_len, pat->loop_depth);
pat->cached_visited = (uint32_t*)mrb_calloc(mrb, pat->code_len + 1, sizeof(uint32_t));
pat->cached_threads[0] = mrb_malloc(mrb, sizeof(re_thread_cache) * list_capa);
pat->cached_threads[1] = mrb_malloc(mrb, sizeof(re_thread_cache) * list_capa);
Expand Down
97 changes: 79 additions & 18 deletions mrbgems/mruby-regexp/src/re_exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ typedef struct {
int pool_next; /* next free slot */
int pool_capa; /* total slots allocated */
uint32_t *visited; /* generation-based */
uint32_t gen;
uint32_t gen; /* visited key this step's first epsilon pass uses */
uint32_t key_max; /* highest key a further pass may reach this step */
uint32_t pass_span; /* keys one step reserves: key_max - gen + 1 */
const char *str;
const char *str_end;
mrb_bool matched;
Expand Down Expand Up @@ -129,21 +131,71 @@ pool_copy(pike_state *s, int src_slot)

#define CAP(s, slot) (&(s)->cap_pool[(slot) * (s)->ncap])

/* Add thread following epsilon transitions.
visited[pc] == gen means already visited this step. */
/* TRUE once this step's closure has walked the loop head at pc, which means
the body just ran without consuming: an empty iteration. */
static mrb_bool
loop_head_seen(pike_state *s, uint32_t pc)
{
return s->visited[pc] >= s->gen;
}

/* A fork also closes e+: the body is laid out before it, so its jump target
is the body start and pc+1 is the loop's exit. Returns the key the jump
target's branch walks under, or RE_LOOP_STOP when the body matched empty
and the repetition therefore has to stop. An ordinary fork, or one closing
a loop whose body always consumes (`a` is 0, see mark_empty_loops()), has
no empty iteration to account for and keeps the current key. */
#define RE_LOOP_STOP UINT32_MAX

static uint32_t
re_loop_back(pike_state *s, re_inst inst, uint32_t pc, uint32_t key)
{
if (inst.offset > pc || !inst.a) return key;
if (loop_head_seen(s, inst.offset)) return RE_LOOP_STOP;
return key < s->key_max ? key + 1 : key;
}

/* Add thread following epsilon transitions. `key` is s->gen for the first
pass over this step's closure and one higher per further pass, and
visited[pc] holds the key of the pass that last walked pc: a later pass may
re-walk what an earlier one marked, and no key is ever reused by a later
step. */
static void
add_thread(pike_state *s, re_threadlist *list,
uint32_t pc, int cap_slot, const char *sp)
uint32_t pc, int cap_slot, const char *sp, uint32_t key)
{
for (;;) {
if (s->cut) return;
if (pc >= s->pat->code_len) return;
if (s->visited[pc] == s->gen) return;
s->visited[pc] = s->gen;
if (s->visited[pc] >= key) return;
s->visited[pc] = key;

re_inst inst = s->pat->code[pc];
switch (inst.op) {
case RE_JMP:
/* A backward jump closes a repetition (e*, e{n,}): it returns to the
RE_SPLIT/RE_SPLITNG head, whose offset is the loop's exit. `a` is set
only when that body can run empty (see mark_empty_loops()), which is
the only case with a final empty iteration to account for. */
if (inst.offset <= pc && inst.a) {
uint32_t head = inst.offset;
if (loop_head_seen(s, head)) {
/* The head was walked at this position, so the iteration that just
finished consumed nothing. Onigmo stops a repetition on an empty
iteration and keeps what that iteration captured, so leave the
loop from here rather than dying on the head's mark: this path
outranks the exit the head itself queued before the body ran, and
claims the exit pc first. */
pc = s->pat->code[head].offset;
continue;
}
/* The head is unmarked, so this closure resumed inside the body and
the iteration it just finished is a real one. Run the next
iteration in a fresh pass, past the marks the resumed tail left. */
if (key < s->key_max) key++;
pc = head;
continue;
}
pc = inst.offset;
continue;

Expand All @@ -155,19 +207,24 @@ add_thread(pike_state *s, re_threadlist *list,
pc+1's closure can mutate the shared slot; the jump branch then runs
on that snapshot. */
{
uint32_t back = re_loop_back(s, inst, pc, key);
if (back == RE_LOOP_STOP) { pc++; continue; }
int cp = s->match_only ? 0 : pool_copy(s, cap_slot);
add_thread(s, list, pc + 1, cap_slot, sp);
add_thread(s, list, pc + 1, cap_slot, sp, key);
if (s->cut) return;
pc = inst.offset;
cap_slot = cp;
key = back;
}
continue;

case RE_SPLITNG:
/* Non-greedy fork: the jump target outranks the fall-through. */
{
uint32_t back = re_loop_back(s, inst, pc, key);
if (back == RE_LOOP_STOP) { pc++; continue; }
int cp = s->match_only ? 0 : pool_copy(s, cap_slot);
add_thread(s, list, inst.offset, cap_slot, sp);
add_thread(s, list, inst.offset, cap_slot, sp, back);
if (s->cut) return;
pc = pc + 1;
cap_slot = cp;
Expand Down Expand Up @@ -262,7 +319,7 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat,
int ncap = pat->num_captures * 2;
if (ncap == 0) ncap = 2;

int list_capa = (int)pat->code_len * 2 + 16;
int list_capa = RE_LIST_CAPA(pat->code_len, pat->loop_depth);

mrb_bool match_only = (captures == NULL || captures_size == 0);

Expand All @@ -281,7 +338,9 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat,
s.match_only = match_only;
s.binary = binary;
s.cut = FALSE;
s.gen = 1;
s.pass_span = RE_PASS_SPAN(pat->loop_depth);
s.gen = s.pass_span;
s.key_max = s.gen + s.pass_span - 1;
if (match_only) {
s.pool_capa = 1;
s.pool_next = 0;
Expand Down Expand Up @@ -337,9 +396,10 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat,
!mrb_re_utf8_interior_p(str, sp, str_end)) {
int slot = match_only ? 0 : pool_alloc(&s);
if (!match_only) memset(CAP(&s, slot), -1, sizeof(int) * ncap);
s.gen++;
s.gen += s.pass_span;
s.key_max += s.pass_span;
s.cut = FALSE;
add_thread(&s, &curr, 0, slot, sp);
add_thread(&s, &curr, 0, slot, sp, s.gen);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (s.matched && curr.count == 0) break;
}
}
Expand Down Expand Up @@ -367,7 +427,8 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat,
s.pool_next = curr.count;
}

s.gen++;
s.gen += s.pass_span;
s.key_max += s.pass_span;
s.cut = FALSE;
next.count = 0;

Expand Down Expand Up @@ -400,35 +461,35 @@ pike_vm(mrb_state *mrb, const mrb_regexp_pattern *pat,
case RE_CHAR:
if (ch == inst.a) {
int cp = match_only ? 0 : pool_copy(&s, th->cap_slot);
add_thread(&s, &next, th->pc + 1, cp, sp + 1);
add_thread(&s, &next, th->pc + 1, cp, sp + 1, s.gen);
}
break;

case RE_ANY:
if (ch != '\n') {
int cp = match_only ? 0 : pool_copy(&s, th->cap_slot);
add_thread(&s, &next, th->pc + 1, cp, sp + advance);
add_thread(&s, &next, th->pc + 1, cp, sp + advance, s.gen);
}
break;

case RE_ANY_NL:
{
int cp = match_only ? 0 : pool_copy(&s, th->cap_slot);
add_thread(&s, &next, th->pc + 1, cp, sp + advance);
add_thread(&s, &next, th->pc + 1, cp, sp + advance, s.gen);
}
break;

case RE_CLASS:
if (class_match(&pat->classes[inst.a], curr_cp)) {
int cp = match_only ? 0 : pool_copy(&s, th->cap_slot);
add_thread(&s, &next, th->pc + 1, cp, sp + advance);
add_thread(&s, &next, th->pc + 1, cp, sp + advance, s.gen);
}
break;

case RE_NCLASS:
if (!class_match(&pat->classes[inst.a], curr_cp)) {
int cp = match_only ? 0 : pool_copy(&s, th->cap_slot);
add_thread(&s, &next, th->pc + 1, cp, sp + advance);
add_thread(&s, &next, th->pc + 1, cp, sp + advance, s.gen);
}
break;

Expand Down
49 changes: 49 additions & 0 deletions mrbgems/mruby-regexp/test/regexp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,55 @@
assert_equal "a", Regexp.new("ab?").match("ac")[0]
end

assert("Regexp - a repetition stops on an empty iteration") do
# A repetition whose body matches empty runs that iteration and then stops,
# so a body that prefers the empty branch ends the loop at once instead of
# going around again on the branch that consumes.
assert_equal "", "a".match(/(|a)*/)[0]
assert_equal "", "aaa".match(/(|a)*/)[0]
assert_equal "", "a".match(/(?:|a)+/)[0]
# a body that can only match empty after consuming still consumes first
assert_equal "aaa", "aaa".match(/(a|)*/)[0]
assert_equal "aa", "aab".match(/(a?)*/)[0]
end

assert("Regexp - a repetition keeps its last, empty iteration's capture") do
# The final iteration is the empty one, and the group keeps what it
# captured: the empty string where the loop stopped. The linear-time engine
# used to drop that iteration and report the previous one's text, or nil
# when there was no previous one.
md = "a".match(/(a?)*/)
assert_equal "a", md[0]
assert_equal "", md[1]
assert_equal 1, md.begin(1)
assert_equal "", "aab".match(/(a*)*b/)[1]
assert_equal "", "a".match(/(a|)*/)[1]
assert_equal "", "a".match(/(a?)+/)[1]
# with no earlier iteration the group still participates
assert_equal "", "b".match(/(a?)*/)[1]
assert_equal "", "".match(/(a?)*/)[1]
assert_equal "", "b".match(/(a*)*b/)[1]
# a nullable body nested in a repetition reaches the same answer
assert_equal "", "a".match(/((a?)*)*/)[1]
# both engines agree: a lookaround routes the same pattern to the other one
assert_equal "", "a".match(/(?=a)(a?)*/)[1]
assert_equal 1, "a".match(/(?=a)(a?)*/).begin(1)
assert_equal "", "b".match(/(?=b)(a?)*/)[1]
end

assert("Regexp - a repetition whose body always consumes is unaffected") do
assert_equal "b", "ab".match(/(a|b)*/)[1]
assert_nil "c".match(/(a|b)*/)[1]
assert_equal "aa", "aa".match(/(a)*/)[0]
assert_equal "a", "aa".match(/(a)*/)[1]
assert_equal ["", "b", ""], "ab".split(/(?:a?)*/, -1)
end

assert("String#split and String#scan see the empty iteration's capture") do
assert_equal ["", "", "b", "", ""], "ab".split(/(a?)*/, -1)
assert_equal [[""], [""], [""]], "ab".scan(/(a?)*/)
end

assert("Regexp - quantified first alternative does not leak into the next") do
# A quantifier loops back to its own atom. When the atom starts the first
# alternative, the alternation SPLIT is inserted in front of it; the
Expand Down
Loading