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
4 changes: 4 additions & 0 deletions mrbgems/mruby-regexp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ str.split(re) # => array of parts
str[re] # => matched substring or nil
str[re, capture] # => capture by index or name
str.slice(re) # => same as str[re]
str[re] = repl # replace the match
str[re, capture] = repl # replace a capture by index or name
str.slice!(re) # remove and return the match, or nil
str.slice!(re, capture) # same, for a capture by index or name

# Symbol methods (the String methods applied to the symbol's name)
sym.match(re) # => MatchData or nil
Expand Down
97 changes: 97 additions & 0 deletions mrbgems/mruby-regexp/mrblib/string_regexp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ class String
# function rather than an alias of `[]`, so this one capture serves both.
alias __aref []

# The write side of the same pair, overridden at the end of this file too.
# `[]=` has a single method table entry, and `slice!` comes from
# mruby-string-ext, which this gem depends on, so it needs its own capture.
alias __aset []=
alias __slice_bang slice!

# `match` and `match?` accept a Regexp or a String and reject everything
# else. The check lives in C (see Regexp.__check_pattern) so that the
# argument cannot steer it: it cannot pose as a Regexp, and there is no
Expand Down Expand Up @@ -258,4 +264,95 @@ def [](*args)
# what makes `sym[re]` work: `Symbol#[]` is an alias of `Symbol#slice`,
# which delegates to `String#slice`.
alias slice []

# Regexp-aware element assignment. Falls back to the C-defined `[]=`
# (aliased as `__aset` above) for every other argument form, and handles a
# regexp here.
#
# `vm_op_setidx()` optimizes Array and Hash only and sends `[]=` for every
# other receiver, so unlike the read side there is no opcode keeping the
# ordinary `str[i] = repl` off this override: it pays a Ruby frame on its
# way to `__aset`. That is why the delegation guard is a single
# `Regexp ===`, before any other work.
def []=(*args)
return __aset(*args) unless Regexp === args[0]
unless args.length == 2 || args.length == 3
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 2..3)"
end
# `match` and not `match?`, so that the match globals are published here
# including the clearing a failed match does. CRuby searches before it
# checks the receiver for modification, which makes the order observable:
# a frozen receiver still leaves the match behind, and a pattern that does
# not match raises IndexError rather than FrozenError. Letting the
# mutation below be what raises reproduces both.
md = args[0].match(self)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
raise IndexError, "regexp not matched" unless md
group = args.length > 2 ? args[1] : 0
if Integer === group
# An index out of range is an error here, not a missing group, and
# CRuby reports it before normalizing a negative one, so the message
# names the index as given, and group 0 is out of the negative end's
# reach. `MatchData#begin` has its own wording for this and rejects
# every negative index, so the check cannot be left to it.
size = md.size
if group >= size || -group >= size
raise IndexError, "index #{group} out of regexp"
end
group += size if group < 0
end
# A String or Symbol reaches `MatchData#begin` as it stands: it resolves
# the name to its group and raises the IndexError CRuby raises for a name
# that resolves to none, with the same message.
beg = md.begin(group)
# A group that exists but did not take part in the match has nothing to
# replace. CRuby names the group's number even when the argument was a
# name; the number is not reachable from Ruby, so the message repeats the
# argument as it was given.
raise IndexError, "regexp group #{group} not matched" unless beg
# `begin` and `end` report character offsets, which is the space the
# two-integer form of `[]=` works in, so a multibyte subject needs no
# further conversion. The replacement is handed over unchecked: the type
# check belongs to the core method, as it does for `sub`.
__aset(beg, md.end(group) - beg, args[-1])
end

