Range#step: restore legacy behavior for String ranges

This commit is contained in:
zverok 2024-08-25 20:58:47 +03:00 committed by Akinori Musha
parent 452eb24b15
commit 245ed2fc89
Notes: git 2024-09-03 07:22:00 +00:00
3 changed files with 146 additions and 56 deletions

60
range.c
View File

@ -309,6 +309,34 @@ range_each_func(VALUE range, int (*func)(VALUE, VALUE), VALUE arg)
} }
} }
// NB: Two functions below (step_i_iter and step_i) are used only to maintain the
// backward-compatible behavior for string ranges with integer steps. If that branch
// will be removed from range_step, these two can go, too.
static bool
step_i_iter(VALUE arg)
{
VALUE *iter = (VALUE *)arg;
if (FIXNUM_P(iter[0])) {
iter[0] -= INT2FIX(1) & ~FIXNUM_FLAG;
}
else {
iter[0] = rb_funcall(iter[0], '-', 1, INT2FIX(1));
}
if (iter[0] != INT2FIX(0)) return false;
iter[0] = iter[1];
return true;
}
static int
step_i(VALUE i, VALUE arg)
{
if (step_i_iter(arg)) {
rb_yield(i);
}
return 0;
}
static int static int
discrete_object_p(VALUE obj) discrete_object_p(VALUE obj)
{ {
@ -430,9 +458,18 @@ range_step_size(VALUE range, VALUE args, VALUE eobj)
* *
* For non-Numeric ranges, step absence is an error: * For non-Numeric ranges, step absence is an error:
* *
* ('a'..'z').step { p _1 } * (Time.utc(2022, 3, 1)..Time.utc(2022, 2, 24)).step { p _1 }
* # raises: step is required for non-numeric ranges (ArgumentError) * # raises: step is required for non-numeric ranges (ArgumentError)
* *
* For backward compatibility reasons, String ranges support the iteration both with
* string step and with integer step. In the latter case, the iteration is performed
* by calculating the next values with String#succ:
*
* ('a'..'e').step(2) { p _1 }
* # Prints: a, c, e
* ('a'..'e').step { p _1 }
* # Default step 1; prints: a, b, c, d, e
*
*/ */
static VALUE static VALUE
range_step(int argc, VALUE *argv, VALUE range) range_step(int argc, VALUE *argv, VALUE range)
@ -445,11 +482,15 @@ range_step(int argc, VALUE *argv, VALUE range)
const VALUE b_num_p = rb_obj_is_kind_of(b, rb_cNumeric); const VALUE b_num_p = rb_obj_is_kind_of(b, rb_cNumeric);
const VALUE e_num_p = rb_obj_is_kind_of(e, rb_cNumeric); const VALUE e_num_p = rb_obj_is_kind_of(e, rb_cNumeric);
// For backward compatibility reasons (conforming to behavior before 3.4), String supports
// both old behavior ('a'..).step(1) and new behavior ('a'..).step('a')
// Hence the additional conversion/addional checks.
const VALUE sb = rb_check_string_type(b);
if (rb_check_arity(argc, 0, 1)) if (rb_check_arity(argc, 0, 1))
step = argv[0]; step = argv[0];
else { else {
if (b_num_p || (NIL_P(b) && e_num_p)) if (b_num_p || !NIL_P(sb) || (NIL_P(b) && e_num_p))
step = INT2FIX(1); step = INT2FIX(1);
else else
rb_raise(rb_eArgError, "step is required for non-numeric ranges"); rb_raise(rb_eArgError, "step is required for non-numeric ranges");
@ -520,8 +561,19 @@ range_step(int argc, VALUE *argv, VALUE range)
} }
else if (b_num_p && step_num_p && ruby_float_step(b, e, step, EXCL(range), TRUE)) { else if (b_num_p && step_num_p && ruby_float_step(b, e, step, EXCL(range), TRUE)) {
/* done */ /* done */
} } else if (!NIL_P(sb) && FIXNUM_P(step)) {
else { // backwards compatibility behavior for String only, when no step/Integer step is passed
// See discussion in https://bugs.ruby-lang.org/issues/18368
VALUE iter[2] = {INT2FIX(1), step};
if (NIL_P(e)) {
rb_str_upto_endless_each(sb, step_i, (VALUE)iter);
}
else {
rb_str_upto_each(sb, e, EXCL(range), step_i, (VALUE)iter);
}
} else {
v = b; v = b;
if (!NIL_P(e)) { if (!NIL_P(e)) {
if (b_num_p && step_num_p && r_less(step, INT2FIX(0)) < 0) { if (b_num_p && step_num_p && r_less(step, INT2FIX(0)) < 0) {

View File

@ -175,21 +175,21 @@ describe "Range#step" do
end end
describe "and String values" do describe "and String values" do
it "yields String values incremented by #succ and less than or equal to end when not passed a step" do
("A".."E").step { |x| ScratchPad << x }
ScratchPad.recorded.should == ["A", "B", "C", "D", "E"]
end
it "yields String values incremented by #succ called Integer step times" do
("A".."G").step(2) { |x| ScratchPad << x }
ScratchPad.recorded.should == ["A", "C", "E", "G"]
end
it "raises a TypeError when passed a Float step" do
-> { ("A".."G").step(2.0) { } }.should raise_error(TypeError)
end
ruby_version_is ""..."3.4" do ruby_version_is ""..."3.4" do
it "yields String values incremented by #succ and less than or equal to end when not passed a step" do
("A".."E").step { |x| ScratchPad << x }
ScratchPad.recorded.should == ["A", "B", "C", "D", "E"]
end
it "yields String values incremented by #succ called Integer step times" do
("A".."G").step(2) { |x| ScratchPad << x }
ScratchPad.recorded.should == ["A", "C", "E", "G"]
end
it "raises a TypeError when passed a Float step" do
-> { ("A".."G").step(2.0) { } }.should raise_error(TypeError)
end
it "calls #succ on begin and each element returned by #succ" do it "calls #succ on begin and each element returned by #succ" do
obj = mock("Range#step String start") obj = mock("Range#step String start")
obj.should_receive(:<=>).exactly(3).times.and_return(-1, -1, -1, 0) obj.should_receive(:<=>).exactly(3).times.and_return(-1, -1, -1, 0)
@ -201,17 +201,13 @@ describe "Range#step" do
end end
ruby_version_is "3.4" do ruby_version_is "3.4" do
it "raises an ArgumentError when not passed a step" do
-> { ("A".."E").step { } }.should raise_error(ArgumentError)
end
it "yields String values adjusted by step and less than or equal to end" do it "yields String values adjusted by step and less than or equal to end" do
("A".."AAA").step("A") { |x| ScratchPad << x } ("A".."AAA").step("A") { |x| ScratchPad << x }
ScratchPad.recorded.should == ["A", "AA", "AAA"] ScratchPad.recorded.should == ["A", "AA", "AAA"]
end end
it "raises a TypeError when passed an incompatible type step" do it "raises a TypeError when passed an incompatible type step" do
-> { ("A".."G").step(2) { } }.should raise_error(TypeError) -> { ("A".."G").step([]) { } }.should raise_error(TypeError)
end end
it "calls #+ on begin and each element returned by #+" do it "calls #+ on begin and each element returned by #+" do
@ -397,17 +393,13 @@ describe "Range#step" do
end end
ruby_version_is "3.4" do ruby_version_is "3.4" do
it "raises an ArgumentError when not passed a step" do
-> { ("A".."E").step { } }.should raise_error(ArgumentError)
end
it "yields String values adjusted by step and less than or equal to end" do it "yields String values adjusted by step and less than or equal to end" do
("A"..."AAA").step("A") { |x| ScratchPad << x } ("A"..."AAA").step("A") { |x| ScratchPad << x }
ScratchPad.recorded.should == ["A", "AA"] ScratchPad.recorded.should == ["A", "AA"]
end end
it "raises a TypeError when passed an incompatible type step" do it "raises a TypeError when passed an incompatible type step" do
-> { ("A".."G").step(2) { } }.should raise_error(TypeError) -> { ("A".."G").step([]) { } }.should raise_error(TypeError)
end end
end end
end end
@ -482,36 +474,30 @@ describe "Range#step" do
end end
describe "and String values" do describe "and String values" do
ruby_version_is ""..."3.4" do it "yields String values incremented by #succ and less than or equal to end when not passed a step" do
it "yields String values incremented by #succ and less than or equal to end when not passed a step" do eval("('A'..)").step { |x| break if x > "D"; ScratchPad << x }
eval("('A'..)").step { |x| break if x > "D"; ScratchPad << x } ScratchPad.recorded.should == ["A", "B", "C", "D"]
ScratchPad.recorded.should == ["A", "B", "C", "D"]
ScratchPad.record [] ScratchPad.record []
eval("('A'...)").step { |x| break if x > "D"; ScratchPad << x } eval("('A'...)").step { |x| break if x > "D"; ScratchPad << x }
ScratchPad.recorded.should == ["A", "B", "C", "D"] ScratchPad.recorded.should == ["A", "B", "C", "D"]
end end
it "yields String values incremented by #succ called Integer step times" do it "yields String values incremented by #succ called Integer step times" do
eval("('A'..)").step(2) { |x| break if x > "F"; ScratchPad << x } eval("('A'..)").step(2) { |x| break if x > "F"; ScratchPad << x }
ScratchPad.recorded.should == ["A", "C", "E"] ScratchPad.recorded.should == ["A", "C", "E"]
ScratchPad.record [] ScratchPad.record []
eval("('A'...)").step(2) { |x| break if x > "F"; ScratchPad << x } eval("('A'...)").step(2) { |x| break if x > "F"; ScratchPad << x }
ScratchPad.recorded.should == ["A", "C", "E"] ScratchPad.recorded.should == ["A", "C", "E"]
end end
it "raises a TypeError when passed a Float step" do it "raises a TypeError when passed a Float step" do
-> { eval("('A'..)").step(2.0) { } }.should raise_error(TypeError) -> { eval("('A'..)").step(2.0) { } }.should raise_error(TypeError)
-> { eval("('A'...)").step(2.0) { } }.should raise_error(TypeError) -> { eval("('A'...)").step(2.0) { } }.should raise_error(TypeError)
end
end end
ruby_version_is "3.4" do ruby_version_is "3.4" do
it "raises an ArgumentError when not passed a step" do
-> { ("A"..).step { } }.should raise_error(ArgumentError)
end
it "yields String values adjusted by step" do it "yields String values adjusted by step" do
eval("('A'..)").step("A") { |x| break if x > "AAA"; ScratchPad << x } eval("('A'..)").step("A") { |x| break if x > "AAA"; ScratchPad << x }
ScratchPad.recorded.should == ["A", "AA", "AAA"] ScratchPad.recorded.should == ["A", "AA", "AAA"]
@ -522,8 +508,8 @@ describe "Range#step" do
end end
it "raises a TypeError when passed an incompatible type step" do it "raises a TypeError when passed an incompatible type step" do
-> { eval("('A'..)").step(2) { } }.should raise_error(TypeError) -> { eval("('A'..)").step([]) { } }.should raise_error(TypeError)
-> { eval("('A'...)").step(2) { } }.should raise_error(TypeError) -> { eval("('A'...)").step([]) { } }.should raise_error(TypeError)
end end
end end
end end

View File

@ -427,11 +427,11 @@ class TestRange < Test::Unit::TestCase
assert_raise(ArgumentError) { (...'aaa').step('a') } assert_raise(ArgumentError) { (...'aaa').step('a') }
# step is not provided # step is not provided
assert_raise(ArgumentError) { ('a'...'aaaa').step } assert_raise(ArgumentError) { (Time.new(2022)...Time.new(2023)).step }
# step is incompatible # step is incompatible
assert_raise(TypeError) { ('a'...'aaaa').step(1) {} } assert_raise(TypeError) { (Time.new(2022)...Time.new(2023)).step('a') {} }
assert_raise(TypeError) { ('a'...'aaaa').step(1).to_a } assert_raise(TypeError) { (Time.new(2022)...Time.new(2023)).step('a').to_a }
# step is compatible, but shouldn't convert into numeric domain: # step is compatible, but shouldn't convert into numeric domain:
a = [] a = []
@ -467,6 +467,58 @@ class TestRange < Test::Unit::TestCase
assert_equal([], a) assert_equal([], a)
end end
def test_step_string_legacy
# finite
a = []
('a'..'g').step(2) { a << _1 }
assert_equal(%w[a c e g], a)
assert_kind_of(Enumerator, ('a'..'g').step(2))
assert_equal(%w[a c e g], ('a'..'g').step(2).to_a)
a = []
('a'...'g').step(2) { a << _1 }
assert_equal(%w[a c e], a)
assert_kind_of(Enumerator, ('a'...'g').step(2))
assert_equal(%w[a c e], ('a'...'g').step(2).to_a)
# endless
a = []
('a'...).step(2) { a << _1; break if a.size == 3 }
assert_equal(%w[a c e], a)
assert_kind_of(Enumerator, ('a'...).step(2))
assert_equal(%w[a c e], ('a'...).step(2).take(3))
# beginless
assert_raise(ArgumentError) { (...'g').step(2) {} }
assert_raise(ArgumentError) { (...'g').step(2) }
# step is not provided
a = []
('a'..'d').step { a << _1 }
assert_equal(%w[a b c d], a)
assert_kind_of(Enumerator, ('a'..'d').step)
assert_equal(%w[a b c d], ('a'..'d').step.to_a)
a = []
('a'...'d').step { a << _1 }
assert_equal(%w[a b c], a)
assert_kind_of(Enumerator, ('a'...'d').step)
assert_equal(%w[a b c], ('a'...'d').step.to_a)
# endless
a = []
('a'...).step { a << _1; break if a.size == 3 }
assert_equal(%w[a b c], a)
assert_kind_of(Enumerator, ('a'...).step)
assert_equal(%w[a b c], ('a'...).step.take(3))
end
def test_step_bug15537 def test_step_bug15537
assert_equal([10.0, 9.0, 8.0, 7.0], (10 ..).step(-1.0).take(4)) assert_equal([10.0, 9.0, 8.0, 7.0], (10 ..).step(-1.0).take(4))
assert_equal([10.0, 9.0, 8.0, 7.0], (10.0 ..).step(-1).take(4)) assert_equal([10.0, 9.0, 8.0, 7.0], (10.0 ..).step(-1).take(4))