From 9a2f2c807a3b9012137e8dc61ffed8501c344bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:12:15 +0000 Subject: [PATCH 1/4] StringIO line readers and the byte side of fresh string literals StringIO (20k-line string, us; before -> after, CRuby 3.3): - gets: 3496 -> 1288 (1646). The bare call has its own overload, so it binds without the optional arguments' conversions; and a one-byte separator is looked for with IndexOf(byte), with no copy of the separator per line. - readlines(chomp: true): 8651 -> ~2300 (1274). Chomp compared the separator with MutableString.EndsWith and sliced a second string. It now compares bytes and cuts the line, a fresh slice, where it is. - lineno no longer counts the nil gets answers at the end (MRI's strio_gets); every read that answers a line still counts, limit or no. Fresh string literals start as a CLR string and switch to bytes the first time something wants bytes, through the encoding's escaping encoder, even when the text is ASCII. "hello world\n".bytesize took ~420 ns (CRuby ~62), and writing a fresh literal to an IO or a StringIO paid that on every call. StringContent now: - finds out whether the data is ASCII (a vectorized scan, cached in the flags) before switching to bytes in GetByteCount and GetByte; - narrows ASCII data in an ASCII-compatible encoding with Ascii.FromUtf16 in DataToBytes, GetDataByteCount and GetDataBytes. Literal bytesize ~420 -> ~200 ns. IO#puts x10k 3386 -> 1871 us (CRuby 2784); StringIO#puts x10k 4168 -> 2740 (2858). spec/core/string (2 errors from mspec's fixnum_max(), as on master), library/stringio, library/stringscanner, core/io, core/file, core/encoding, core/symbol, core/regexp, core/matchdata, core/marshal, core/array/pack, library/zlib and library/digest: no failures. IronRuby.Tests passes, with the new StringIO_LineReaders1, whose expected output is CRuby's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQjtzmBTstQR8oqifXLX6 --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 89 +++++++++++++++++++ Src/Libraries/Initializers.Generated.cs | 3 +- Src/Libraries/StringIO/StringIO.cs | 54 ++++++++--- .../Builtins/MutableString.StringContent.cs | 30 ++++++- 5 files changed, 158 insertions(+), 19 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index d04fb77a5..5407dfee3 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -861,6 +861,7 @@ public Tests(Driver/*!*/ driver) { Io_BuiltinReaders1, Io_LineReaders2, Io_LineReaders3, + StringIO_LineReaders1, Method_LightFrame1, Method_LightFrame2, Hash_SelfKey1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index d17a92833..a8045590b 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2808,6 +2808,95 @@ public void IO_SysOpenPermission1() { "); } + /// + /// StringIO's line readers after the bare gets got an overload of its own, a one-byte + /// separator is looked for as a byte, and chomp: cuts the line where it is; lineno counts + /// the lines read and not the nil at the end. Also the byte side of a fresh literal, which + /// learns it is ASCII rather than being switched to bytes to find out. + /// + public void StringIO_LineReaders1() { + TestOutput(@" +require 'ruby4' +require 'stringio' +def t(label) + r = begin; yield; rescue => e; [e.class, e.message]; end + puts ""#{label}: #{r.inspect}"" +end +src = ""one\r\ntwo\n\n\nthréeéx\nlast"" +[[], [""\n""], [""e""], [""é""], [""éx""], [""""], [nil], [3], [""o"", 2], [""\r\n""]].each do |a| + t(""gets #{a.inspect}"") { s = StringIO.new(src); r = []; while l = s.gets(*a); r << l; end; [r, $_, s.lineno] } + t(""gets chomp #{a.inspect}"") { s = StringIO.new(src); r = []; while l = s.gets(*a, chomp: true); r << l; end; r } + t(""readlines chomp #{a.inspect}"") { StringIO.new(src).readlines(*a, chomp: true) } + t(""each_line chomp #{a.inspect}"") { StringIO.new(src).each_line(*a, chomp: true).to_a } +end +t(""binary sep"") { StringIO.new(""a\xFFb\xFFc"".b).readlines(""\xFF"".b, chomp: true) } +t(""gets bare"") { s = StringIO.new(""x\ny""); [s.gets, s.gets, s.gets, $_] } +t(""frozen line chomp"") { s = StringIO.new(""ab\n"".freeze); l = s.gets(chomp: true); [l, l.frozen?, s.string] } +t(""chomp keeps source"") { str = +""a\nb\n""; s = StringIO.new(str); s.readlines(chomp: true); str } +# literal byte access +[""hello"", ""héllo"", """", ""\u{1F600}x""].each do |lit| + t(""bytes #{lit.inspect}"") { x = lit.dup; [x.bytesize, x.getbyte(0), x.getbyte(1), x.bytes, x.size, x.ascii_only?, x.b, x.byteslice(1, 2)] } +end +t(""iso literal"") { x = ""caf\xE9"".force_encoding(""iso-8859-1""); [x.bytesize, x.getbyte(3), x.bytes] } +t(""utf16"") { x = ""ab"".encode(""UTF-16LE""); [x.bytesize, x.getbyte(0), x.getbyte(1), x.bytes] } +t(""io write literal"") { s = StringIO.new(+""""); s.write(""abc""); s.write(""dé""); [s.string, s.string.bytesize, s.pos] } +t(""string << then bytes"") { x = +""ab""; x << ""c""; [x.bytesize, x.getbyte(2), x.bytes] } +", @" +gets []: [[""one\r\n"", ""two\n"", ""\n"", ""\n"", ""thréeéx\n"", ""last""], nil, 6] +gets chomp []: [""one"", ""two"", """", """", ""thréeéx"", ""last""] +readlines chomp []: [""one"", ""two"", """", """", ""thréeéx"", ""last""] +each_line chomp []: [""one"", ""two"", """", """", ""thréeéx"", ""last""] +gets [""\n""]: [[""one\r\n"", ""two\n"", ""\n"", ""\n"", ""thréeéx\n"", ""last""], nil, 6] +gets chomp [""\n""]: [""one"", ""two"", """", """", ""thréeéx"", ""last""] +readlines chomp [""\n""]: [""one"", ""two"", """", """", ""thréeéx"", ""last""] +each_line chomp [""\n""]: [""one"", ""two"", """", """", ""thréeéx"", ""last""] +gets [""e""]: [[""one"", ""\r\ntwo\n\n\nthrée"", ""éx\nlast""], nil, 3] +gets chomp [""e""]: [""on"", ""\r\ntwo\n\n\nthré"", ""éx\nlast""] +readlines chomp [""e""]: [""on"", ""\r\ntwo\n\n\nthré"", ""éx\nlast""] +each_line chomp [""e""]: [""on"", ""\r\ntwo\n\n\nthré"", ""éx\nlast""] +gets [""é""]: [[""one\r\ntwo\n\n\nthré"", ""eé"", ""x\nlast""], nil, 3] +gets chomp [""é""]: [""one\r\ntwo\n\n\nthr"", ""e"", ""x\nlast""] +readlines chomp [""é""]: [""one\r\ntwo\n\n\nthr"", ""e"", ""x\nlast""] +each_line chomp [""é""]: [""one\r\ntwo\n\n\nthr"", ""e"", ""x\nlast""] +gets [""éx""]: [[""one\r\ntwo\n\n\nthréeéx"", ""\nlast""], nil, 2] +gets chomp [""éx""]: [""one\r\ntwo\n\n\nthrée"", ""\nlast""] +readlines chomp [""éx""]: [""one\r\ntwo\n\n\nthrée"", ""\nlast""] +each_line chomp [""éx""]: [""one\r\ntwo\n\n\nthrée"", ""\nlast""] +gets [""""]: [[""one\r\ntwo\n\n\n"", ""thréeéx\nlast""], nil, 2] +gets chomp [""""]: [""one\r\ntwo"", ""thréeéx\nlast""] +readlines chomp [""""]: [""one\r\ntwo"", ""thréeéx\nlast""] +each_line chomp [""""]: [""one\r\ntwo"", ""thréeéx\nlast""] +gets [nil]: [[""one\r\ntwo\n\n\nthréeéx\nlast""], nil, 1] +gets chomp [nil]: [""one\r\ntwo\n\n\nthréeéx\nlast""] +readlines chomp [nil]: [""one\r\ntwo\n\n\nthréeéx\nlast""] +each_line chomp [nil]: [""one\r\ntwo\n\n\nthréeéx\nlast""] +gets [3]: [[""one"", ""\r\n"", ""two"", ""\n"", ""\n"", ""\n"", ""thr"", ""ée"", ""éx"", ""\n"", ""las"", ""t""], nil, 12] +gets chomp [3]: [""one"", """", ""two"", """", """", """", ""thr"", ""ée"", ""éx"", """", ""las"", ""t""] +readlines chomp [3]: [""one"", """", ""two"", """", """", """", ""thr"", ""ée"", ""éx"", """", ""las"", ""t""] +each_line chomp [3]: [""one"", """", ""two"", """", """", """", ""thr"", ""ée"", ""éx"", """", ""las"", ""t""] +gets [""o"", 2]: [[""o"", ""ne"", ""\r\n"", ""tw"", ""o"", ""\n\n"", ""\nt"", ""hr"", ""é"", ""eé"", ""x\n"", ""la"", ""st""], nil, 13] +gets chomp [""o"", 2]: ["""", ""ne"", ""\r\n"", ""tw"", """", ""\n\n"", ""\nt"", ""hr"", ""é"", ""eé"", ""x\n"", ""la"", ""st""] +readlines chomp [""o"", 2]: ["""", ""ne"", ""\r\n"", ""tw"", """", ""\n\n"", ""\nt"", ""hr"", ""é"", ""eé"", ""x\n"", ""la"", ""st""] +each_line chomp [""o"", 2]: ["""", ""ne"", ""\r\n"", ""tw"", """", ""\n\n"", ""\nt"", ""hr"", ""é"", ""eé"", ""x\n"", ""la"", ""st""] +gets [""\r\n""]: [[""one\r\n"", ""two\n\n\nthréeéx\nlast""], nil, 2] +gets chomp [""\r\n""]: [""one"", ""two\n\n\nthréeéx\nlast""] +readlines chomp [""\r\n""]: [""one"", ""two\n\n\nthréeéx\nlast""] +each_line chomp [""\r\n""]: [""one"", ""two\n\n\nthréeéx\nlast""] +binary sep: [""a"", ""b"", ""c""] +gets bare: [""x\n"", ""y"", nil, nil] +frozen line chomp: [""ab"", false, ""ab\n""] +chomp keeps source: ""a\nb\n"" +bytes ""hello"": [5, 104, 101, [104, 101, 108, 108, 111], 5, true, ""hello"", ""el""] +bytes ""héllo"": [6, 104, 195, [104, 195, 169, 108, 108, 111], 5, false, ""h\xC3\xA9llo"", ""é""] +bytes """": [0, nil, nil, [], 0, true, """", nil] +bytes ""😀x"": [5, 240, 159, [240, 159, 152, 128, 120], 2, false, ""\xF0\x9F\x98\x80x"", ""\x9F\x98""] +iso literal: [4, 233, [99, 97, 102, 233]] +utf16: [4, 97, 0, [97, 0, 98, 0]] +io write literal: [""abcdé"", 6, 6] +string << then bytes: [3, 99, [97, 98, 99]] +"); + } + /// /// Text mode's universal newline decorator - "\r\n" and a lone "\r" are folded before the /// line is split - and the newline options that are decorators for the way out only diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 39415c633..38da140d3 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -13466,7 +13466,8 @@ private static void LoadStringIO_Instance(IronRuby.Builtins.RubyModule/*!*/ modu ); DefineLibraryMethod(module, "gets", 0x11, - 0x00400000U, + 0x00000000U, 0x00400000U, + new Func(IronRuby.StandardLibrary.StringIO.StringIO.Gets), new Func>, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyScope, IronRuby.StandardLibrary.StringIO.StringIO, System.Object, System.Object, System.Collections.Generic.IDictionary, IronRuby.Builtins.MutableString>(IronRuby.StandardLibrary.StringIO.StringIO.Gets) ); diff --git a/Src/Libraries/StringIO/StringIO.cs b/Src/Libraries/StringIO/StringIO.cs index 6db27fc1a..00d7e9f7d 100644 --- a/Src/Libraries/StringIO/StringIO.cs +++ b/Src/Libraries/StringIO/StringIO.cs @@ -1010,9 +1010,17 @@ private static MutableString Chomp(MutableString line, MutableString separator, } int separatorLength = separator.GetByteCount(); - if (separatorLength == 0 || length < separatorLength || !line.EndsWith(separator)) { + if (separatorLength == 0 || length < separatorLength) { return line; } + // The separator is compared as bytes - the line is a slice of the content and its + // separator was found as bytes - and the line, which nothing else holds yet, is cut + // where it is rather than sliced into another string. + for (int i = 0; i < separatorLength; i++) { + if (line.GetByte(length - separatorLength + i) != separator.GetByte(i)) { + return line; + } + } int cut = length - separatorLength; // A line ended by "\n" gives up the "\r" in front of it too - MRI chomps the pair - @@ -1020,22 +1028,17 @@ private static MutableString Chomp(MutableString line, MutableString separator, if (separatorLength == 1 && separator.GetByte(0) == (byte)'\n' && cut > 0 && line.GetByte(cut - 1) == (byte)'\r') { cut--; } - return line.GetSlice(0, cut); + line.SetByteCount(cut); + return line; } + // The bare call - nearly every gets - binds without the optional arguments' conversions. [RubyMethod("gets")] - public static MutableString Gets(ConversionStorage>/*!*/ toHash, - ConversionStorage/*!*/ toStr, ConversionStorage/*!*/ toInt, - RubyScope/*!*/ scope, StringIO/*!*/ self, - [Optional]object separatorOrLimit, [Optional]object limitOrOptions, - [DefaultParameterValue(null), DefaultProtocol]IDictionary options) { - - MutableString separator; - int limit; - bool chomp; - ParseLineArguments(toHash, toStr, toInt, scope.RubyContext, separatorOrLimit, limitOrOptions, options, - out separator, out limit, out chomp); + public static MutableString Gets(RubyScope/*!*/ scope, StringIO/*!*/ self) { + return Gets(scope, self, scope.RubyContext.InputSeparator, -1, false); + } + private static MutableString Gets(RubyScope/*!*/ scope, StringIO/*!*/ self, MutableString separator, int limit, bool chomp) { var content = self.GetReadableContent(); int position = self._position; @@ -1045,11 +1048,30 @@ public static MutableString Gets(ConversionStorage>/ // $_ is frame local, and the frame it belongs in is the caller's - which is why this // is not wrapped in Ruby the way the chomp: option once was. scope.GetInnerMostClosureScope().LastInputLine = result; - self._lineNumber++; + // Every read that answers a line counts, cut short by a limit or not - but the nil at + // the end is not a line (MRI's strio_gets). + if (result != null) { + self._lineNumber++; + } return result; } + [RubyMethod("gets")] + public static MutableString Gets(ConversionStorage>/*!*/ toHash, + ConversionStorage/*!*/ toStr, ConversionStorage/*!*/ toInt, + RubyScope/*!*/ scope, StringIO/*!*/ self, + [Optional]object separatorOrLimit, [Optional]object limitOrOptions, + [DefaultParameterValue(null), DefaultProtocol]IDictionary options) { + + MutableString separator; + int limit; + bool chomp; + ParseLineArguments(toHash, toStr, toInt, scope.RubyContext, separatorOrLimit, limitOrOptions, options, + out separator, out limit, out chomp); + return Gets(scope, self, separator, limit, chomp); + } + [RubyMethod("readline")] public static MutableString/*!*/ ReadLine(ConversionStorage>/*!*/ toHash, ConversionStorage/*!*/ toStr, ConversionStorage/*!*/ toInt, @@ -1136,6 +1158,10 @@ private static MutableString ReadLine(MutableString/*!*/ content, MutableString position++; } } + } else if (separator.GetByteCount() == 1) { + // the usual "\n": one byte to look for, and no copy of the separator to look with + position = content.IndexOf(separator.GetByte(0), oldPosition); + position = (position != -1) ? position + 1 : length; } else { // Positions are byte offsets, so the separator is looked for as bytes too: searching // for it as a string switched a non-ASCII content to characters, giving a character diff --git a/Src/Ruby/Builtins/MutableString.StringContent.cs b/Src/Ruby/Builtins/MutableString.StringContent.cs index ec268a236..8f63435a5 100644 --- a/Src/Ruby/Builtins/MutableString.StringContent.cs +++ b/Src/Ruby/Builtins/MutableString.StringContent.cs @@ -55,15 +55,35 @@ internal string/*!*/ Data { return SwitchToMutable(); } + // ASCII text in an ASCII-compatible encoding is its own bytes. Narrowing it is one + // vectorized pass; the escaping encoder, which has to be ready for characters that are + // not valid, costs a few hundred nanoseconds even on a short literal - and a literal + // written to an IO, or asked for its bytesize, is switched to bytes this way first. + private bool IsAsciiData() { + return _owner._encoding.IsAsciiIdentity && _owner.IsAscii(); + } + internal byte[]/*!*/ DataToBytes() { - return _data.Length > 0 ? _owner._encoding.EscapingEncoding.GetBytes(_data) : Utils.EmptyBytes; + if (_data.Length == 0) { + return Utils.EmptyBytes; + } + if (IsAsciiData()) { + var bytes = new byte[_data.Length]; + System.Text.Ascii.FromUtf16(_data, bytes, out _); + return bytes; + } + return _owner._encoding.EscapingEncoding.GetBytes(_data); } internal int GetDataByteCount() { - return _owner._encoding.EscapingEncoding.GetByteCount(_data); + return IsAsciiData() ? _data.Length : _owner._encoding.EscapingEncoding.GetByteCount(_data); } internal void GetDataBytes(byte[]/*!*/ bytes, int start) { + if (IsAsciiData()) { + System.Text.Ascii.FromUtf16(_data, new Span(bytes, start, _data.Length), out _); + return; + } _owner._encoding.EscapingEncoding.GetBytes(_data, 0, _data.Length, bytes, start); } @@ -100,7 +120,9 @@ public override int GetCharacterCount() { } public override int GetByteCount() { - if (_data.Length == 0 || _owner.HasSingleByteCharacters && !_owner.HasSurrogates()) { + // A fresh string has not been looked at yet; finding out that it is ASCII (or in a + // single-byte encoding) is cheaper than switching it to bytes to count them. + if (_data.Length == 0 || _owner.DetectSingleByteCharacters() && !_owner.HasSurrogates()) { return _data.Length; } return SwitchToBinary().GetByteCount(); @@ -217,7 +239,7 @@ public override char GetChar(int index) { } public override byte GetByte(int index) { - if (index == 0 || _owner.HasSingleByteCharacters && !_owner.HasSurrogates()) { + if (index == 0 || _owner.DetectSingleByteCharacters() && !_owner.HasSurrogates()) { if (index >= _data.Length) { throw new IndexOutOfRangeException(); } From 19c63868a0a0d7c50749828cc666bee5e19ad760 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:28:06 +0000 Subject: [PATCH 2/4] Kernel#Integer/Float exception: false, Array#inject/minmax, zip with a Range Before -> after, CRuby 3.3: - Integer("x", exception: false): 54.8 -> 0.9 us (0.17). The prelude rescued the ArgumentError the C# parser raised. __ir_try_Integer__ and __ir_try_Float__ now answer nil for a String that does not read as a number. An invalid radix still raises, as in MRI; it used to answer nil. - Integer(1.5): 1.5 -> 0.9 us. A finite Float is truncated before the block and its rescue frame. - [1..100].inject(:+): 7.7 -> 2.2 us (0.25). Over a plain Array, inject loops over the list and not through #each with a splatting Proc, and while Integer#+ is the built-in it adds Fixnums in C#, as MRI's ary_inject_op does. - [1..100].minmax: 7.5 -> 0.5 us (0.22). Array#minmax is [min, max], which is what MRI's ary_minmax calls, so a custom <=> sees the same comparisons, and it uses min's and max's Fixnum fast paths. With a block it is Enumerable#minmax. - (1..100).zip(1..100): 1620 -> 164 us (42). An Integer Range whose #each is the built-in is read from the index; going through Enumerator#next cost ~15 us an element. Fixed as well, all already in master: Integer and Float of a String in an ASCII-incompatible encoding raise Encoding::CompatibilityError whether or not exception: false is given (Integer("12" in UTF-16LE) was 12), and Float("1\0") says "string for Float contains null byte". spec/core/kernel, enumerable, range, float, struct, hash and enumerator: no failures. core/array and core/integer: only the errors from mspec's fixnum_max()/fixnum_min() helper, which has no IronRuby case (core/string has the same on master). IronRuby.Tests passes, with the new Kernel_ConversionsInjectZip1, whose expected output is CRuby's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013PQjtzmBTstQR8oqifXLX6 --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 89 +++++++++++++++++++++++++ Src/Libraries/Builtins/Enumerable.cs | 41 ++++++++++++ Src/Libraries/Builtins/KernelOps.cs | 54 +++++++++++++++ Src/Libraries/Extensions/IListOps.cs | 16 +++++ Src/Libraries/Initializers.Generated.cs | 37 ++++++++-- Src/Ruby/Runtime/RubyOps.cs | 2 +- Src/StdLib/ironruby/ruby4.rb | 30 +++++++++ 8 files changed, 265 insertions(+), 5 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 5407dfee3..3ab093ab1 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -862,6 +862,7 @@ public Tests(Driver/*!*/ driver) { Io_LineReaders2, Io_LineReaders3, StringIO_LineReaders1, + Kernel_ConversionsInjectZip1, Method_LightFrame1, Method_LightFrame2, Hash_SelfKey1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index a8045590b..21ed3a702 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -2808,6 +2808,95 @@ public void IO_SysOpenPermission1() { "); } + /// + /// Kernel#Integer and #Float with exception: false answer nil from the C# parser instead of + /// raising and rescuing; a String in an ASCII-incompatible encoding is refused either way, + /// and Float names a NUL. Array#inject over Fixnums, Array#minmax as [min, max], and + /// Enumerable#zip with an Integer Range worked out from the index. + /// + public void Kernel_ConversionsInjectZip1() { + TestOutput(@" +require 'ruby4' +def t(label) + r = begin; yield; rescue => e; [e.class, e.message]; end + puts ""#{label}: #{r.inspect}"" +end +['123', ' 0x1A ', '0b101', '1_000', '1__0', '', ' ', 'x', '12x', ""1\0"", '-0o17', '0d19', '08', '+5', 'ff'].each do |s| + t(""Integer #{s.inspect}"") { [Integer(s, exception: false), Integer(s, 16, exception: false), Float(s, exception: false)] } +end +['1.5', '1e3', '.5', '1_0.5', 'Infinity', 'NaN', '0x1p3', ' 2.5 ', '1e', ""1\0""].each do |s| + t(""Float #{s.inspect}"") { Float(s, exception: false) } + t(""Float! #{s.inspect}"") { Float(s) } +end +t('radix 99') { Integer('1', 99, exception: false) } +u = '12'.encode('UTF-16LE') +t('utf16') { [(Integer(u) rescue $!.class), (Integer(u, exception: false) rescue $!.class), (Float(u, exception: false) rescue $!.class)] } +t('nil') { Integer(nil, exception: false) } +[1.5, -2.9, 1e20, Float::NAN, Float::INFINITY].each { |f| t(""Integer(#{f})"") { [(Integer(f) rescue $!.class), Integer(f, exception: false)] } } +a = [1, 2, 3, 2**62, 2**62] +t('inject +') { [a.inject(:+), a.inject(10, :+), [].inject(:+), [].inject(5, :+), [1, 2.5, 3].inject(:+), ([1, 'a'].inject(:+) rescue $!.class)] } +t('inject sym') { [[2, 3, 4].inject(:*), [1, 2].inject('+'), %w[a b c].inject(:+), (1..4).inject(:+), [3].inject(:+)] } +t('inject block') { [[1, 2, 3].inject { |s, x| s * 10 + x }, [1, 2, 3].inject(100) { |s, x| s - x }, [1, 2, 3].inject { |s, x| break s * 7 if x == 3; s + x }] } +t('inject subclass') { Class.new(Array) { def each; yield 9; end }.new([1, 2]).inject(:+) } +t('minmax') { [[3, 1, 2].minmax, [].minmax, %w[b a].minmax, [1.5, 1, 2**70].minmax, [3, 1, 2].minmax { |x, y| y <=> x }, ([1, nil].minmax rescue $!.class)] } +t('zip') { [(1..5).zip(1..3), (1..5).zip(2...4), (1..4).zip(10..), (1..3).zip(5..1), (1..3).zip(2**64..2**64 + 1), (1..3).zip('a'..'b')] } +t('zip block') { r = []; [(1..3).zip(4..6) { |x| r << x }, r] } +t('zip subclass') { (1..2).zip(Class.new(Range) { def each; yield 42; end }.new(1, 5)) } +", @" +Integer ""123"": [123, 291, 123.0] +Integer "" 0x1A "": [26, 26, 26.0] +Integer ""0b101"": [5, 45313, nil] +Integer ""1_000"": [1000, 4096, 1000.0] +Integer ""1__0"": [nil, nil, nil] +Integer """": [nil, nil, nil] +Integer "" "": [nil, nil, nil] +Integer ""x"": [nil, nil, nil] +Integer ""12x"": [nil, nil, nil] +Integer ""1\u0000"": [nil, nil, nil] +Integer ""-0o17"": [-15, nil, nil] +Integer ""0d19"": [19, 3353, nil] +Integer ""08"": [nil, 8, 8.0] +Integer ""+5"": [5, 5, 5.0] +Integer ""ff"": [nil, 255, nil] +Float ""1.5"": 1.5 +Float! ""1.5"": 1.5 +Float ""1e3"": 1000.0 +Float! ""1e3"": 1000.0 +Float "".5"": 0.5 +Float! "".5"": 0.5 +Float ""1_0.5"": 10.5 +Float! ""1_0.5"": 10.5 +Float ""Infinity"": nil +Float! ""Infinity"": [ArgumentError, ""invalid value for Float(): \""Infinity\""""] +Float ""NaN"": nil +Float! ""NaN"": [ArgumentError, ""invalid value for Float(): \""NaN\""""] +Float ""0x1p3"": 8.0 +Float! ""0x1p3"": 8.0 +Float "" 2.5 "": 2.5 +Float! "" 2.5 "": 2.5 +Float ""1e"": nil +Float! ""1e"": [ArgumentError, ""invalid value for Float(): \""1e\""""] +Float ""1\u0000"": nil +Float! ""1\u0000"": [ArgumentError, ""string for Float contains null byte""] +radix 99: [ArgumentError, ""invalid radix 99""] +utf16: [Encoding::CompatibilityError, Encoding::CompatibilityError, Encoding::CompatibilityError] +nil: nil +Integer(1.5): [1, 1] +Integer(-2.9): [-2, -2] +Integer(1.0e+20): [100000000000000000000, 100000000000000000000] +Integer(NaN): [FloatDomainError, nil] +Integer(Infinity): [FloatDomainError, nil] +inject +: [9223372036854775814, 9223372036854775824, nil, 5, 6.5, TypeError] +inject sym: [24, 3, ""abc"", 10, 3] +inject block: [123, 94, 21] +inject subclass: 9 +minmax: [[1, 3], [nil, nil], [""a"", ""b""], [1, 1180591620717411303424], [3, 1], ArgumentError] +zip: [[[1, 1], [2, 2], [3, 3], [4, nil], [5, nil]], [[1, 2], [2, 3], [3, nil], [4, nil], [5, nil]], [[1, 10], [2, 11], [3, 12], [4, 13]], [[1, nil], [2, nil], [3, nil]], [[1, 18446744073709551616], [2, 18446744073709551617], [3, nil]], [[1, ""a""], [2, ""b""], [3, nil]]] +zip block: [nil, [[1, 4], [2, 5], [3, 6]]] +zip subclass: [[1, 42], [2, nil]] +"); + } + /// /// StringIO's line readers after the bare gets got an overload of its own, a one-byte /// separator is looked for as a byte, and chomp: cuts the line where it is; lineno counts diff --git a/Src/Libraries/Builtins/Enumerable.cs b/Src/Libraries/Builtins/Enumerable.cs index 2f7923663..b63492d71 100644 --- a/Src/Libraries/Builtins/Enumerable.cs +++ b/Src/Libraries/Builtins/Enumerable.cs @@ -591,6 +591,11 @@ internal static object Inject(CallSiteStorage/*!*/ each, BlockParam op operatorName, new RubyCallSignature(1, RubyCallFlags.HasScope | RubyCallFlags.HasImplicitSelf) ); + var list = self as RubyArray; + if (list != null && IsPlainList(each.Context, self)) { + return InjectList(list, operation, scope, operatorName, site, initial); + } + object result = initial; Each(each, self, Proc.Create(each.Context, 0, delegate(BlockParam/*!*/ selfBlock, object _, object[] __, RubyArray/*!*/ args) { Debug.Assert(__.Length == 0); @@ -616,6 +621,42 @@ internal static object Inject(CallSiteStorage/*!*/ each, BlockParam op return result != Missing.Value ? result : null; } + /// + /// #inject over a plain list (see IsPlainList): the elements are taken from the list, + /// with no #each call, Proc or argument array for each one. inject(:+) over Fixnums adds + /// them here while Integer#+ is the built-in, as MRI's ary_inject_op does, and goes back to + /// calling #+ at the first element that is not a Fixnum. + /// + private static object InjectList(RubyArray/*!*/ list, BlockParam operation, RubyScope scope, string operatorName, + CallSite> site, object initial) { + + object result = initial; + int i = 0; + if (result == Missing.Value) { + if (list.Count == 0) { + return null; + } + result = list[0]; + i = 1; + } + + if (site != null && operatorName == "+" && IListOps.IsBuiltinMethod(scope.RubyContext.IntegerClass, "+")) { + for (; i < list.Count && result is int && list[i] is int; i++) { + result = ClrInteger.Add((int)result, (int)list[i]); + } + } + + for (; i < list.Count; i++) { + object value = list[i]; + if (site != null) { + result = site.Target(site, scope, result, value); + } else if (operation.Yield(result, value, out result)) { + return result; + } + } + return result; + } + #endregion #region min, max, minmax diff --git a/Src/Libraries/Builtins/KernelOps.cs b/Src/Libraries/Builtins/KernelOps.cs index 9f36d85a8..7fbd32707 100644 --- a/Src/Libraries/Builtins/KernelOps.cs +++ b/Src/Libraries/Builtins/KernelOps.cs @@ -129,6 +129,25 @@ public static object ToFloat(ConversionStorage/*!*/ floatConversion, obj return Protocols.CastToFloat(floatConversion, value); } + // A String has to be readable as a number at all: in an ASCII-compatible encoding - MRI + // checks that before anything else, and exception: false does not change it - and, for + // Float, without a NUL, which MRI names rather than calling the value invalid. + [RubyMethod("Float", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Float", RubyMethodAttributes.PublicSingleton)] + public static object ToFloat(RubyContext/*!*/ context, object self, [NotNull]MutableString/*!*/ value) { + RequireAsciiCompatible(value); + if (value.IndexOf('\0') >= 0) { + throw RubyExceptions.CreateArgumentError("string for Float contains null byte"); + } + return RubyOps.ConvertMutableStringToFloat(context, value); + } + + private static void RequireAsciiCompatible(MutableString/*!*/ value) { + if (!value.Encoding.IsAsciiIdentity) { + throw new EncodingCompatibilityError("ASCII incompatible encoding: " + value.Encoding.Name); + } + } + [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] public static object/*!*/ ToInteger(object self, [NotNull]MutableString/*!*/ value) { @@ -138,6 +157,7 @@ public static object ToFloat(ConversionStorage/*!*/ floatConversion, obj [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] public static object/*!*/ ToInteger(object self, [NotNull]MutableString/*!*/ value, [DefaultProtocol]int radix) { + RequireAsciiCompatible(value); var str = value.ConvertToString(); // A negative radix means "this is only a hint, a prefix wins"; zero and the @@ -162,6 +182,40 @@ public static object ToFloat(ConversionStorage/*!*/ floatConversion, obj throw InvalidIntegerValue(str); } + // Kernel#Integer and #Float with exception: false (ruby4.rb): a String that does not read + // as a number answers nil here, rather than raising an ArgumentError for the prelude to + // rescue - which cost ~55 us a failure. An invalid radix still raises, as it does in MRI + // whatever exception: says. + + [RubyMethod("__ir_try_Integer__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_try_Integer__", RubyMethodAttributes.PublicSingleton)] + public static object TryToInteger(object self, [NotNull]MutableString/*!*/ value) { + return TryToInteger(self, value, 0); + } + + [RubyMethod("__ir_try_Integer__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_try_Integer__", RubyMethodAttributes.PublicSingleton)] + public static object TryToInteger(object self, [NotNull]MutableString/*!*/ value, [DefaultProtocol]int radix) { + RequireAsciiCompatible(value); + var str = value.ConvertToString(); + if (!HasIntegerDigits(str)) { + return null; + } + if (radix > 0 && (radix < 2 || radix > 36)) { + throw RubyExceptions.CreateArgumentError("invalid radix {0}", radix); + } + object result; + return TryParseRubyInteger(str, radix, out result) ? result : null; + } + + [RubyMethod("__ir_try_Float__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_try_Float__", RubyMethodAttributes.PublicSingleton)] + public static object TryToFloat(object self, [NotNull]MutableString/*!*/ value) { + RequireAsciiCompatible(value); + double result; + return RubyOps.TryParseRubyFloat(value.ConvertToString(), out result) ? (object)result : null; + } + #region Kernel#Integer string grammar // Kernel#Integer is stricter than String#to_i: it consumes the whole string or fails. diff --git a/Src/Libraries/Extensions/IListOps.cs b/Src/Libraries/Extensions/IListOps.cs index b50f101c4..4f31d754d 100644 --- a/Src/Libraries/Extensions/IListOps.cs +++ b/Src/Libraries/Extensions/IListOps.cs @@ -2771,6 +2771,22 @@ public static object GetMaximum(ConversionStorage/*!*/ fixnumCast, Comparis return GetExtremes(fixnumCast, comparisonStorage, comparer, self, count, +1); } + /// + /// Array#minmax is [min, max] - MRI's ary_minmax calls the two, so a custom <=> sees + /// the same comparisons - and gets their Fixnum fast paths. With a block it is + /// Enumerable#minmax, as MRI hands it to super. + /// + [RubyMethod("minmax")] + public static object/*!*/ GetMinMax(CallSiteStorage>/*!*/ each, + ComparisonStorage/*!*/ comparisonStorage, BlockParam comparer, IList/*!*/ self) { + if (comparer != null) { + return Enumerable.GetExtremes(each, comparisonStorage, comparer, self); + } + object min = GetExtreme(comparisonStorage, null, self, -1); + object max = GetExtreme(comparisonStorage, null, self, +1); + return new RubyArray(2) { min, max }; + } + private static bool IsBuiltinIntegerComparison(RubyContext/*!*/ context) { return IsBuiltinMethod(context.IntegerClass, "<=>"); } diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 38da140d3..de003b826 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -4758,6 +4758,17 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module new Func(IronRuby.Builtins.KernelOps.SetHiddenInstanceVariable) ); + DefineLibraryMethod(module, "__ir_try_Float__", 0x52, + 0x00000002U, + new Func(IronRuby.Builtins.KernelOps.TryToFloat) + ); + + DefineLibraryMethod(module, "__ir_try_Integer__", 0x52, + 0x00000002U, 0x00020002U, + new Func(IronRuby.Builtins.KernelOps.TryToInteger), + new Func(IronRuby.Builtins.KernelOps.TryToInteger) + ); + DefineLibraryMethod(module, "__method__", 0x52, 0x00000000U, new Func(IronRuby.Builtins.KernelOps.GetCurrentMethodName) @@ -4913,8 +4924,9 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module ); DefineLibraryMethod(module, "Float", 0x52, - 0x00000000U, - new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat) + 0x00000000U, 0x00000004U, + new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func(IronRuby.Builtins.KernelOps.ToFloat) ); DefineLibraryMethod(module, "fork", 0x52, @@ -5308,6 +5320,17 @@ private static void LoadKernel_Class(IronRuby.Builtins.RubyModule/*!*/ module) { new Func(IronRuby.Builtins.KernelOps.GetCurrentCalleeName) ); + DefineLibraryMethod(module, "__ir_try_Float__", 0x61, + 0x00000002U, + new Func(IronRuby.Builtins.KernelOps.TryToFloat) + ); + + DefineLibraryMethod(module, "__ir_try_Integer__", 0x61, + 0x00000002U, 0x00020002U, + new Func(IronRuby.Builtins.KernelOps.TryToInteger), + new Func(IronRuby.Builtins.KernelOps.TryToInteger) + ); + DefineLibraryMethod(module, "__method__", 0x61, 0x00000000U, new Func(IronRuby.Builtins.KernelOps.GetCurrentMethodName) @@ -5389,8 +5412,9 @@ private static void LoadKernel_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "Float", 0x61, - 0x00000000U, - new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat) + 0x00000000U, 0x00000004U, + new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func(IronRuby.Builtins.KernelOps.ToFloat) ); DefineLibraryMethod(module, "fork", 0x61, @@ -9519,6 +9543,11 @@ private static void LoadSystem__Collections__IList_Instance(IronRuby.Builtins.Ru new Func, IronRuby.Runtime.ComparisonStorage, IronRuby.Runtime.BlockParam, System.Collections.IList, System.Object, System.Object>(IronRuby.Builtins.IListOps.GetMinimum) ); + DefineLibraryMethod(module, "minmax", 0x51, + 0x00000000U, + new Func>, IronRuby.Runtime.ComparisonStorage, IronRuby.Runtime.BlockParam, System.Collections.IList, System.Object>(IronRuby.Builtins.IListOps.GetMinMax) + ); + DefineLibraryMethod(module, "nitems", 0x51, 0x00000000U, new Func(IronRuby.Builtins.IListOps.NumberOfNonNilItems) diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 35b716946..67d8713f7 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -4111,7 +4111,7 @@ private static bool ScanExponent(string/*!*/ str, int end, ref int index, out in return true; } - internal static bool TryParseRubyFloat(string/*!*/ str, out double result) { + public static bool TryParseRubyFloat(string/*!*/ str, out double result) { result = 0.0; int index = 0; diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index f120efccf..f2bcf2fd8 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -786,12 +786,29 @@ def each_slice(n, &block) unless method_defined?(:zip_without_conversion) alias :zip_without_conversion :zip + # An Integer Range whose #each is the built-in one yields begin, begin + 1, ... so the + # element a row wants is worked out from its index. Going through Enumerator#next for + # every element cost ~15 us each: (1..100).zip(1..100) was ~40x MRI. + ZipIntegerRange__ = ::Struct.new(:first, :last) do + def at(index) + value = first + index + last.nil? || value <= last ? value : nil + end + end + RANGE_EACH__ = ::Range.instance_method(:each) + def __zip_source__(other) return other if other.is_a?(Array) if other.respond_to?(:to_ary) converted = other.to_ary return converted if converted.is_a?(Array) end + if other.instance_of?(::Range) && ::Integer === other.begin && + (other.end.nil? || ::Integer === other.end) && ::Range.instance_method(:each) == RANGE_EACH__ + last = other.end + last -= 1 if last && other.exclude_end? + return ZipIntegerRange__.new(other.begin, last) + end unless other.respond_to?(:each) raise TypeError, "wrong argument type #{__ir_message_type_name__(other)} (must respond to :each)" end @@ -808,6 +825,8 @@ def zip(*others, &block) sources.each do |source| row << if source.is_a?(Array) source[index] + elsif ZipIntegerRange__ === source + source.at(index) else begin source.next @@ -3144,6 +3163,13 @@ def Integer(arg, base = nil, exception: true) end # and an Integer is answered as it is, as the block below would return arg if base.nil? && ::Integer === arg && true.equal?(exception) + # a finite Float is truncated, as below, without the block + return arg.to_i if base.nil? && ::Float === arg && true.equal?(exception) && arg.finite? + # exception: false on a String: the C# parser answers nil for what it cannot read, + # where raising and rescuing cost ~55 us a call + if false.equal?(exception) && ::String === arg + return base.nil? ? __ir_try_Integer__(arg) : __ir_try_Integer__(arg, base) + end exception = ::Kernel.__opts_exception__(exception) __numeric_conversion__(exception) do # a base goes with a String, and that is checked before anything else - Integer(nil, 8) @@ -3219,6 +3245,10 @@ def Float(arg, exception: true) return arg if ::Float === arg return __ir_Float__(arg) if ::Integer === arg end + # exception: false on a String, as Integer does above + if false.equal?(exception) && ::String === arg + return __ir_try_Float__(arg) + end exception = ::Kernel.__opts_exception__(exception) __numeric_conversion__(exception) do raise ::TypeError, "can't convert nil into Float" if arg.nil? From fc28c7389a3c5565502028b05a78b8ab16198941 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:09:15 +0000 Subject: [PATCH 3/4] zip: take the indexed Range path only when the Range's own #each is the built-in A Range that is not frozen - (1..3).dup - can have a singleton #each or extend a module with one; MRI's zip goes through it, so the guard now asks for the method the argument resolves, not Range's instance method. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPwZf7MsYwQdhLBCZCkH2C --- Src/IronRuby.Tests/Runtime/MiscTests.cs | 2 ++ Src/StdLib/ironruby/ruby4.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 1beb8d69e..c66442031 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -3181,6 +3181,7 @@ def t(label) t('zip') { [(1..5).zip(1..3), (1..5).zip(2...4), (1..4).zip(10..), (1..3).zip(5..1), (1..3).zip(2**64..2**64 + 1), (1..3).zip('a'..'b')] } t('zip block') { r = []; [(1..3).zip(4..6) { |x| r << x }, r] } t('zip subclass') { (1..2).zip(Class.new(Range) { def each; yield 42; end }.new(1, 5)) } +t('zip singleton') { r = (1..3).dup; def r.each; yield 42; end; (1..2).zip(r) } ", @" Integer ""123"": [123, 291, 123.0] Integer "" 0x1A "": [26, 26, 26.0] @@ -3233,6 +3234,7 @@ def t(label) zip: [[[1, 1], [2, 2], [3, 3], [4, nil], [5, nil]], [[1, 2], [2, 3], [3, nil], [4, nil], [5, nil]], [[1, 10], [2, 11], [3, 12], [4, 13]], [[1, nil], [2, nil], [3, nil]], [[1, 18446744073709551616], [2, 18446744073709551617], [3, nil]], [[1, ""a""], [2, ""b""], [3, nil]]] zip block: [nil, [[1, 4], [2, 5], [3, 6]]] zip subclass: [[1, 42], [2, nil]] +zip singleton: [[1, 42], [2, nil]] "); } diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index caf65ba1e..f00bd2296 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -804,7 +804,7 @@ def __zip_source__(other) return converted if converted.is_a?(Array) end if other.instance_of?(::Range) && ::Integer === other.begin && - (other.end.nil? || ::Integer === other.end) && ::Range.instance_method(:each) == RANGE_EACH__ + (other.end.nil? || ::Integer === other.end) && other.method(:each).unbind == RANGE_EACH__ last = other.end last -= 1 if last && other.exclude_end? return ZipIntegerRange__.new(other.begin, last) From f6145cede8fdfb7f202c6d3624fccc3aa3eb1027 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:26:34 +0000 Subject: [PATCH 4/4] zip: enumerate a Range whose begin, end or exclude_end? is overridden Range#each reads the stored endpoints, so a Range that overrides one of the readers - (1..3).dup can - must not be zipped from the index the readers describe. The method checks also come before the readers are called, as MRI never calls them here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPwZf7MsYwQdhLBCZCkH2C --- Src/IronRuby.Tests/Runtime/MiscTests.cs | 2 ++ Src/StdLib/ironruby/ruby4.rb | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index c66442031..34ad997f6 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -3182,6 +3182,7 @@ def t(label) t('zip block') { r = []; [(1..3).zip(4..6) { |x| r << x }, r] } t('zip subclass') { (1..2).zip(Class.new(Range) { def each; yield 42; end }.new(1, 5)) } t('zip singleton') { r = (1..3).dup; def r.each; yield 42; end; (1..2).zip(r) } +t('zip readers') { [(r = (1..3).dup; def r.begin; 10; end; (0..1).zip(r)), (r = (1..3).dup; def r.end; 1; end; (0..2).zip(r)), (r = (1..3).dup; def r.exclude_end?; true; end; (0..3).zip(r))] } ", @" Integer ""123"": [123, 291, 123.0] Integer "" 0x1A "": [26, 26, 26.0] @@ -3235,6 +3236,7 @@ def t(label) zip block: [nil, [[1, 4], [2, 5], [3, 6]]] zip subclass: [[1, 42], [2, nil]] zip singleton: [[1, 42], [2, nil]] +zip readers: [[[0, 1], [1, 2]], [[0, 1], [1, 2], [2, 3]], [[0, 1], [1, 2], [2, 3], [3, nil]]] "); } diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index f00bd2296..67d024d6a 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -796,6 +796,9 @@ def at(index) end end RANGE_EACH__ = ::Range.instance_method(:each) + # the readers the indexed path asks; Range#each itself uses the stored endpoints, so a + # Range that overrides one of them - (1..3).dup can - has to be enumerated instead + RANGE_READERS__ = [:begin, :end, :exclude_end?].map { |name| [name, ::Range.instance_method(name)] }.freeze def __zip_source__(other) return other if other.is_a?(Array) @@ -803,8 +806,9 @@ def __zip_source__(other) converted = other.to_ary return converted if converted.is_a?(Array) end - if other.instance_of?(::Range) && ::Integer === other.begin && - (other.end.nil? || ::Integer === other.end) && other.method(:each).unbind == RANGE_EACH__ + if other.instance_of?(::Range) && other.method(:each).unbind == RANGE_EACH__ && + RANGE_READERS__.all? { |name, method| other.method(name).unbind == method } && + ::Integer === other.begin && (other.end.nil? || ::Integer === other.end) last = other.end last -= 1 if last && other.exclude_end? return ZipIntegerRange__.new(other.begin, last)