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
1 change: 1 addition & 0 deletions mrbgems/mruby-regexp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ simulation) with backtracking fallback.
- `\D`, `\W`, `\S` negated shortcuts
- `(...)` capture group
- `(?:...)` non-capturing group
- `(?#...)` comment group
- `(?<name>...)` named capture group
- `|` alternation
- `\1`-`\9` backreferences
Expand Down
100 changes: 77 additions & 23 deletions mrbgems/mruby-regexp/src/re_compile.c
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/* Compiler state */
typedef struct {
mrb_state *mrb;
const char *src; /* pattern source (preprocessed in extended mode) */
const char *src; /* pattern source, preprocessed (see preprocess_pattern) */
const char *src_end;
const char *orig; /* pattern as written, for error messages */
const char *orig_end;
Expand All @@ -31,19 +31,20 @@ typedef struct {
uint16_t num_named;
mrb_bool has_backref;
mrb_bool needs_backtrack;
char *stripped; /* allocated buffer for x-mode preprocessing */
char *stripped; /* allocated buffer for pattern preprocessing */
} re_compiler;

static void compile_alt(re_compiler *c); /* forward */

static void
compile_error(re_compiler *c, const char *msg)
{
/* Quote c->orig, the pattern as written: in extended mode c->src points at
the buffer strip_extended() returned, so quoting it would drop the
free-spacing and the comments from the message. c->orig is the caller's
buffer, which outlives the compile. It is not NUL-terminated, so use %l
with the explicit length from c->orig_end. */
/* Quote c->orig, the pattern as written: when the pattern is preprocessed
c->src points at the buffer preprocess_pattern() returned, so quoting it
would drop the free-spacing, the comments and the (?#...) groups from the
message. c->orig is the caller's buffer, which outlives the compile. It
is not NUL-terminated, so use %l with the explicit length from
c->orig_end. */
mrb_value emsg = mrb_format(c->mrb, "%s: /%l/",
msg, c->orig, (size_t)(c->orig_end - c->orig));

Expand Down Expand Up @@ -719,12 +720,19 @@ compile_atom(re_compiler *c)
compile_error(c, "undefined (?...) sequence");
}
}
else if (c->p[1] == '#') {
/* preprocess_pattern() removes a terminated comment group before
the parser runs, so one reaching here was never closed. */
compile_error(c, "unterminated comment group");
}
else {
/* (?X) with an unsupported X: not one of the recognized (?: (?= (?!
(?<= (?<! (?<name> (?imx forms. The absent operator (?~...) and
conditionals (?(...)) are not implemented. Raise here rather than
falling through to the capturing-group path, which would leave
the stray `?` for compile_seq to spin on forever (A1). */
(?<= (?<! (?<name> (?imx forms. Comment groups (?#...) never get
here either, having been removed by preprocess_pattern(). The
absent operator (?~...) and conditionals (?(...)) are not
implemented. Raise here rather than falling through to the
capturing-group path, which would leave the stray `?` for
compile_seq to spin on forever (A1). */
compile_error(c, "undefined (?...) sequence");
}
}
Expand Down Expand Up @@ -1140,12 +1148,31 @@ compile_alt(re_compiler *c)
}

/*
* Strip whitespace and #comments for extended mode (/x flag).
* Whitespace inside [...] character classes is preserved.
* Does the pattern hold a (?# comment group opener? Cheap pre-check so an
* ordinary pattern without one skips preprocess_pattern() and its malloc.
*/
static mrb_bool
has_comment_group(const char *src, mrb_int len)
{
const char *p = src, *end = src + len;
while (p < end && (p = (const char*)memchr(p, '(', (size_t)(end - p))) != NULL) {
if (end - p >= 3 && p[1] == '?' && p[2] == '#') return TRUE;
p++;
}
return FALSE;
}

/*
* Rewrite the pattern before the parser sees it.
* Removes (?#...) comment groups always, and in extended mode (/x) also
* whitespace and #comments.
* Whitespace inside [...] character classes is preserved, and so is a (?#
* written there, which is a literal member rather than a comment group.
* Escaped characters (\ followed by anything) are preserved.
*/
static char*
strip_extended(mrb_state *mrb, const char *src, mrb_int len, mrb_int *out_len)
preprocess_pattern(mrb_state *mrb, const char *src, mrb_int len,
mrb_bool extended, mrb_int *out_len)
{
char *buf = (char*)mrb_malloc(mrb, len);
mrb_int o = 0;
Expand Down Expand Up @@ -1186,14 +1213,39 @@ strip_extended(mrb_state *mrb, const char *src, mrb_int len, mrb_int *out_len)
if (src < end && *src == ']') buf[o++] = *src++;
continue;
}
if (ch == '#') {
/* skip to end of line */
while (src < end && *src != '\n') src++;
if (ch == '(' && end - src >= 3 && src[1] == '?' && src[2] == '#') {
/* Comment group: ends at the first ')' not preceded by a backslash.
It does not nest, so (?#a(?#b)) closes at the first ')' and leaves
the second one to be reported as unmatched, as CRuby does.
Dropping the group here rather than in compile_atom() is what lets
it stand where an atom cannot: CRuby compiles "a(?#x)*" as "a*", and
an atom that emits no instruction cannot be a quantifier's target.
An unterminated group is copied through instead, so that
compile_atom() raises on it. */
const char *q = src + 3;
while (q < end && *q != ')') {
if (*q == '\\' && q + 1 < end) q++;
q++;
}
if (q < end) {
src = q + 1;
continue;
}
buf[o++] = *src++;
buf[o++] = *src++;
buf[o++] = *src++;
continue;
}
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '\f' || ch == '\v') {
src++;
continue;
if (extended) {
if (ch == '#') {
/* skip to end of line */
while (src < end && *src != '\n') src++;
continue;
}
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '\f' || ch == '\v') {
src++;
continue;
}
}
buf[o++] = *src++;
}
Expand Down Expand Up @@ -1294,9 +1346,10 @@ mrb_re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags)
c.orig = pattern;
c.orig_end = pattern + len;

if (flags & RE_FLAG_EXTENDED) {
if ((flags & RE_FLAG_EXTENDED) || has_comment_group(pattern, len)) {
mrb_int slen;
c.stripped = strip_extended(mrb, pattern, len, &slen);
c.stripped = preprocess_pattern(mrb, pattern, len,
(flags & RE_FLAG_EXTENDED) != 0, &slen);
pattern = c.stripped;
len = slen;
}
Expand Down Expand Up @@ -1333,7 +1386,8 @@ mrb_re_compile(mrb_state *mrb, const char *pattern, mrb_int len, uint32_t flags)

/* Copy capture names into an owned arena. Until this point the names
point into the pattern source (or into c.stripped, which gets freed
below in /x mode). After this loop the regexp owns its names. */
below when the pattern was preprocessed). After this loop the regexp
owns its names. */
if (c.num_named > 0) {
size_t total = 0;
for (uint16_t i = 0; i < c.num_named; i++) total += c.named_captures[i].name_len;
Expand Down
48 changes: 48 additions & 0 deletions mrbgems/mruby-regexp/test/regexp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,42 @@
assert_raise(RegexpError) { Regexp.new("(?x)a b") }
end

assert("Regexp - comment groups (?#...)") do
# The group is removed before the pattern is parsed, so it can stand
# anywhere, including where an atom cannot.
assert_true(/a(?#note)b/.match?("ab"))
assert_true Regexp.new("(?#lead)ab").match?("ab")
assert_true Regexp.new("ab(?#trail)").match?("ab")
assert_true Regexp.new("a(?#)b").match?("ab") # empty comment
assert_true Regexp.new("a(?#no\nte)b").match?("ab") # newline is comment text
assert_equal ["ab", "ab"], Regexp.new("(a(?#c)b)").match("ab").to_a

# The group is not an atom: a quantifier after it repeats what came before.
assert_equal 0, (Regexp.new("a(?#x)*") =~ "aaa")
assert_raise(RegexpError) { Regexp.new("(?#x)*") }

# A backslash escapes the following byte, so \) does not close the group.
assert_true Regexp.new("a(?#x\\)y)b").match?("ab")
# ... but an escaped backslash does not reach the ')', which then closes
# the group and leaves the second one unmatched.
assert_raise(RegexpError) { Regexp.new("a(?#x\\\\)y)b") }

# Comment groups do not nest: the first ')' closes, the second is unmatched.
assert_raise(RegexpError) { Regexp.new("x(?#a(?#b))y") }

# An unterminated group raises rather than swallowing the rest.
assert_raise_with_message(RegexpError, "unterminated comment group: /a(?#note/") do
Regexp.new("a(?#note")
end

# Inside a character class the same bytes are ordinary members.
assert_true Regexp.new("a[(?#c)]b").match?("a#b")
assert_true Regexp.new("a[(?#c)]b").match?("a(b")

# An escaped '(' does not open a comment group.
assert_raise(RegexpError) { Regexp.new("a\\(?#note)b") }
end

assert("MatchData#captures") do
re = Regexp.new("(a)(b)(c)")
md = re.match("abc")
Expand Down Expand Up @@ -696,6 +732,18 @@
re = Regexp.new('a\\ b', Regexp::EXTENDED)
assert_true re.match?("a b")

# a comment group is removed ahead of the line-comment pass, so its ')'
# survives the '#' inside it
re = Regexp.new("a (?#note) b", Regexp::EXTENDED)
assert_true re.match?("ab")

re = Regexp.new("a (?#note) b # tail\nc", Regexp::EXTENDED)
assert_true re.match?("abc")

assert_raise_with_message(RegexpError, "unterminated comment group: /a (?#note/") do
Regexp.new("a (?#note", Regexp::EXTENDED)
end

# inspect shows x flag
assert_equal "/abc/x", Regexp.new("abc", Regexp::EXTENDED).inspect

Expand Down
Loading