# Regexp-aware `slice!`. Falls back to the C-defined `slice!` (aliased as
# `__slice_bang` above) for every other argument form.
def slice!(*args)
return __slice_bang(*args) unless Regexp === args[0]
if args.length > 2
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 1..2)"
end
# Before the search, where `mrb_str_slice_bang()` and CRuby both put it:
# a frozen receiver raises even for a pattern that would not have
# matched, and `$~` is left as it was. This is the opposite order from
# `[]=` above, and both are observable. `frozen?` is redefinable where
# the C check is not, but no other route to that check leaves the string
# alone on the way.
raise FrozenError, "can't modify frozen String" if frozen?
md = args[0].match(self)
return nil unless md
group = args.length > 1 ? args[1] : 0
if Integer === group
# Where `[]=` raises, `slice!` answers nil: an index that reaches no
# group removed nothing. The normalization is the same, so group 0
# stays out of the negative end's reach here too.
size = md.size
return nil if group >= size || -group >= size
group += size if group < 0
end
beg = md.begin(group)
# CRuby answers "" for a group that exists but did not take part in the
# match, and removes nothing. That falls out of `rb_str_slice_bang()`
# building the result from the group's -1 offset rather than out of a
# decision, but it is what the method answers.
return "" unless beg
len = md.end(group) - beg
# From the MatchData, whose subject is a snapshot taken before this
# method mutates anything, and which is a plain String even when the
# receiver is a subclass, both as in CRuby.
removed = md[group]
__aset(beg, len, "")
removed
end
end
210 changes: 210 additions & 0 deletions mrbgems/mruby-regexp/test/regexp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1982,3 +1982,213 @@ def fake.is_a?(klass); true; end
def fake.match(str); raise "must not be called"; end
assert_raise(TypeError) { "hello"[fake] }
end

assert("String#[]= with regexp") do
s = "hello"
assert_equal "X", (s[/l+/] = "X")
assert_equal "heXo", s

# a multibyte subject: `MatchData#begin` and `#end` report character
# offsets, which is the space the two-integer form of `[]=` works in
s = "あいlluえお"
s[/l+/] = "X"
assert_equal "あいXuえお", s

# an empty match replaces an empty span
s = "hello"
s[/x*/] = "X"
assert_equal "Xhello", s

# a pattern that does not match is an error, unlike the read side's nil
s = "hello"
assert_raise(IndexError) { s[/z/] = "X" }
assert_equal "hello", s
end

assert("String#[]= with regexp and capture") do
s = "hello"
s[/(l+)(o)/, 1] = "X"
assert_equal "heXo", s

s = "hello"
s[/(l+)(o)/, 0] = "X"
assert_equal "heX", s

# a negative index counts back from the last group, and is rejected once
# it reaches group 0, so the whole match is out of its reach
s = "hello"
s[/(l+)(o)/, -1] = "X"
assert_equal "hellX", s
assert_raise(IndexError) { "hello"[/l+/, -1] = "X" }

s = "hello"
s[/(?<x>l+)/, :x] = "Y"
assert_equal "heYo", s

s = "あいlluえお"
s[/(?<x>l+)/, "x"] = "Y"
assert_equal "あいYuえお", s

# an index that reaches no group is an error here, where the read side
# answers nil
assert_raise(IndexError) { "hello"[/(l+)/, 5] = "X" }
# so is a group that exists but did not take part in the match
assert_raise(IndexError) { "hello"[/(h)|(z)/, 2] = "X" }
# and so is a name that resolves to no group
assert_raise(IndexError) { "hello"[/(?<x>l+)/, :zz] = "X" }
assert_raise(IndexError) { "hello"[/(l+)/, "x"] = "X" }
assert_raise(TypeError) { "hello"[/(l+)/, nil] = "X" }
end

assert("String#[]= with regexp sets the match globals") do
s = "hello"
s[/l+/] = "X"
assert_equal "ll", $~[0]
# the MatchData describes the subject as it was before the replacement
assert_equal "hello", $~.string

s = "hello"
s[/(l)(l)/, 2] = "X"
assert_equal "l", $1

# a failed match clears $~ before the IndexError, which is why this goes
# through `match` rather than `match?`
assert_raise(IndexError) { "hello"[/z/] = "X" }
assert_nil $~
end

assert("String#[]= with regexp searches before it checks the receiver") do
# CRuby modifies last, so a frozen receiver raises only once the search
# has left its match behind, and a pattern that does not match raises
# IndexError rather than FrozenError
$~ = nil
assert_raise(FrozenError) { "hello".freeze[/l+/] = "X" }
assert_equal "ll", $~[0]

$~ = nil
assert_raise(IndexError) { "hello".freeze[/z/] = "X" }
assert_nil $~
end

assert("String#[]= delegates every non-regexp argument") do
s = "hello"
s[0] = "H"
assert_equal "Hello", s
s[1, 3] = "X"
assert_equal "HXo", s
s = "hello"
s[1..3] = "X"
assert_equal "hXo", s
s = "hello"
s["ll"] = "X"
assert_equal "heXo", s

# the errors are the ones the C method raises, and the replacement reaches
# its type check unconverted. Only the regexp form's arity is the
# override's to report: the core reads its arguments in order, so a
# four-argument call is rejected for the replacement's type before the
# count is ever looked at.
assert_raise(ArgumentError) { "hello"[/l/, 1, 2] = "X" }
assert_raise(TypeError) { "hello"[1, 2, 3] = "X" }
assert_raise(IndexError) { "hello"["bye"] = "X" }
assert_raise(TypeError) { "hello"[nil] = "X" }
assert_raise(TypeError) { "hello"[/l/] = :sym }

# `is_a?` is redefinable, so the real type is what decides
re = /l+/
def re.is_a?(klass); false; end
s = "hello"
s[re] = "X"
assert_equal "heXo", s
end

assert("String#slice! with regexp") do
s = "hello"
assert_equal "ll", s.slice!(/l+/)
assert_equal "heo", s

s = "あいlluえお"
assert_equal "ll", s.slice!(/l+/)
assert_equal "あいuえお", s

# a pattern that does not match removes nothing and answers nil
s = "hello"
assert_nil s.slice!(/z/)
assert_equal "hello", s

# an empty match removes nothing but is still a match
s = "hello"
assert_equal "", s.slice!(/x*/)
assert_equal "hello", s

# a plain String even for a subclass receiver, as in CRuby
sub = Class.new(String)
assert_equal String, sub.new("hello").slice!(/l+/).class
end

assert("String#slice! with regexp and capture") do
s = "hello"
assert_equal "l", s.slice!(/(l)(o)/, 1)
assert_equal "helo", s
# the MatchData left behind describes the whole match, not the capture
assert_equal "lo", $~[0]

s = "hello"
assert_equal "o", s.slice!(/(l+)(o)/, -1)
assert_equal "hell", s

s = "hello"
assert_equal "ll", s.slice!(/(?<x>l+)/, :x)
assert_equal "heo", s

# where `[]=` raises, an index that reaches no group answers nil here,
# group 0 included once the index is negative
s = "hello"
assert_nil s.slice!(/(l+)/, 5)
assert_nil s.slice!(/l+/, -1)
assert_equal "hello", s

# a group that exists but did not take part in the match answers "" and
# removes nothing, as in CRuby
s = "hello"
assert_equal "", s.slice!(/(h)|(z)/, 2)
assert_equal "hello", s

# only a name that resolves to no group raises, as it does for `[]=`
assert_raise(IndexError) { "hello".slice!(/(?<x>l+)/, :zz) }
assert_raise(IndexError) { "hello".slice!(/(l+)/, "x") }
assert_raise(TypeError) { "hello".slice!(/(l+)/, nil) }
end

assert("String#slice! with regexp checks the receiver before it searches") do
# the opposite order from `[]=`, and CRuby draws the same distinction: the
# check comes first, so a pattern that would not have matched still raises
# and $~ is left alone
$~ = nil
assert_raise(FrozenError) { "hello".freeze.slice!(/l+/) }
assert_nil $~
assert_raise(FrozenError) { "hello".freeze.slice!(/z/) }
assert_nil $~
end

assert("String#slice! delegates every non-regexp argument") do
s = "hello"
assert_equal "e", s.slice!(1)
assert_equal "hllo", s
s = "hello"
assert_equal "ell", s.slice!(1, 3)
assert_equal "ho", s
s = "hello"
assert_equal "ell", s.slice!(1..3)
assert_equal "ho", s
s = "hello"
assert_equal "ll", s.slice!("ll")
assert_equal "heo", s
assert_nil "hello".slice!("bye")

assert_raise(ArgumentError) { "hello".slice! }
assert_raise(ArgumentError) { "hello".slice!(1, 2, 3) }
assert_raise(ArgumentError) { "hello".slice!(/l/, 1, 2) }
assert_raise(TypeError) { "hello".slice!(nil) }
assert_raise(FrozenError) { "hello".freeze.slice!(0) }
end
Loading