From bc3e3287d50b8372d44c3cf27d937f75b38e76fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:21:42 +0000 Subject: [PATCH 1/4] Kernel#Integer/#Float in C#, Fixnum rounding, Range#first/last, Float#step loop (#89) - Kernel#Integer and #Float are C# entry points (KernelOps.ToInteger/ToFloat). An Integer, a finite Float, a String with or without a radix, and the exception: keyword alone are answered there; everything else goes to the prelude (__ir_Integer_slow__, __ir_Float_slow__). The Ruby wrapper `def Integer(arg, base = nil, exception: true)` mixed an optional parameter with a keyword, which PrismAstBridge lowers to a splat that the method JIT does not take: ~0.35-1 us a call. Integer("42") is now ~75 ns (CRuby ~85), Integer(4.2) ~20 ns (was ~585). A radix given as nil is a TypeError, as in MRI, and a radix that is not a number raises whatever exception: says. - Integer#round/floor/ceil/truncate of a Fixnum with a negative ndigits use long arithmetic instead of BigInteger (167 -> 19 ns for round(-2)). - Range#first and #last without a count are C# (134 -> 10 ns); with a count they go to the prelude (__first_n__, __last_n__). (..5).last(2) is [4, 5] as in MRI, where it raised. - ArithmeticSequence keeps how it was written and builds its inspect text only when asked; Range#% and #step no longer build it on every call. - The Float loop of Numeric#step / Range#step is C# (KernelOps.FloatStep): a method that yields is not one the JIT takes. (1.0..100.0).step(0.5) {} went from ~35 to ~15 us. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPwZf7MsYwQdhLBCZCkH2C --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 77 +++++- Src/Libraries/Builtins/Integer.cs | 34 +++ Src/Libraries/Builtins/KernelOps.cs | 252 +++++++++++++++++- Src/Libraries/Builtins/RangeOps.cs | 36 ++- Src/Libraries/Extensions/NumericRounding.cs | 65 +++++ Src/Libraries/Initializers.Generated.cs | 90 +++++-- Src/StdLib/ironruby/ruby4.rb | 268 +++++++++----------- 8 files changed, 636 insertions(+), 187 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index be37b0546..f3d13c9ec 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -870,6 +870,7 @@ public Tests(Driver/*!*/ driver) { Io_LineReaders3, StringIO_LineReaders1, Kernel_ConversionsInjectZip1, + Kernel_ConversionsRoundingStep1, Method_LightFrame1, Method_LightFrame2, Hash_SelfKey1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 34ad997f6..17300e470 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -3147,6 +3147,81 @@ private static bool CanCreateSymbolicLinks() { } } + /// + /// Kernel#Integer and #Float in C# (the cases they answer themselves and the ones they hand to + /// the prelude), Integer#round/floor/ceil/truncate of a Fixnum in long arithmetic, Range#first + /// and #last without a count, the lazily inspected ArithmeticSequence, and the Float loop of + /// #step in C#, break and return included. The expected output is CRuby's. + /// + public void Kernel_ConversionsRoundingStep1() { + TestOutput(@" +require 'ruby4' +def t(l); r = begin; yield; rescue => e; [e.class, e.message]; end; puts ""#{l}: #{r.inspect}""; end +t('Integer nil radix') { Integer(""10"", nil) } +t('Integer float radix') { [Integer(""10"", 8.9), Integer(""z"", 8.9, exception: false)] } +o = Object.new; def o.to_int; 16; end +t('Integer to_int radix') { Integer(""ff"", o) } +t('Integer radix non-string') { [Integer(12, 16, exception: false), (Integer(12, 16) rescue $!.message)] } +t('Integer nil') { [Integer(nil, exception: false), (Integer(nil) rescue $!.message)] } +t('Integer 3 args') { Integer(""1"", 2, 3) } +t('Integer 4 args') { Integer(""1"", 2, 3, 4) } +t('Float 2 args') { Float(1, 2) } +t('Integer unknown keyword') { Integer(""1"", foo: 1) } +t('Float unknown keyword') { Float(""1"", foo: 1) } +t('Integer braced hash') { Integer(""1"", {exception: false}) } +t('arity') { [method(:Integer).arity, method(:Float).arity] } +t('Integer values') { [Integer(4.7), Integer(-4.7), Integer(1e30), Integer(2**70), Integer(""0x1f""), Integer(""1_000""), Integer("" 42 "")] } +t('Integer failures') { [Integer(Float::NAN, exception: false), (Integer(Float::NAN) rescue $!.class), Integer(""zz"", exception: false), Integer(""zz"", 36)] } +t('Float values') { [Float(1), Float(2**70), Float(""1.5""), Float(""x"", exception: false), Float(nil, exception: false), (Float(nil) rescue $!.class), Float(""1e3""), (Float("""") rescue $!.class)] } +f = 1.5 +t('Float identity') { Float(f).equal?(f) } +t('Kernel.') { [Kernel.Integer(""7""), Kernel.Float(""1"")] } +t('bad exception') { Integer(""1"", exception: 1) } +t('true radix') { Integer(""1"", true) } +t('utf16') { Integer(""12"".encode(""UTF-16LE""), exception: false) } +t('to_i') { r = Object.new; def r.to_i; 3; end; [Integer(r), Integer(Rational(7, 2)), Float(Rational(7, 2))] } +t('round') { [12345.round(-2), 12350.round(-2), -12350.round(-2), 12350.round(-2, half: :even), 12350.round(-2, half: :down), 25.round(-1, half: :even), 2147483647.round(-9), -2147483648.floor(-9), 5.ceil(-18), 5.ceil(-19), -5.floor(-19), 5.round(-25), 12345.truncate(-3), -12345.truncate(-3), 7.round(2)] } +t('first/last') { [(1..10).first, (1...10).last, (1..10).first(3), (1..10).last(3), (..5).last(2), (...5).last(3), ('a'..'e').last(2)] } +t('beginless first') { (..5).first } +t('endless last') { (1..).last } +t('step inspect') { r = (1..10); [(r % 3).inspect, r.step(3).inspect, r.step.inspect, 1.step(10, 3).inspect, 1.step(by: 2, to: 5).inspect, 1.0.step(2.0, 0.5).inspect, (r % 3).to_a] } +t('float step') { a = []; 1.0.step(2.0, 0.3) { |x| a << x }; 2.0.step(1.0, -0.5) { |x| a << x }; (1.0...2.0).step(0.5) { |x| a << x }; a } +t('float step break') { [1.0.step(2.0, 0.5) { |x| break x * 10 if x > 1.2 }, 1.0.step(2.0, 0.5) { }, (1.0..2.0).step(0.5).map { |x| x * 2 }] } +def float_step_return; 1.0.step(5.0, 1.0) { |x| return x if x == 3.0 }; :no; end +t('float step return') { float_step_return } +", @" +Integer nil radix: [TypeError, ""no implicit conversion from nil to integer""] +Integer float radix: [8, nil] +Integer to_int radix: 255 +Integer radix non-string: [nil, ""base specified for non string value""] +Integer nil: [nil, ""can't convert nil into Integer""] +Integer 3 args: [ArgumentError, ""wrong number of arguments (given 3, expected 1..2)""] +Integer 4 args: [ArgumentError, ""wrong number of arguments (given 4, expected 1..2)""] +Float 2 args: [ArgumentError, ""wrong number of arguments (given 2, expected 1)""] +Integer unknown keyword: [ArgumentError, ""unknown keyword: :foo""] +Float unknown keyword: [ArgumentError, ""unknown keyword: :foo""] +Integer braced hash: [TypeError, ""no implicit conversion of Hash into Integer""] +arity: [-2, -2] +Integer values: [4, -4, 1000000000000000019884624838656, 1180591620717411303424, 31, 1000, 42] +Integer failures: [nil, FloatDomainError, nil, 1295] +Float values: [1.0, 1.1805916207174113e+21, 1.5, nil, nil, TypeError, 1000.0, ArgumentError] +Float identity: true +Kernel.: [7, 1.0] +bad exception: [ArgumentError, ""expected true or false as exception: 1""] +true radix: [TypeError, ""no implicit conversion of true into Integer""] +utf16: [Encoding::CompatibilityError, ""ASCII incompatible encoding: UTF-16LE""] +to_i: [3, 3, 3.5] +round: [12300, 12400, -12400, 12400, 12300, 20, 2000000000, -3000000000, 1000000000000000000, 10000000000000000000, -10000000000000000000, 0, 12000, -12000, 7] +first/last: [1, 10, [1, 2, 3], [8, 9, 10], [4, 5], [2, 3, 4], [""d"", ""e""]] +beginless first: [RangeError, ""cannot get the first element of beginless range""] +endless last: [RangeError, ""cannot get the last element of endless range""] +step inspect: [""((1..10).%(3))"", ""((1..10).step(3))"", ""((1..10).step)"", ""(1.step(10, 3))"", ""(1.step(by: 2, to: 5))"", ""(1.0.step(2.0, 0.5))"", [1, 4, 7, 10]] +float step: [1.0, 1.3, 1.6, 1.9, 2.0, 1.5, 1.0, 1.0, 1.5] +float step break: [15.0, 1.0, [2.0, 3.0, 4.0]] +float step return: 3.0 +"); + } + /// /// 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, @@ -3584,7 +3659,7 @@ public void Range_IntegerFastPaths1() { first 1..3.5 2: [1, 2] last 1..3.5 2: [2, 3] first ..5 2: ""RangeError: cannot get the first element of beginless range"" -last ..5 2: ""TypeError: can't iterate from NilClass"" +last ..5 2: [4, 5] sub first: [:x] sub last: [9, 10] big last: [999999998, 999999999, 1000000000] diff --git a/Src/Libraries/Builtins/Integer.cs b/Src/Libraries/Builtins/Integer.cs index 1c7b24d3b..2d1845d65 100644 --- a/Src/Libraries/Builtins/Integer.cs +++ b/Src/Libraries/Builtins/Integer.cs @@ -121,6 +121,36 @@ public static object ToInteger(object/*!*/ self) { return self; } + // A Fixnum and an Integer ndigits: long arithmetic (NumericRounding.TryRoundFixnum). + + [RubyMethod("round")] + public static object Round(int self, int ndigits) { + object result; + return NumericRounding.TryRoundFixnum(self, ndigits, NumericRounding.IntegerRounding.Round, NumericRounding.Half.Up, out result) + ? result : NumericRounding.RoundInteger(new BigInteger(self), ndigits, NumericRounding.Half.Up); + } + + [RubyMethod("floor")] + public static object Floor(int self, int ndigits) { + object result; + return NumericRounding.TryRoundFixnum(self, ndigits, NumericRounding.IntegerRounding.Floor, NumericRounding.Half.Up, out result) + ? result : NumericRounding.FloorInteger(new BigInteger(self), ndigits); + } + + [RubyMethod("ceil")] + public static object Ceil(int self, int ndigits) { + object result; + return NumericRounding.TryRoundFixnum(self, ndigits, NumericRounding.IntegerRounding.Ceil, NumericRounding.Half.Up, out result) + ? result : NumericRounding.CeilInteger(new BigInteger(self), ndigits); + } + + [RubyMethod("truncate")] + public static object Truncate(int self, int ndigits) { + object result; + return NumericRounding.TryRoundFixnum(self, ndigits, NumericRounding.IntegerRounding.Truncate, NumericRounding.Half.Up, out result) + ? result : NumericRounding.TruncateInteger(new BigInteger(self), ndigits); + } + /// /// Rounds self to a multiple of 10**(-ndigits). A non-negative ndigits returns self. /// Accepts the half: option (:up, :down or :even). @@ -143,6 +173,10 @@ public static object Round(ConversionStorage/*!*/ integerCast, obj if (nd >= 0) { return self; } + object fixnumResult; + if (self is int && NumericRounding.TryRoundFixnum((int)self, nd, NumericRounding.IntegerRounding.Round, half, out fixnumResult)) { + return fixnumResult; + } return NumericRounding.RoundInteger(ToBigInteger(self), nd, half); } diff --git a/Src/Libraries/Builtins/KernelOps.cs b/Src/Libraries/Builtins/KernelOps.cs index b0bada6c5..2170bc61e 100644 --- a/Src/Libraries/Builtins/KernelOps.cs +++ b/Src/Libraries/Builtins/KernelOps.cs @@ -114,14 +114,246 @@ public static object InitializeClone( return result; } + #region Kernel#Integer, Kernel#Float + + // Kernel#Integer(arg, base = nil, exception: true) and #Float(arg, exception: true). The + // cases a program spends its time in - an Integer, a finite Float, a String with or without + // a radix, the exception: keyword alone - are answered here. Everything else goes to the + // prelude (__ir_Integer_slow__, __ir_Float_slow__ in ruby4.rb), which knows the coercion + // protocol and the errors. A Ruby wrapper with an optional parameter and a keyword cost + // ~0.5 us a call: such a signature is lowered to a splat, which the JIT does not take. + + private static readonly object NoException = new object(); + + // The exception: keyword of a trailing keyword hash: true or false, or NoException when the + // hash has anything else in it (another key, a value that is not true or false), which the + // prelude reports. Null when the argument is not a keyword hash at all. + private static object ExceptionOption(RubyContext/*!*/ context, object options) { + var hash = options as Hash; + if (hash == null || !hash.IsKeywordArguments) { + return null; + } + if (hash.Count == 0) { + return ScriptingRuntimeHelpers.True; + } + object value; + if (hash.Count == 1 && hash.TryGetValue(context.CreateAsciiSymbol("exception"), out value) && value is bool) { + return value; + } + return NoException; + } + + // ruby4.rb is not loaded (an embedding that does without it, a test): the conversion + // protocol of the C# library is all there is. + private static bool HasPrelude(RubyContext/*!*/ context, string/*!*/ name) { + return context.KernelModule.ResolveMethod(name, VisibilityContext.AllVisible).Found; + } + + private static Exception/*!*/ ConversionArity(int given, string/*!*/ expected) { + return RubyExceptions.CreateArgumentError("wrong number of arguments (given {0}, expected {1})", given, expected); + } + + [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + public static object ToInteger(CallSiteStorage>/*!*/ slow, + ConversionStorage/*!*/ integerConversion, object self, object value) { + object result; + if (TryToIntegerFast(self, value, null, true, out result)) { + return result; + } + if (!HasPrelude(integerConversion.Context, "__ir_Integer_slow__")) { + return ToInteger(integerConversion, self, value); + } + var site = slow.GetCallSite("__ir_Integer_slow__", 2); + return site.Target(site, self, value, null); + } + + [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + public static object ToInteger(CallSiteStorage>/*!*/ slow, + CallSiteStorage>/*!*/ slowWithOptions, + RubyContext/*!*/ context, object self, object value, object radixOrOptions) { + + object result; + object exception = ExceptionOption(context, radixOrOptions); + if (exception == null) { + // Integer(value, radix) + RequireRadix(radixOrOptions); + if (TryToIntegerFast(self, value, radixOrOptions, true, out result)) { + return result; + } + var site = slow.GetCallSite("__ir_Integer_slow__", 2); + return site.Target(site, self, value, radixOrOptions); + } + if (exception != NoException && TryToIntegerFast(self, value, null, (bool)exception, out result)) { + return result; + } + var optionsSite = slowWithOptions.GetCallSite("__ir_Integer_slow__", 3); + return optionsSite.Target(optionsSite, self, value, null, radixOrOptions); + } + + [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + public static object ToInteger(CallSiteStorage>/*!*/ slowWithOptions, + RubyContext/*!*/ context, object self, object value, object radix, object options) { + + object exception = ExceptionOption(context, options); + if (exception == null) { + throw ConversionArity(3, "1..2"); + } + RequireRadix(radix); + object result; + if (exception != NoException && TryToIntegerFast(self, value, radix, (bool)exception, out result)) { + return result; + } + var site = slowWithOptions.GetCallSite("__ir_Integer_slow__", 3); + return site.Target(site, self, value, radix, options); + } + + // A radix given as nil is not a radix left out (MRI's NUM2INT), whatever exception: says. + private static void RequireRadix(object radix) { + if (radix == null) { + throw RubyExceptions.CreateTypeError("no implicit conversion from nil to integer"); + } + } + + // Any other number of arguments: the error MRI words, which the binder would not (it + // counts the keyword hash as one more parameter). + [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + public static object ToInteger(object self, object value, object radix, object options, object extra, params object[]/*!*/ more) { + throw ConversionArity(4 + more.Length, "1..2"); + } + + private static bool TryToIntegerFast(object self, object value, object radix, bool exception, out object result) { + if (radix == null) { + if (value is int || value is BigInteger) { + result = value; + return true; + } + if (value is double) { + double d = (double)value; + if (Double.IsNaN(d) || Double.IsInfinity(d)) { + // FloatDomainError, which the prelude raises - or nil + result = null; + return !exception; + } + result = ClrFloat.ToInt(d); + return true; + } + } + var str = value as MutableString; + if (str != null && (radix == null || radix is int)) { + int r = (radix == null) ? 0 : (int)radix; + result = exception ? ToInteger(self, str, r) : TryToInteger(self, str, r); + return true; + } + result = null; + return false; + } + + [RubyMethod("Float", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Float", RubyMethodAttributes.PublicSingleton)] + public static object ToFloat(CallSiteStorage>/*!*/ slow, + ConversionStorage/*!*/ floatConversion, RubyContext/*!*/ context, object self, object value) { + + object result; + if (TryToFloatFast(context, self, value, true, out result)) { + return result; + } + if (!HasPrelude(context, "__ir_Float_slow__")) { + return ToFloat(floatConversion, self, value); + } + var site = slow.GetCallSite("__ir_Float_slow__", 1); + return site.Target(site, self, value); + } + + [RubyMethod("Float", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Float", RubyMethodAttributes.PublicSingleton)] + public static object ToFloat(CallSiteStorage>/*!*/ slowWithOptions, + RubyContext/*!*/ context, object self, object value, object options) { + + object exception = ExceptionOption(context, options); + if (exception == null) { + throw ConversionArity(2, "1"); + } + object result; + if (exception != NoException && TryToFloatFast(context, self, value, (bool)exception, out result)) { + return result; + } + var site = slowWithOptions.GetCallSite("__ir_Float_slow__", 2); + return site.Target(site, self, value, options); + } + + [RubyMethod("Float", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("Float", RubyMethodAttributes.PublicSingleton)] + public static object ToFloat(object self, object value, object options, object extra, params object[]/*!*/ more) { + throw ConversionArity(3 + more.Length, "1"); + } + + private static bool TryToFloatFast(RubyContext/*!*/ context, object self, object value, bool exception, out object result) { + if (value is double) { + // the very same object, see below + result = value; + return true; + } + if (value is int) { + result = (double)(int)value; + return true; + } + var str = value as MutableString; + if (str != null) { + result = exception ? ToFloat(context, self, str) : TryToFloat(self, str); + return true; + } + result = null; + return false; + } + + #endregion + + // The Float loop of Numeric#step and Range#step (ArithmeticSequence.__step_each__ in + // ruby4.rb, after MRI's ruby_float_step): n values beg, beg + step, ..., the last one held + // to stop. A method that yields is not one the method JIT takes, so the loop cost ~0.25 us + // a value in Ruby. + [RubyMethod("__ir_float_step__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_float_step__", RubyMethodAttributes.PublicSingleton)] + public static object FloatStep(BlockParam/*!*/ block, object self, double beg, double stop, double step, double n) { + object result; + if (Double.IsInfinity(step)) { + if (n > 0 && block.Yield(beg, out result)) { + return result; + } + } else if (step == 0) { + while (true) { + if (block.Yield(beg, out result)) { + return result; + } + } + } else { + // n may be NaN (Infinity.step(Infinity, 1)); 0 < NaN is false, so nothing is + // yielded, which is what MRI's for(;i= 0 ? stop < d : d < stop) { + d = stop; + } + if (block.Yield(d, out result)) { + return result; + } + } + } + return null; + } + /// /// A Float given to Float() comes back as the very same object. Taking it as a double and /// handing it back boxes it afresh, and #equal? on a boxed double compares the boxes - so /// Float(f).equal?(f) was false, including for the NaN and the Infinity that have no /// other way of being told apart from any other NaN or Infinity. /// - [RubyMethod("Float", RubyMethodAttributes.PrivateInstance)] - [RubyMethod("Float", RubyMethodAttributes.PublicSingleton)] + [RubyMethod("__ir_Float__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_Float__", RubyMethodAttributes.PublicSingleton)] public static object ToFloat(ConversionStorage/*!*/ floatConversion, object self, object value) { if (value is double) { return value; @@ -132,8 +364,8 @@ public static object ToFloat(ConversionStorage/*!*/ floatConversion, obj // 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)] + [RubyMethod("__ir_Float__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_Float__", RubyMethodAttributes.PublicSingleton)] public static object ToFloat(RubyContext/*!*/ context, object self, [NotNull]MutableString/*!*/ value) { RequireAsciiCompatible(value); if (value.IndexOf('\0') >= 0) { @@ -148,14 +380,14 @@ private static void RequireAsciiCompatible(MutableString/*!*/ value) { } } - [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] - [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + [RubyMethod("__ir_Integer__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_Integer__", RubyMethodAttributes.PublicSingleton)] public static object/*!*/ ToInteger(object self, [NotNull]MutableString/*!*/ value) { return ToInteger(self, value, 0); } - [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] - [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + [RubyMethod("__ir_Integer__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_Integer__", RubyMethodAttributes.PublicSingleton)] public static object/*!*/ ToInteger(object self, [NotNull]MutableString/*!*/ value, [DefaultProtocol]int radix) { RequireAsciiCompatible(value); var str = value.ConvertToString(); @@ -370,8 +602,8 @@ private static bool TryParseRubyInteger(string/*!*/ str, int requestedRadix, out #endregion - [RubyMethod("Integer", RubyMethodAttributes.PrivateInstance)] - [RubyMethod("Integer", RubyMethodAttributes.PublicSingleton)] + [RubyMethod("__ir_Integer__", RubyMethodAttributes.PrivateInstance)] + [RubyMethod("__ir_Integer__", RubyMethodAttributes.PublicSingleton)] public static object/*!*/ ToInteger(ConversionStorage/*!*/ integerConversion, object self, object value) { var integer = Protocols.ConvertToInteger(integerConversion, value); return integer.IsFixnum ? ScriptingRuntimeHelpers.Int32ToObject(integer.Fixnum) : integer.Bignum; diff --git a/Src/Libraries/Builtins/RangeOps.cs b/Src/Libraries/Builtins/RangeOps.cs index d9b57e519..943f92f8a 100644 --- a/Src/Libraries/Builtins/RangeOps.cs +++ b/Src/Libraries/Builtins/RangeOps.cs @@ -98,15 +98,47 @@ public static object Reinitialize(BinaryOpStorage/*!*/ comparisonStorage, /// /// Returns the first object in self /// - [RubyMethod("begin"), RubyMethod("first")] + [RubyMethod("begin")] public static object Begin([NotNull]Range/*!*/ self) { return self.Begin; } + // #first and #last without a count answer the endpoint; with one, ruby4.rb counts + // (__first_n__, __last_n__). They were a Ruby `def first(*args)', whose rest parameter + // put every call on the slow path: (1..100).first cost ~0.2 us. + + [RubyMethod("first")] + public static object First([NotNull]Range/*!*/ self) { + if (self.Begin == null) { + throw RubyExceptions.CreateRangeError("cannot get the first element of beginless range"); + } + return self.Begin; + } + + [RubyMethod("first")] + public static object First(CallSiteStorage>/*!*/ firstN, [NotNull]Range/*!*/ self, object count) { + var site = firstN.GetCallSite("__first_n__", 1); + return site.Target(site, self, count); + } + + [RubyMethod("last")] + public static object Last([NotNull]Range/*!*/ self) { + if (self.End == null) { + throw RubyExceptions.CreateRangeError("cannot get the last element of endless range"); + } + return self.End; + } + + [RubyMethod("last")] + public static object Last(CallSiteStorage>/*!*/ lastN, [NotNull]Range/*!*/ self, object count) { + var site = lastN.GetCallSite("__last_n__", 1); + return site.Target(site, self, count); + } + /// /// Returns the object that defines the end of self /// - [RubyMethod("end"), RubyMethod("last")] + [RubyMethod("end")] public static object End([NotNull]Range/*!*/ self) { return self.End; } diff --git a/Src/Libraries/Extensions/NumericRounding.cs b/Src/Libraries/Extensions/NumericRounding.cs index 76696bb1b..f50d47857 100644 --- a/Src/Libraries/Extensions/NumericRounding.cs +++ b/Src/Libraries/Extensions/NumericRounding.cs @@ -367,6 +367,71 @@ private static bool RoundsAwayEntirely(BigInteger value, int ndigits) { return -(long)ndigits > digits; } + // ---- a Fixnum and a negative ndigits, in long arithmetic ------------------------------- + // + // What RoundInteger and its siblings answer, without a BigInteger in sight: 12345.round(-2) + // cost ~0.5 us, nearly all of it BigInteger arithmetic on numbers that fit in a long. A + // power of ten past 10**18 does not fit, and the BigInteger versions take that. + + internal enum IntegerRounding { Round, Floor, Ceil, Truncate } + + private static readonly long[] _powersOf10 = BuildPowersOf10(); + + private static long[] BuildPowersOf10() { + var result = new long[19]; + long p = 1; + for (int i = 0; i < result.Length; i++) { + result[i] = p; + p *= 10; + } + return result; + } + + internal static bool TryRoundFixnum(int value, int ndigits, IntegerRounding kind, Half mode, out object result) { + if (ndigits >= 0) { + result = ScriptingRuntimeHelpers.Int32ToObject(value); + return true; + } + if (ndigits < -18) { + result = null; + return false; + } + long f = _powersOf10[-ndigits]; + long v = value; + long r = v % f; // the sign of v, as C#'s % has it + long down = v - r; // towards zero + long rounded; + switch (kind) { + case IntegerRounding.Truncate: + rounded = down; + break; + case IntegerRounding.Floor: + rounded = (r < 0) ? down - f : down; + break; + case IntegerRounding.Ceil: + rounded = (r > 0) ? down + f : down; + break; + default: + long twice = 2 * Math.Abs(r); + bool away; + if (twice != f) { + away = twice > f; + } else { + switch (mode) { + case Half.Down: away = false; break; + case Half.Even: away = ((down / f) & 1) != 0; break; + default: away = true; break; + } + } + rounded = away ? (v < 0 ? down - f : down + f) : down; + break; + } + result = (rounded >= Int32.MinValue && rounded <= Int32.MaxValue) + ? ScriptingRuntimeHelpers.Int32ToObject((int)rounded) + : (object)new BigInteger(rounded); + return true; + } + private static BigInteger Pow10(int exponent) { return BigInteger.Pow(10, exponent); } diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index fe84aab70..5a8573ec4 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -3107,8 +3107,9 @@ private static void LoadInteger_Instance(IronRuby.Builtins.RubyModule/*!*/ modul ); DefineLibraryMethod(module, "ceil", 0x51, - 0x00000000U, 0x00000000U, + 0x00000000U, 0x00000000U, 0x00000000U, new Func(IronRuby.Builtins.Integer.ToInteger), + new Func(IronRuby.Builtins.Integer.Ceil), new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.Integer.Ceil) ); @@ -3145,8 +3146,9 @@ private static void LoadInteger_Instance(IronRuby.Builtins.RubyModule/*!*/ modul ); DefineLibraryMethod(module, "floor", 0x51, - 0x00000000U, 0x00000000U, + 0x00000000U, 0x00000000U, 0x00000000U, new Func(IronRuby.Builtins.Integer.ToInteger), + new Func(IronRuby.Builtins.Integer.Floor), new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.Integer.Floor) ); @@ -3229,8 +3231,9 @@ private static void LoadInteger_Instance(IronRuby.Builtins.RubyModule/*!*/ modul ); DefineLibraryMethod(module, "round", 0x51, - 0x00000000U, 0x00000000U, + 0x00000000U, 0x00000000U, 0x00000000U, new Func(IronRuby.Builtins.Integer.ToInteger), + new Func(IronRuby.Builtins.Integer.Round), new Func, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.Integer.Round) ); @@ -3268,8 +3271,9 @@ private static void LoadInteger_Instance(IronRuby.Builtins.RubyModule/*!*/ modul ); DefineLibraryMethod(module, "truncate", 0x51, - 0x00000000U, 0x00000000U, + 0x00000000U, 0x00000000U, 0x00000000U, new Func(IronRuby.Builtins.Integer.ToInteger), + new Func(IronRuby.Builtins.Integer.Truncate), new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.Integer.Truncate) ); @@ -4733,6 +4737,17 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module new Func>, IronRuby.Runtime.CallSiteStorage>, System.Object, System.Boolean, System.Object>(IronRuby.Builtins.KernelOps.CloneWithFreeze) ); + DefineLibraryMethod(module, "__ir_Float__", 0x52, + 0x00000000U, 0x00000004U, + new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func(IronRuby.Builtins.KernelOps.ToFloat) + ); + + DefineLibraryMethod(module, "__ir_float_step__", 0x52, + 0x00000000U, + new Func(IronRuby.Builtins.KernelOps.FloatStep) + ); + DefineLibraryMethod(module, "__ir_hidden_ivar__", 0x52, 0x00000004U, new Func(IronRuby.Builtins.KernelOps.GetHiddenInstanceVariable) @@ -4743,6 +4758,13 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module new Func(IronRuby.Builtins.KernelOps.InspectForDisplay) ); + DefineLibraryMethod(module, "__ir_Integer__", 0x52, + 0x00000002U, 0x00020002U, 0x00000000U, + new Func(IronRuby.Builtins.KernelOps.ToInteger), + new Func(IronRuby.Builtins.KernelOps.ToInteger), + new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger) + ); + DefineLibraryMethod(module, "__ir_lazy_grep__", 0x52, 0x00000000U, new Func>, IronRuby.Runtime.RubyScope, IronRuby.Runtime.BlockParam, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.LazyGrep) @@ -4929,9 +4951,10 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module ); DefineLibraryMethod(module, "Float", 0x52, - 0x00000000U, 0x00000004U, - new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), - new Func(IronRuby.Builtins.KernelOps.ToFloat) + 0x00000000U, 0x00000000U, 0x80000000U, + new Func>, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func>, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func(IronRuby.Builtins.KernelOps.ToFloat) ); DefineLibraryMethod(module, "fork", 0x52, @@ -5023,10 +5046,11 @@ private static void LoadKernel_Instance(IronRuby.Builtins.RubyModule/*!*/ module ); DefineLibraryMethod(module, "Integer", 0x52, - 0x00000002U, 0x00020002U, 0x00000000U, - new Func(IronRuby.Builtins.KernelOps.ToInteger), - new Func(IronRuby.Builtins.KernelOps.ToInteger), - new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger) + 0x00000000U, 0x00000000U, 0x00000000U, 0x80000000U, + new Func>, IronRuby.Runtime.ConversionStorage, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger), + new Func>, IronRuby.Runtime.CallSiteStorage>, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger), + new Func>, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger), + new Func(IronRuby.Builtins.KernelOps.ToInteger) ); DefineLibraryMethod(module, "is_a?", 0x51, @@ -5325,6 +5349,24 @@ private static void LoadKernel_Class(IronRuby.Builtins.RubyModule/*!*/ module) { new Func(IronRuby.Builtins.KernelOps.GetCurrentCalleeName) ); + DefineLibraryMethod(module, "__ir_Float__", 0x61, + 0x00000000U, 0x00000004U, + new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func(IronRuby.Builtins.KernelOps.ToFloat) + ); + + DefineLibraryMethod(module, "__ir_float_step__", 0x61, + 0x00000000U, + new Func(IronRuby.Builtins.KernelOps.FloatStep) + ); + + DefineLibraryMethod(module, "__ir_Integer__", 0x61, + 0x00000002U, 0x00020002U, 0x00000000U, + new Func(IronRuby.Builtins.KernelOps.ToInteger), + new Func(IronRuby.Builtins.KernelOps.ToInteger), + new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger) + ); + DefineLibraryMethod(module, "__ir_try_Float__", 0x61, 0x00000002U, new Func(IronRuby.Builtins.KernelOps.TryToFloat) @@ -5417,9 +5459,10 @@ private static void LoadKernel_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "Float", 0x61, - 0x00000000U, 0x00000004U, - new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), - new Func(IronRuby.Builtins.KernelOps.ToFloat) + 0x00000000U, 0x00000000U, 0x80000000U, + new Func>, IronRuby.Runtime.ConversionStorage, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func>, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToFloat), + new Func(IronRuby.Builtins.KernelOps.ToFloat) ); DefineLibraryMethod(module, "fork", 0x61, @@ -5449,10 +5492,11 @@ private static void LoadKernel_Class(IronRuby.Builtins.RubyModule/*!*/ module) { ); DefineLibraryMethod(module, "Integer", 0x61, - 0x00000002U, 0x00020002U, 0x00000000U, - new Func(IronRuby.Builtins.KernelOps.ToInteger), - new Func(IronRuby.Builtins.KernelOps.ToInteger), - new Func, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger) + 0x00000000U, 0x00000000U, 0x00000000U, 0x80000000U, + new Func>, IronRuby.Runtime.ConversionStorage, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger), + new Func>, IronRuby.Runtime.CallSiteStorage>, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger), + new Func>, IronRuby.Runtime.RubyContext, System.Object, System.Object, System.Object, System.Object, System.Object>(IronRuby.Builtins.KernelOps.ToInteger), + new Func(IronRuby.Builtins.KernelOps.ToInteger) ); DefineLibraryMethod(module, "iterator?", 0x61, @@ -7639,8 +7683,9 @@ private static void LoadRange_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "first", 0x51, - 0x00000001U, - new Func(IronRuby.Builtins.RangeOps.Begin) + 0x00000001U, 0x00000002U, + new Func(IronRuby.Builtins.RangeOps.First), + new Func>, IronRuby.Builtins.Range, System.Object, System.Object>(IronRuby.Builtins.RangeOps.First) ); DefineLibraryMethod(module, "hash", 0x51, @@ -7669,8 +7714,9 @@ private static void LoadRange_Instance(IronRuby.Builtins.RubyModule/*!*/ module) ); DefineLibraryMethod(module, "last", 0x51, - 0x00000001U, - new Func(IronRuby.Builtins.RangeOps.End) + 0x00000001U, 0x00000002U, + new Func(IronRuby.Builtins.RangeOps.Last), + new Func>, IronRuby.Builtins.Range, System.Object, System.Object>(IronRuby.Builtins.RangeOps.Last) ); DefineLibraryMethod(module, "member?", 0x51, diff --git a/Src/StdLib/ironruby/ruby4.rb b/Src/StdLib/ironruby/ruby4.rb index 67d024d6a..cb4bc851c 100644 --- a/Src/StdLib/ironruby/ruby4.rb +++ b/Src/StdLib/ironruby/ruby4.rb @@ -933,20 +933,13 @@ def __reverse_each_size__ end private :__reverse_each_size__ - # The built-in #first/#last only answer the no-argument form, and because they - # are defined on Range they hide Enumerable#first(n) rather than falling - # through to it - so `(0..Float::INFINITY).first(3)` was an ArgumentError. - unless method_defined?(:first_without_count) - alias :first_without_count :first - alias :last_without_count :last - - def first(*args) - if args.empty? - raise RangeError, "cannot get the first element of beginless range" if self.begin.nil? - return first_without_count - end + # #first(n) and #last(n): the C# #first and #last (RangeOps) answer the form without a + # count and hand the count here - an Enumerable#first(n) they would otherwise hide, so + # `(0..Float::INFINITY).first(3)` was an ArgumentError. + unless private_method_defined?(:__first_n__) + def __first_n__(count) raise RangeError, "cannot get the first element of beginless range" if self.begin.nil? - n = __to_count__(args[0]) + n = __to_count__(count) raise ArgumentError, "negative array size (or size too big)" if n < 0 b = self.begin e = self.end @@ -969,23 +962,20 @@ def first(*args) result end - def last(*args) - if args.empty? - raise RangeError, "cannot get the last element of endless range" if self.end.nil? - return last_without_count - end + def __last_n__(count) raise RangeError, "cannot get the last element of endless range" if self.end.nil? - n = __to_count__(args[0]) + n = __to_count__(count) raise ArgumentError, "negative array size" if n < 0 b = self.begin e = self.end # MRI's rb_int_range_last: the last n of an Integer range, without building all of it # first (to_a.last(n) made (1..10**6).last(3) a million-element Array). MRI takes this # path for a subclass too, whatever its #each. - if ::Integer === b && ::Integer === e + # (..5).last(2) too: a beginless Integer range counts down from its end. + if (b.nil? || ::Integer === b) && ::Integer === e top = exclude_end? ? e - 1 : e first_value = top - n + 1 - first_value = b if first_value < b + first_value = b if !b.nil? && first_value < b return __int_run__(first_value, top) end to_a.last(n) @@ -1004,6 +994,7 @@ def __int_run__(from, to) result end private :__int_run__ + private :__first_n__, :__last_n__ # #first and #last take a count, not an arbitrary object: anything that is # not an Integer has to offer #to_int and have it answer one. @@ -3147,130 +3138,102 @@ def __opts_exception__(value) private :__opts_exception__ module_function :__opts_exception__ - # Kernel#Integer and #Float: the C# side parses the string grammar (and, since - # this branch, an explicit radix); the arity, the exception: keyword and the - # coercion protocol are easier to get exactly right here. + # Kernel#Integer and #Float are C# (KernelOps.ToInteger, ToFloat): an Integer, a finite + # Float, a String with or without a radix, and the exception: keyword alone are answered + # there. Everything else comes here - the coercion protocol, the errors, exception: false + # around an object's own conversion - by way of these two, which the C# side calls with + # the base as given (nil when there was none) and the keywords as given. # # MRI's order for Integer(): an Integer or Float is converted directly, a # String is parsed, and anything else is asked for #to_int, then #to_i, with # #to_str consulted first when the object offers one. - if private_method_defined?(:Integer) || method_defined?(:Integer) - alias :__ir_Integer__ :Integer - private :__ir_Integer__ - - def Integer(arg, base = nil, exception: true) - # The common case - a String, no base, failures raised - is what the block below - # does first anyway; going straight there skips the block and its rescue frame, - # which cost many times the parse itself. - if base.nil? && true.equal?(exception) && !arg.nil? && arg.is_a?(::String) - return __ir_Integer__(arg) - 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) - # is this error, not the nil one - if base && !arg.is_a?(::String) && !arg.respond_to?(:to_str) - raise ::ArgumentError, "base specified for non string value" - end + def __ir_Integer_slow__(arg, base, exception: true) + exception = ::Kernel.__opts_exception__(exception) + # The radix is converted first, and outside the rescue: exception: false does not + # turn a radix that is not a number into nil (MRI's NUM2INT in rb_f_integer). + unless base.nil? || ::Integer === base + radix = base.respond_to?(:to_int) ? base.to_int : nil + unless ::Integer === radix + raise ::TypeError, "no implicit conversion of #{true.equal?(base) || false.equal?(base) ? base : base.class} into Integer" + end + base = radix + end + __numeric_conversion__(exception) do + # a base goes with a String, and that is checked before anything else - Integer(nil, 8) + # is this error, not the nil one + if base && !arg.is_a?(::String) && !arg.respond_to?(:to_str) + raise ::ArgumentError, "base specified for non string value" + end - if arg.nil? - raise ::TypeError, "can't convert nil into Integer" - end + if arg.nil? + raise ::TypeError, "can't convert nil into Integer" + end - if arg.is_a?(::String) - __ir_check_numeric_string__(arg) - next base ? __ir_Integer__(arg, base) : __ir_Integer__(arg) - end + if arg.is_a?(::String) + __ir_check_numeric_string__(arg) + next base ? __ir_Integer__(arg, base) : __ir_Integer__(arg) + end - if arg.is_a?(::Integer) - next arg - end + if arg.is_a?(::Integer) + next arg + end - if arg.is_a?(::Float) - if arg.nan? || arg.infinite? - raise ::FloatDomainError, arg.to_s - end - next arg.to_i + if arg.is_a?(::Float) + if arg.nan? || arg.infinite? + raise ::FloatDomainError, arg.to_s end + next arg.to_i + end - # #to_int first, then #to_str, then #to_i; a non-Integer from #to_int is - # not an error by itself, it just falls through to the next candidate. - if arg.respond_to?(:to_int) - value = arg.to_int - next value if value.is_a?(::Integer) - end + # #to_int first, then #to_str, then #to_i; a non-Integer from #to_int is + # not an error by itself, it just falls through to the next candidate. + if arg.respond_to?(:to_int) + value = arg.to_int + next value if value.is_a?(::Integer) + end - if arg.respond_to?(:to_str) - text = arg.to_str - unless text.is_a?(::String) - raise ::TypeError, "can't convert #{arg.class} into Integer" - end - __ir_check_numeric_string__(text) - next base ? __ir_Integer__(text, base) : __ir_Integer__(text) + if arg.respond_to?(:to_str) + text = arg.to_str + unless text.is_a?(::String) + raise ::TypeError, "can't convert #{arg.class} into Integer" end + __ir_check_numeric_string__(text) + next base ? __ir_Integer__(text, base) : __ir_Integer__(text) + end - unless arg.respond_to?(:to_i) - raise ::TypeError, "can't convert #{__conversion_class_name__(arg)} into Integer" - end + unless arg.respond_to?(:to_i) + raise ::TypeError, "can't convert #{__conversion_class_name__(arg)} into Integer" + end - value = arg.to_i - unless value.is_a?(::Integer) - raise ::TypeError, - "can't convert #{__conversion_class_name__(arg)} into Integer " \ - "(#{arg.class}#to_i gives #{__ir_conversion_result_name__(value)})" - end - value + value = arg.to_i + unless value.is_a?(::Integer) + raise ::TypeError, + "can't convert #{__conversion_class_name__(arg)} into Integer " \ + "(#{arg.class}#to_i gives #{__ir_conversion_result_name__(value)})" end + value end - module_function :Integer - end - - if private_method_defined?(:Float) || method_defined?(:Float) - alias :__ir_Float__ :Float - private :__ir_Float__ - - def Float(arg, exception: true) - # A String with failures raised goes to the C# parser below in any case; see Integer. - if true.equal?(exception) && !arg.nil? && ::String === arg - return __ir_Float__(arg) - end - # A Float is its own answer and an Integer is the C# conversion, as below, without the - # block, its rescue frame or the class tests. - if true.equal?(exception) - 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? - __ir_check_numeric_string__(arg, "Float") if ::String === arg - if ::Kernel === arg && !(::Integer === arg || ::Float === arg || ::Rational === arg || - ::String === arg || true.equal?(arg) || false.equal?(arg)) && arg.respond_to?(:to_f, true) - # MRI hands back the very Float #to_f returned (the same NaN, say), so the - # result must not go through the C# conversion, which reboxes it. - result = arg.__send__(:to_f) - return result if ::Float === result - name = arg.class.to_s - raise ::TypeError, "can't convert #{name} to Float (#{name}#to_f gives #{result.class})" - end - __ir_Float__(arg) + end + module_function :__ir_Integer_slow__ + + def __ir_Float_slow__(arg, exception: true) + exception = ::Kernel.__opts_exception__(exception) + __numeric_conversion__(exception) do + raise ::TypeError, "can't convert nil into Float" if arg.nil? + __ir_check_numeric_string__(arg, "Float") if ::String === arg + if ::Kernel === arg && !(::Integer === arg || ::Float === arg || ::Rational === arg || + ::String === arg || true.equal?(arg) || false.equal?(arg)) && arg.respond_to?(:to_f, true) + # MRI hands back the very Float #to_f returned (the same NaN, say), so the + # result must not go through the C# conversion, which reboxes it. + result = arg.__send__(:to_f) + return result if ::Float === result + name = arg.class.to_s + raise ::TypeError, "can't convert #{name} to Float (#{name}#to_f gives #{result.class})" end + __ir_Float__(arg) end - module_function :Float end + module_function :__ir_Float_slow__ # A String has to be readable as a number at all: ASCII-compatible, no NUL. def __ir_check_numeric_string__(text, what = "Integer") @@ -4409,9 +4372,7 @@ def step(*args, **kw, &block) unit = 1 if unit.nil? ::Kernel.raise(::ArgumentError, "step can't be 0") if unit == 0 if (to.nil? || to.is_a?(::Numeric)) && unit.is_a?(::Numeric) - shown = args.empty? && (kw.nil? || kw.empty?) ? "" : "(#{(args.map(&:inspect) + (kw || {}).map { |k, v| "#{k}: #{v.inspect}" }).join(', ')})" - return ::Enumerator::ArithmeticSequence.__build__( - self, to, unit, false, self, "(#{inspect}.step#{shown})") + return ::Enumerator::ArithmeticSequence.__build__(self, to, unit, false, self, [:num, args, kw]) end recv = self e = ::Enumerator.new { |y| recv.step(*args, **kw) { |v| y << v } } @@ -7375,21 +7336,8 @@ def self.__step_each__(from, to, unit, excl, &block) stop = fin.to_f step = unit.to_f n = __float_step_size__(beg, stop, step, excl) - if step.infinite? - block.call(beg) if n > 0 - elsif step == 0 - loop { block.call(beg) } - else - i = 0 - # n may be NaN (Infinity.step(Infinity, 1)); 0 < NaN is false, so - # nothing is yielded, which is what MRI's for(;i= 0 ? stop < d : d < stop - block.call(d) - i += 1 - end - end + # the loop is C# (KernelOps.FloatStep) + ::Kernel.__ir_float_step__(beg, stop, step, n.to_f, &block) return end @@ -7397,7 +7345,7 @@ def self.__step_each__(from, to, unit, excl, &block) cmp = desc ? :< : :> loop do break if excl ? (i == fin || i.__send__(cmp, fin)) : i.__send__(cmp, fin) - block.call(i) + yield(i) i += unit end end @@ -7492,9 +7440,27 @@ def hash [self.begin, self.end, step, exclude_end?].hash end + # @inspect_str is the text, or how the sequence was written - worked out only when asked + # for, since building it was a good part of what making a sequence cost def inspect - return @inspect_str if @inspect_str - "((#{@source.inspect}).%(#{@step.inspect}))" + form = @inspect_str + case form + when ::String then form + when :step then "((#{@source.inspect}).step)" + when ::Array + case form[0] + when :step then "((#{@source.inspect}).step(#{form[1].inspect}))" + when :% then "((#{@source.inspect}).%(#{form[1].inspect}))" + else + # Numeric#step: [:num, args, kw] + args = form[1] + kw = form[2] + shown = args.empty? && (kw.nil? || kw.empty?) ? "" : "(#{(args.map(&:inspect) + (kw || {}).map { |k, v| "#{k}: #{v.inspect}" }).join(', ')})" + "(#{@source.inspect}.step#{shown})" + end + else + "((#{@source.inspect}).%(#{@step.inspect}))" + end end alias :to_s :inspect end @@ -12797,8 +12763,7 @@ def step(*args, &block) if block ::Kernel.raise(::ArgumentError, "#step iteration for beginless ranges is meaningless") end - written = n.nil? ? "((#{inspect}).step)" : "((#{inspect}).step(#{n.inspect}))" - return ::Enumerator::ArithmeticSequence.__build__(b, e, unit, exclude_end?, self, written) + return ::Enumerator::ArithmeticSequence.__build__(b, e, unit, exclude_end?, self, n.nil? ? :step : [:step, n]) end ::Kernel.raise(::ArgumentError, "step can't be 0") if numeric && unit == 0 @@ -12806,8 +12771,7 @@ def step(*args, &block) unless block if numeric # inspected the way it was written: ((1..10).step) or ((1..10).step(2)) - written = n.nil? ? "((#{inspect}).step)" : "((#{inspect}).step(#{n.inspect}))" - return ::Enumerator::ArithmeticSequence.__build__(b, e, unit, exclude_end?, self, written) + return ::Enumerator::ArithmeticSequence.__build__(b, e, unit, exclude_end?, self, n.nil? ? :step : [:step, n]) end range = self return ::Enumerator.new { |y| range.step(*args) { |x| y << x } }.__set_inspect__(self, :step, args) @@ -12831,7 +12795,7 @@ def step(*args, &block) def %(n) seq = step(n) - seq.instance_variable_set(:@inspect_str, "((#{inspect}).%(#{n.inspect}))") if seq.is_a?(::Enumerator::ArithmeticSequence) + seq.instance_variable_set(:@inspect_str, [:%, n]) if seq.is_a?(::Enumerator::ArithmeticSequence) seq end From bf21517245cd9f496ea67efc2bdab70bace08b84 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:45:51 +0000 Subject: [PATCH 2/4] Optional positionals before keywords: no splat, and the method JIT takes them (#89) `def m(a, b = 2, k: 3)` was lowered to `def m(*?args?)` plus a prologue that took the splat apart, and the method JIT does not take a splat: every call went through the generic body, ~150 ns where `def m(a, k: 3)` costs ~20. Now: - PrismAstBridge keeps the optionals as optional parameters and lowers the keywords after them, as it already did after mandatory ones (Parameters.KeywordsAfterOptionals). A keyword call that leaves an optional out passes its keyword hash where that optional is; the body moves it to the keyword parameter before any default runs (Parameters.TransformOptionalsInitialization). - The method JIT recognizes such a method when the optional defaults are literals (JitKeywords.OptionalPositionals): the entry and the profiler move the hash the same way (JitKeywords.ShiftKeywords), the separation check looks at the last mandatory parameter, and the direct keyword entry is not built for them. f(1) on `def f(a, b = nil, exception: true)` went from ~145 to ~18 ns. - The arity error of a call to any method with keywords is worded from the declared signature (RubyOps.MakeKeywordMethodArityError): the hidden keyword parameter is not counted, nor is the keyword hash, and required keywords are named, as in MRI - `def f(a, k: 1); f` said "expected 1..2". - Method#parameters lists required keywords before optional ones, as MRI does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPwZf7MsYwQdhLBCZCkH2C --- Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 109 ++++++++++++++++++ Src/Prism/PrismAstBridge.cs | 73 ++++++++---- Src/Ruby/Compiler/Ast/Parameters.cs | 61 ++++++++++ .../Compiler/ReflectionCache.Generated.cs | 2 + Src/Ruby/Runtime/Calls/RubyMethodInfo.cs | 16 +++ Src/Ruby/Runtime/Jit/JitCompiler.cs | 14 ++- Src/Ruby/Runtime/Jit/JitKeywords.cs | 47 +++++++- Src/Ruby/Runtime/Jit/JitStub.cs | 48 ++++++-- Src/Ruby/Runtime/RubyOps.cs | 20 ++++ 10 files changed, 354 insertions(+), 37 deletions(-) diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index f3d13c9ec..25b27970f 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -871,6 +871,7 @@ public Tests(Driver/*!*/ driver) { StringIO_LineReaders1, Kernel_ConversionsInjectZip1, Kernel_ConversionsRoundingStep1, + Method_OptionalAndKeywordParameters1, Method_LightFrame1, Method_LightFrame2, Hash_SelfKey1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 17300e470..3b3fb50c5 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -3147,6 +3147,115 @@ private static bool CanCreateSymbolicLinks() { } } + /// + /// A method with optional positional parameters and keywords (`def m(a, b = 2, k: 3)') keeps its + /// optionals as optional parameters; a keyword call that leaves one out has its keyword hash moved + /// into place before the defaults run, in the generic body and in the JIT's entry (the loop at the + /// end runs the methods specialized). Arity errors name the positionals and required keywords as + /// MRI does, and #parameters lists required keywords first. The expected output is CRuby's. + /// + public void Method_OptionalAndKeywordParameters1() { + TestOutput(@" +require 'ruby4' +def t(l); r = begin; yield; rescue => e; [e.class, e.message]; end; puts ""#{l}: #{r.inspect}""; end +def m1(a, b = 2, k: 3) = [a, b, k] +def m2(a, b = 2, c = a + b, k: 3, j:) = [a, b, c, k, j] +def m3(a, b = {x: 1}, **kw) = [a, b, kw] +def m4(b = 1, k: 2) = [b, k] +def m5(a, b = nil, exception: true) = [a, b, exception] +t(1) { [m1(1), m1(1, 5), m1(1, k: 9), m1(1, 5, k: 9)] } +t(2) { m1(1, {k: 9}) } +t(3) { m1(1, 5, {k: 9}) } +t(4) { m1(1, 5, 6) } +t(5) { m1(k: 1) } +t(6) { m1() } +t(7) { m1(1, z: 2) } +t(8) { [m2(1, j: 0), m2(1, 4, j: 0), m2(1, 4, 5, j: 0, k: 7)] } +t(9) { m2(1, 4) } +t(10) { m2(1, 4, 5, 6, j: 0) } +t(11) { [m3(1), m3(1, z: 2), m3(1, {y: 2}), m3(1, {y: 2}, z: 3), m3(1, **{})] } +t(12) { h = {k: 7}; [m1(1, **h), m1(1, 2, **h), m4, m4(5), m4(k: 6), m4(5, k: 6), m4({k: 6})] } +t(13) { [m5(1), m5(""1"", 16), m5(1, exception: false), m5(1, 2, exception: false)] } +t(14) { [method(:m1).arity, method(:m2).arity, method(:m3).arity, method(:m4).arity, method(:m1).parameters, method(:m2).parameters] } +class P; def s(a, b = 2, k: 3) = [:p, a, b, k]; end +class C < P; def s(a, b = 5, k: 4); super; end; end +class D < P; def s(a, b = 5, k: 4); b = 9; k = 8; super; end; end +t(15) { [C.new.s(1), C.new.s(1, 2), C.new.s(1, k: 0), D.new.s(1)] } +def nil_kw(a, b = 1, k: 2) = [a, b, k] +t(16) { [nil_kw(1, nil), nil_kw(nil), nil_kw(1, nil, k: nil)] } +t(18) { send(:m1, 1, k: 4) } +t(19) { method(:m1).call(1, k: 4) } +t(20) { pr = method(:m1).to_proc; [pr.(1), pr.(1, k: 2)] } +o = Object.new +def o.each_pair(x = 1, sep: "",""); ""#{x}#{sep}""; end +t(21) { [o.each_pair, o.each_pair(2), o.each_pair(sep: "";""), o.each_pair(3, sep: ""|"")] } +def opt_hash_default(a, b = {}, k: 1) = [a, b, k] +t(22) { [opt_hash_default(1), opt_hash_default(1, {z: 1}), opt_hash_default(1, k: 2)] } +1000.times { m1(1, k: 2); m1(1); m1(1, 2) } +t(23) { [m1(1), m1(1, 5), m1(1, k: 9), m1(1, 5, k: 9)] } +# every case again after the JIT has specialized the methods +def n1(a, b = 2, k: 3) = [a, b, k] +def n2(a, b = 2, c = 7, k: 3, j:) = [a, b, c, k, j] +def n4(b = 1, k: 2) = [b, k] +def m6(a, b = 1.5, k: :x) = [a, b, k] +def mi(a, b = 2, k: 3) = a + b + k +r = nil +3000.times do |i| + r = [n1(1), n1(1, 5), n1(1, k: 9), n1(1, 5, k: 9), n2(1, j: 0), n2(1, 4, j: 0), n2(1, 4, 5, j: 0, k: 7), + m4, n4(5), n4(k: 6), n4(5, k: 6), n4({k: 6}), m6(1), m6(1, 2.5), m6(1, k: :y), mi(1), mi(1, 2, k: 4), mi(i, k: i)] +end +p r +t(1) { n1(k: 1) } +t(2) { n1(1, {k: 9}) } +t(3) { n1(1, 5, 6) } +t(4) { n1(1, z: 2) } +t(5) { n2(1, 4) } +t(6) { mi(1, ""x"") } +t(7) { mi(""a"", k: 1) } +t(8) { n1({k: 1}) } +t(9) { n4({k: 6}, k: 1) } +def kwreq_order(a, k: 1, j:) = 1 +t(24) { method(:kwreq_order).parameters } +def mand_only(a, k: 1) = 1 +t(25) { [(mand_only rescue $!.message), (mand_only(1, 2) rescue $!.message)] } +", @" +1: [[1, 2, 3], [1, 5, 3], [1, 2, 9], [1, 5, 9]] +2: [1, {k: 9}, 3] +3: [ArgumentError, ""wrong number of arguments (given 3, expected 1..2)""] +4: [ArgumentError, ""wrong number of arguments (given 3, expected 1..2)""] +5: [ArgumentError, ""wrong number of arguments (given 0, expected 1..2)""] +6: [ArgumentError, ""wrong number of arguments (given 0, expected 1..2)""] +7: [ArgumentError, ""unknown keyword: :z""] +8: [[1, 2, 3, 3, 0], [1, 4, 5, 3, 0], [1, 4, 5, 7, 0]] +9: [ArgumentError, ""missing keyword: :j""] +10: [ArgumentError, ""wrong number of arguments (given 4, expected 1..3; required keyword: j)""] +11: [[1, {x: 1}, {}], [1, {x: 1}, {z: 2}], [1, {y: 2}, {}], [1, {y: 2}, {z: 3}], [1, {x: 1}, {}]] +12: [[1, 2, 7], [1, 2, 7], [1, 2], [5, 2], [1, 6], [5, 6], [{k: 6}, 2]] +13: [[1, nil, true], [""1"", 16, true], [1, nil, false], [1, 2, false]] +14: [-2, -3, -2, -1, [[:req, :a], [:opt, :b], [:key, :k]], [[:req, :a], [:opt, :b], [:opt, :c], [:keyreq, :j], [:key, :k]]] +15: [[:p, 1, 5, 4], [:p, 1, 2, 4], [:p, 1, 5, 0], [:p, 1, 9, 8]] +16: [[1, nil, 2], [nil, 1, 2], [1, nil, nil]] +18: [1, 2, 4] +19: [1, 2, 4] +20: [[1, 2, 3], [1, 2, 2]] +21: [""1,"", ""2,"", ""1;"", ""3|""] +22: [[1, {}, 1], [1, {z: 1}, 1], [1, {}, 2]] +23: [[1, 2, 3], [1, 5, 3], [1, 2, 9], [1, 5, 9]] +[[1, 2, 3], [1, 5, 3], [1, 2, 9], [1, 5, 9], [1, 2, 7, 3, 0], [1, 4, 7, 3, 0], [1, 4, 5, 7, 0], [1, 2], [5, 2], [1, 6], [5, 6], [{k: 6}, 2], [1, 1.5, :x], [1, 2.5, :x], [1, 1.5, :y], 6, 7, 6000] +1: [ArgumentError, ""wrong number of arguments (given 0, expected 1..2)""] +2: [1, {k: 9}, 3] +3: [ArgumentError, ""wrong number of arguments (given 3, expected 1..2)""] +4: [ArgumentError, ""unknown keyword: :z""] +5: [ArgumentError, ""missing keyword: :j""] +6: [TypeError, ""String can't be coerced into Integer""] +7: [TypeError, ""no implicit conversion of Integer into String""] +8: [{k: 1}, 2, 3] +9: [{k: 6}, 1] +24: [[:req, :a], [:keyreq, :j], [:key, :k]] +25: [""wrong number of arguments (given 0, expected 1)"", ""wrong number of arguments (given 2, expected 1)""] +"); + } + /// /// Kernel#Integer and #Float in C# (the cases they answer themselves and the ones they hand to /// the prelude), Integer#round/floor/ceil/truncate of a Fixnum in long arithmetic, Range#first diff --git a/Src/Prism/PrismAstBridge.cs b/Src/Prism/PrismAstBridge.cs index 690a2a672..3cd5adcc5 100644 --- a/Src/Prism/PrismAstBridge.cs +++ b/Src/Prism/PrismAstBridge.cs @@ -2236,16 +2236,18 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List (post as Pm.RequiredParameterNode)?.Name)); } + // MRI lists the required keywords first, whatever order they were declared in: + // `def m(k: 1, j:)' has [[:keyreq, :j], [:key, :k]]. int requiredKeywordCount = 0; foreach (var keyword in node.Keywords) { - switch (keyword) { - case Pm.RequiredKeywordParameterNode required: - parameters.Add(new RubyParameterSignature.Parameter("keyreq", required.Name)); - requiredKeywordCount++; - break; - case Pm.OptionalKeywordParameterNode optional: - parameters.Add(new RubyParameterSignature.Parameter("key", optional.Name)); - break; + if (keyword is Pm.RequiredKeywordParameterNode required) { + parameters.Add(new RubyParameterSignature.Parameter("keyreq", required.Name)); + requiredKeywordCount++; + } + } + foreach (var keyword in node.Keywords) { + if (keyword is Pm.OptionalKeywordParameterNode optional) { + parameters.Add(new RubyParameterSignature.Parameter("key", optional.Name)); } } @@ -2330,23 +2332,43 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List } } + bool keywordsAfterOptionals = false; + // keyword arguments: with only mandatory positionals a trailing optional hash // is equivalent, so use that (it keeps arity and Method#parameters sane). // Anything richer needs positional binding done by hand, because keywords do // not have their own calling-convention slot here. if (node.Keywords.Length > 0 || node.KeywordRest is Pm.KeywordRestParameterNode) { + // A method with optional positionals and nothing else past them keeps them as + // optional parameters, the keywords lowered after them as below: the keyword hash + // of a call that leaves an optional out is moved into place before the defaults run + // (Parameters.KeywordsAfterOptionals). The general lowering puts everything in one + // splat, which no call can bind without allocating, and the method JIT does not take. + // // A block's keyword parameters must not count towards the auto-splat decision: // `{ |a, **k| }` takes one positional parameter, so a single Array argument is // passed whole. The general lowering is the one that knows that, since it binds // the positionals itself. - if (optional.Count > 0 || unsplat != null || node.Posts.Length > 0 || autoSplat) { + if (isMethod && optional.Count > 0 && unsplat == null && node.Posts.Length == 0 && !autoSplat) { + int optionalPositionals = optional.Count; + // `super' passes the positionals by name: the mandatory ones are locals (a + // destructured one a hidden local), and so are the optional ones + var positionals = new List(mandatory); + foreach (var opt in optional) { + positionals.Add((Expression)opt.Left); + } + prologue = LowerKeywords(node, optional, true, mandatory, isMethod, span, optionalPositionals); + _zsuperArguments = ZSuperArguments(node, positionals); + keywordsAfterOptionals = true; + } else if (optional.Count > 0 || unsplat != null || node.Posts.Length > 0 || autoSplat) { return LowerGeneralParameters(node, autoSplat, isMethod, span, out prologue); - } - // a lambda checks arity as strictly as a method does; a plain block does not - prologue = LowerKeywords(node, optional, isMethod || !autoSplat, mandatory, isMethod, span); - if (isMethod) { - // only leading mandatory parameters can be here, and they are all locals - _zsuperArguments = ZSuperArguments(node, new List(mandatory)); + } else { + // a lambda checks arity as strictly as a method does; a plain block does not + prologue = LowerKeywords(node, optional, isMethod || !autoSplat, mandatory, isMethod, span, 0); + if (isMethod) { + // only leading mandatory parameters can be here, and they are all locals + _zsuperArguments = ZSuperArguments(node, new List(mandatory)); + } } } else if (node.KeywordRest is Pm.ForwardingParameterNode) { // def f(...) => def f(*?fwd?, &?fwdblk?); calls with `...` splat them back @@ -2384,7 +2406,9 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List } return new Parameters(mandatory.ToArray(), leadingMandatoryCount, - optional.Count > 0 ? optional.ToArray() : null, unsplat, blockParam, span); + optional.Count > 0 ? optional.ToArray() : null, unsplat, blockParam, span) { + KeywordsAfterOptionals = keywordsAfterOptionals + }; } /// @@ -2543,7 +2567,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List /// rest = ?kw?.dup ; rest.delete(:j) ; rest.delete(:k) /// private Statements/*!*/ LowerKeywords(Pm.ParametersNode/*!*/ node, List/*!*/ optional, - bool strict, List/*!*/ mandatory, bool isMethod, SourceSpan span) { + bool strict, List/*!*/ mandatory, bool isMethod, SourceSpan span, int optionalPositionals) { var kwVar = CurrentScope.AddVariable("?kw?", span); // The default stands for "the caller passed no keywords", so it is marked as keyword @@ -2562,7 +2586,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List } if (strict) { - foreach (var check in KeywordSeparationChecks(kwVar, mandatory, true, span)) { + foreach (var check in KeywordSeparationChecks(kwVar, mandatory, optionalPositionals, true, span)) { prologue.Add(check); } } @@ -2584,24 +2608,25 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List /// m("a" => 1, b: 2) # the keywords landed in a: given 0, expected 1 /// private List/*!*/ KeywordSeparationChecks(LocalVariable/*!*/ kwVar, List/*!*/ mandatory, - bool acceptsKeywords, SourceSpan span) { + int optionalPositionals, bool acceptsKeywords, SourceSpan span) { var checks = new List(); int count = mandatory.Count; + string expected = (optionalPositionals > 0) ? count + ".." + (count + optionalPositionals) : count.ToString(); var lastMandatory = count > 0 ? mandatory[count - 1] as LocalVariable : null; if (lastMandatory != null) { checks.Add(new IfExpression( new KeywordArgumentsTest(lastMandatory, span), new Statements(acceptsKeywords - ? RaiseArityError(count - 1, count, span) + ? RaiseArityError(count - 1, expected, span) : RaiseError("ArgumentError", "no keywords accepted", span)), new List(), span)); } checks.Add(new UnlessExpression( new KeywordArgumentsTest(kwVar, span), - new Statements(RaiseArityError(count + 1, count, span)), null, span)); + new Statements(RaiseArityError(count + optionalPositionals + 1, expected, span)), null, span)); if (!acceptsKeywords) { checks.Add(new UnlessExpression( @@ -2621,7 +2646,7 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List optional.Add(new SimpleAssignmentExpression(kwVar, new HashConstructor(new Maplet[0], true, span), null, span)); var prologue = new Statements(); - foreach (var check in KeywordSeparationChecks(kwVar, mandatory, false, span)) { + foreach (var check in KeywordSeparationChecks(kwVar, mandatory, 0, false, span)) { prologue.Add(check); } return prologue; @@ -2638,6 +2663,10 @@ private Arguments ZSuperArguments(Pm.ParametersNode/*!*/ node, List } private Expression/*!*/ RaiseArityError(int given, int expected, SourceSpan span) { + return RaiseArityError(given, expected.ToString(), span); + } + + private Expression/*!*/ RaiseArityError(int given, string/*!*/ expected, SourceSpan span) { return RaiseError("ArgumentError", "wrong number of arguments (given " + given + ", expected " + expected + ")", span); } diff --git a/Src/Ruby/Compiler/Ast/Parameters.cs b/Src/Ruby/Compiler/Ast/Parameters.cs index ece1f17b1..0717c8604 100644 --- a/Src/Ruby/Compiler/Ast/Parameters.cs +++ b/Src/Ruby/Compiler/Ast/Parameters.cs @@ -73,6 +73,17 @@ internal LeftValue SuperSplat { set { _superSplat = value; } } + // Set for a method whose keywords were lowered onto a trailing optional parameter after + // optional positional ones (`def m(a, b = 1, k: 2)' => `def m(a, b = 1, ?kw? = {})'). A + // keyword call that leaves b out passes its keyword hash where b is: before any default + // is evaluated, the hash is moved to the last parameter (TransformOptionalsInitialization). + private bool _keywordsAfterOptionals; + + internal bool KeywordsAfterOptionals { + get { return _keywordsAfterOptionals; } + set { _keywordsAfterOptionals = value; } + } + public LeftValue/*!*/[]/*!*/ Mandatory { get { return _mandatory; } } @@ -120,6 +131,55 @@ public Parameters(LeftValue/*!*/[] mandatory, int leadingMandatoryCount, SimpleA MSA.Expression singleton = gen.CurrentScope.DefineHiddenVariable("#default", typeof(object)); MSA.Expression result = AstUtils.Empty(); + + // The keyword hash in an optional positional: the arguments a call passes fill the + // parameters from the left, and the ones it leaves out get the default, so when the + // keyword parameter got the default the call's last argument is in the rightmost + // optional that did not. If that is the hash a keyword call made, it is the keywords: + // if kw == #default + // if b != #default then (kw, b = b, #default) if keyword_hash?(b) + // elsif a != #default then ... + MSA.Expression shift = null; + if (_keywordsAfterOptionals && _optional.Length > 1) { + var keywords = _optional[_optional.Length - 1].Left; + MSA.Expression chain = AstUtils.Empty(); + for (int i = 0; i < _optional.Length - 1; i++) { + var positional = _optional[i].Left; + chain = Ast.IfThenElse( + Ast.NotEqual(positional.TransformRead(gen), singleton), + Ast.IfThen( + Methods.IsKeywordArgumentsHash.OpCall(AstUtils.Box(positional.TransformRead(gen))), + Ast.Block( + keywords.TransformWrite(gen, positional.TransformRead(gen)), + positional.TransformWrite(gen, singleton), + AstUtils.Empty() + ) + ), + chain + ); + } + shift = Ast.IfThen(Ast.Equal(keywords.TransformRead(gen), singleton), chain); + } + // Once the keywords have been moved out of a positional, the parameters that got their + // default are no longer a suffix of the list, which the nesting below relies on: test + // each one in turn instead, still left to right. + if (shift != null) { + var defaults = new List(); + defaults.Add(Ast.Assign(singleton, Ast.Field(null, Fields.DefaultArgument))); + defaults.Add(shift); + for (int i = 0; i < _optional.Length; i++) { + defaults.Add(AstUtils.IfThen( + Ast.Equal(_optional[i].Left.TransformRead(gen), singleton), + Ast.Block( + _optional[i].Left.TransformWrite(gen, AstUtils.Constant(null, typeof(object))), + _optional[i].TransformRead(gen) // assignment + ) + )); + } + defaults.Add(AstUtils.Empty()); + return Ast.Block(defaults); + } + for (int i = 0; i < _optional.Length; i++) { result = AstUtils.IfThen( Ast.Equal(_optional[i].Left.TransformRead(gen), singleton), @@ -136,6 +196,7 @@ public Parameters(LeftValue/*!*/[] mandatory, int leadingMandatoryCount, SimpleA return Ast.Block( Ast.Assign(singleton, Ast.Field(null, Fields.DefaultArgument)), + shift ?? AstUtils.Empty(), result, AstUtils.Empty() ); diff --git a/Src/Ruby/Compiler/ReflectionCache.Generated.cs b/Src/Ruby/Compiler/ReflectionCache.Generated.cs index ebb1184fa..5384de9ae 100644 --- a/Src/Ruby/Compiler/ReflectionCache.Generated.cs +++ b/Src/Ruby/Compiler/ReflectionCache.Generated.cs @@ -532,6 +532,8 @@ public static partial class Methods { private static MethodInfo _MakeVirtualClassInstantiatedError; public static MethodInfo/*!*/ MakeWrongNumberOfArgumentsError { get { return _MakeWrongNumberOfArgumentsError ?? (_MakeWrongNumberOfArgumentsError = CallInstruction.CacheFunc(RubyOps.MakeWrongNumberOfArgumentsError)); } } private static MethodInfo _MakeWrongNumberOfArgumentsError; + public static MethodInfo/*!*/ MakeKeywordMethodArityError { get { return _MakeKeywordMethodArityError ?? (_MakeKeywordMethodArityError = CallInstruction.CacheFunc(RubyOps.MakeKeywordMethodArityError)); } } + private static MethodInfo _MakeKeywordMethodArityError; public static MethodInfo/*!*/ MakeWrongNumberOfArgumentsErrorN { get { return _MakeWrongNumberOfArgumentsErrorN ?? (_MakeWrongNumberOfArgumentsErrorN = CallInstruction.CacheFunc(RubyOps.MakeWrongNumberOfArgumentsErrorN)); } } private static MethodInfo _MakeWrongNumberOfArgumentsErrorN; public static MethodInfo/*!*/ MarkAliasCall { get { return _MarkAliasCall ?? (_MarkAliasCall = CallInstruction.CacheFunc(RubyOps.MarkAliasCall)); } } diff --git a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs index fba68c711..94b26d23b 100644 --- a/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs +++ b/Src/Ruby/Runtime/Calls/RubyMethodInfo.cs @@ -269,6 +269,22 @@ private MemberDispatcher GetDispatcher(Type/*!*/ delegateType, RubyCallSignature internal override void BuildCallNoFlow(MetaObjectBuilder/*!*/ metaBuilder, CallArguments/*!*/ args, string/*!*/ name) { Assert.NotNull(metaBuilder, args, name); + // A method with keyword parameters takes them in one more, hidden, optional parameter + // (PrismAstBridge.LowerKeywords), which ArgsBuilder would count as one more positional. + var signature = _body.Ast.Parameters.Signature; + if (signature != null && TakesOrRefusesKeywords && !args.Signature.HasSplattedArgument && !args.Signature.HasRhsArgument && + Parameters.Unsplat == null) { + int count = args.SimpleArgumentCount; + if (count < Parameters.Mandatory.Length || count > Parameters.Mandatory.Length + Parameters.Optional.Length) { + metaBuilder.SetError(Methods.MakeKeywordMethodArityError.OpCall( + AstUtils.Constant(signature, typeof(object)), + AstUtils.Constant(count), + (count > 0) ? AstUtils.Box(args.GetSimpleArgumentExpression(count - 1)) : AstUtils.Constant(null) + )); + return; + } + } + // 2 implicit args: self, block var argsBuilder = new ArgsBuilder(2, Parameters.Mandatory.Length, Parameters.LeadingMandatoryCount, Parameters.Optional.Length, Parameters.Unsplat != null); argsBuilder.SetImplicit(0, AstUtils.Box(args.TargetExpression)); diff --git a/Src/Ruby/Runtime/Jit/JitCompiler.cs b/Src/Ruby/Runtime/Jit/JitCompiler.cs index 9c403f262..c0a03f5b9 100644 --- a/Src/Ruby/Runtime/Jit/JitCompiler.cs +++ b/Src/Ruby/Runtime/Jit/JitCompiler.cs @@ -149,23 +149,25 @@ private JitCompiler(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, JT[]/* // one, then the optional ones, whose defaults the entry has already filled in (JitStub) - // or, for a method with keywords, the mandatory ones and then its keyword locals. var optional = ast.Parameters.Optional; - int expected = mandatory.Length + ((keywords != null) ? keywords.Count : (_supplied >= 0) ? _supplied : optional.Length); + // the positionals of a keyword method: the mandatory ones, then any optional ones before the keywords + int positional = mandatory.Length + ((keywords != null) ? keywords.OptionalPositionals : 0); + int expected = (keywords != null) ? positional + keywords.Count : mandatory.Length + ((_supplied >= 0) ? _supplied : optional.Length); _paramNames = new string[paramTypes.Length]; _paramExprs = new MSA.ParameterExpression[paramTypes.Length]; _resolvedTypes = (JT[])paramTypes.Clone(); if (paramTypes.Length != expected) { throw JitBailout.Instance; } for (int i = 0; i < paramTypes.Length; i++) { string name; - if (i < mandatory.Length || keywords == null) { + if (i < positional || keywords == null) { var lv = (i < mandatory.Length) ? mandatory[i] as LocalVariable : optional[i - mandatory.Length].Left as LocalVariable; if (lv == null) { throw JitBailout.Instance; } name = lv.Name; } else { - name = keywords.LocalNames[i - mandatory.Length]; + name = keywords.LocalNames[i - positional]; } if (_vars[0].ContainsKey(name)) { throw JitBailout.Instance; } _paramNames[i] = name; - if (keywords != null && i >= mandatory.Length && keywords.HasDefaultExpr(i - mandatory.Length)) { + if (keywords != null && i >= positional && keywords.HasDefaultExpr(i - positional)) { // the boxed value or RubyOps.DefaultArgument; the local is bound by EmitKeywordDefaults _paramExprs[i] = Ast.Parameter(typeof(object), "?" + name); continue; @@ -565,7 +567,7 @@ internal static JitCode TryCompile(MethodDeclaration/*!*/ ast, RubyContext/*!*/ private static JT[]/*!*/ LambdaParameterTypes(JT[]/*!*/ paramTypes, JitKeywords keywords, MethodDeclaration/*!*/ ast) { if (keywords == null || keywords.DefaultExprs == null) { return paramTypes; } var result = (JT[])paramTypes.Clone(); - int first = ast.Parameters.Mandatory.Length; + int first = ast.Parameters.Mandatory.Length + keywords.OptionalPositionals; for (int j = 0; j < keywords.Count && first + j < result.Length; j++) { if (keywords.HasDefaultExpr(j)) { result[first + j] = JT.Obj; } } @@ -645,7 +647,7 @@ private static Type DelegateType(JT[]/*!*/ ps, JT ret) { /// private List EmitKeywordDefaults() { if (_keywords.DefaultExprs == null) { return null; } - int first = _ast.Parameters.Mandatory.Length; + int first = _ast.Parameters.Mandatory.Length + _keywords.OptionalPositionals; var parts = new List(); var defaultArgument = Ast.Field(null, typeof(RubyOps).GetField("DefaultArgument")); for (int j = 0; j < _keywords.Count; j++) { diff --git a/Src/Ruby/Runtime/Jit/JitKeywords.cs b/Src/Ruby/Runtime/Jit/JitKeywords.cs index 11d3a4ab6..c8f2b95aa 100644 --- a/Src/Ruby/Runtime/Jit/JitKeywords.cs +++ b/Src/Ruby/Runtime/Jit/JitKeywords.cs @@ -67,14 +67,36 @@ internal bool HasDefaultExpr(int index) { internal int Count { get { return LocalNames.Length; } } + // The optional positional parameters before the keyword hash (Parameters.KeywordsAfterOptionals): + // `def m(a, b = 1, k: 2)' has one. Each has a literal default (OptionalDefaults); a call that + // leaves one out may have put its keyword hash there, which the entry moves into place first. + internal int OptionalPositionals; + internal object[] OptionalDefaults; + /// /// The keyword signature of a method whose parameters are mandatory positionals plus /// keywords lowered by LowerKeywords, or null if the method is anything else. /// internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context) { var ps = ast.Parameters; - if (ps.Optional.Length != 1 || ps.Unsplat != null || ps.Block != null) { return null; } - var kwVar = ps.Optional[0].Left as LocalVariable; + if (ps.Unsplat != null || ps.Block != null) { return null; } + if (ps.Optional.Length != 1 && !(ps.KeywordsAfterOptionals && ps.Optional.Length > 1)) { return null; } + int optionalPositionals = ps.Optional.Length - 1; + object[] optionalDefaults = null; + if (optionalPositionals > 0) { + // only defaults the entry can fill in up front: nil, true, false, a Fixnum or a Float + optionalDefaults = new object[optionalPositionals]; + for (int k = 0; k < optionalPositionals; k++) { + var opt = ps.Optional[k]; + var literal = opt.Right as Literal; + object v = (literal != null) ? literal.Value : null; + if (!(opt.Left is LocalVariable) || literal == null || !(v == null || v is bool || v is int || v is double)) { + return null; + } + optionalDefaults[k] = v; + } + } + var kwVar = ps.Optional[optionalPositionals].Left as LocalVariable; if (kwVar == null || kwVar.Name != "?kw?") { return null; } var body = ast.Body; @@ -121,6 +143,8 @@ internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext if (names.Length == 0 || names.Length > MaxKeywords) { return null; } var result = new JitKeywords(); + result.OptionalPositionals = optionalPositionals; + result.OptionalDefaults = optionalDefaults; result.RequiredCount = match.RequiredCountOrPosition; result.MatchNames = new RubySymbol[names.Length]; for (int j = 0; j < names.Length; j++) { @@ -187,6 +211,25 @@ internal static JitKeywords TryRecognize(MethodDeclaration/*!*/ ast, RubyContext return result; } + /// + /// What the generic body does first (Parameters.TransformOptionalsInitialization): a call that + /// passed no keyword hash where it belongs but left an optional positional out may have put it + /// in the last optional positional it did pass. holds the delegate's + /// positional parameters and then the keyword hash, at . + /// + internal static void ShiftKeywords(object[]/*!*/ args, int firstOptional, int keywordIndex) { + if (args[keywordIndex] != RubyOps.DefaultArgument) { return; } + for (int i = keywordIndex - 1; i >= firstOptional; i--) { + if (args[i] != RubyOps.DefaultArgument) { + if (RubyOps.IsKeywordArgumentsHash(args[i])) { + args[keywordIndex] = args[i]; + args[i] = RubyOps.DefaultArgument; + } + return; + } + } + } + private static bool IsVar(RExpr node, LocalVariable/*!*/ kwVar) { var lv = node as LocalVariable; return lv != null && lv.Name == kwVar.Name && lv.DefinitionLexicalDepth == kwVar.DefinitionLexicalDepth; diff --git a/Src/Ruby/Runtime/Jit/JitStub.cs b/Src/Ruby/Runtime/Jit/JitStub.cs index b511c6bdf..409e6bfbf 100644 --- a/Src/Ruby/Runtime/Jit/JitStub.cs +++ b/Src/Ruby/Runtime/Jit/JitStub.cs @@ -186,6 +186,9 @@ internal Specialization(JitStub/*!*/ stub) { protected void Profile(object self, object[] args) { if (_state != Profiling) { return; } + if (_keywords != null && _keywords.OptionalPositionals > 0) { + JitKeywords.ShiftKeywords(args, _positional - _keywords.OptionalPositionals, _positional); + } if (_keywords != null) { // what the prologue binds the keywords to, if it binds them at all object kw = args[_positional]; @@ -581,7 +584,7 @@ private bool TrySpecialize() { var spec = new Specialization(this); spec.ConstantVersion = constantVersion; spec.Entry = BuildEntry(code, callee, version, needsClassGuard ? _receiverClass : null, spec).Compile(); - if (_keywords != null && !needsClassGuard && _positional <= JitKeywordCall.MaxPositional && + if (_keywords != null && _keywords.OptionalPositionals == 0 && !needsClassGuard && _positional <= JitKeywordCall.MaxPositional && _keywords.Count <= JitKeywordCall.MaxKeywords) { spec.Direct = BuildDirectEntry(code, callee, version, spec).Compile(); } @@ -732,6 +735,22 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls, RefinementActivation/*!*/ var values = new MSA.Expression[_typedCount]; var defaulted = new List(); var fillDefaults = new List(); + if (_keywords != null && _keywords.OptionalPositionals > 0) { + // a keyword hash that landed in an optional positional is moved to where it belongs, + // as the generic body would move it (JitKeywords.ShiftKeywords) - which it then finds + // already done, should the guards below send the call there + var defaultArgument = Ast.Field(null, typeof(RubyOps).GetField("DefaultArgument")); + MSA.Expression chain = Ast.Empty(); + for (int i = _positional - _keywords.OptionalPositionals; i < _positional; i++) { + chain = Ast.IfThenElse( + Ast.NotEqual(args[i], defaultArgument), + Ast.IfThen( + Ast.Call(typeof(RubyOps).GetMethod("IsKeywordArgumentsHash"), args[i]), + Ast.Block(Ast.Assign(args[_positional], args[i]), Ast.Assign(args[i], defaultArgument))), + chain); + } + fillDefaults.Add(Ast.IfThen(Ast.Equal(args[_positional], defaultArgument), chain)); + } for (int i = 0; i < _positional; i++) { if (_hasDefault != null && _hasDefault[i]) { var v = Ast.Variable(typeof(object), "v" + i); @@ -757,9 +776,11 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls, RefinementActivation/*!*/ MSA.ParameterExpression[] keywordValues = null; if (_keywords != null) { MSA.Expression kw = args[_positional]; - // the separation check on the last positional: `m("a" => 1)' gave it the keywords - if (_positional > 0 && code.ParameterTypes[_positional - 1] == JT.Obj) { - test = And(test, Ast.Not(Ast.Call(typeof(RubyOps).GetMethod("IsKeywordArgumentsHash"), args[_positional - 1]))); + // the separation check on the last mandatory positional: `m("a" => 1)' gave it the + // keywords (an optional one cannot hold them any more, they were moved above) + int lastMandatory = _positional - _keywords.OptionalPositionals - 1; + if (lastMandatory >= 0 && code.ParameterTypes[lastMandatory] == JT.Obj) { + test = And(test, Ast.Not(Ast.Call(typeof(RubyOps).GetMethod("IsKeywordArgumentsHash"), args[lastMandatory]))); } keywordValues = new MSA.ParameterExpression[_keywords.Count]; for (int j = 0; j < keywordValues.Length; j++) { @@ -839,7 +860,7 @@ private bool SelfCallResolvesHere(RubyClass/*!*/ cls, RefinementActivation/*!*/ if (keywordValues != null) { body = Ast.Block(typeof(object), keywordValues, body); } - if (defaulted.Count > 0) { + if (fillDefaults.Count > 0) { fillDefaults.Add(body); body = Ast.Block(typeof(object), defaulted, fillDefaults); } @@ -1167,7 +1188,18 @@ private static bool Screen(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, hasDefault = null; defaults = null; } + } else if (keywords != null && keywords.OptionalPositionals > 0) { + // optional positionals before the keywords: the entry fills in their literal defaults + hasDefault = new bool[arity]; + defaults = new object[arity]; + for (int i = 0; i < keywords.OptionalPositionals; i++) { + hasDefault[mandatoryCount + i] = true; + defaults[mandatoryCount + i] = keywords.OptionalDefaults[i]; + } } + // A method whose keywords follow optional positionals is only taken as a keyword method: + // the entry has to move a misplaced keyword hash first, which only that kind does. + if (ps.KeywordsAfterOptionals && keywords == null) { return false; } // A body the compiler can never accept, whatever types it is called with, is not // wrapped at all. Once wrapped, the trampoline is what the runtime caches and bakes // into call site rules, so a method declined later still pays one extra indirect @@ -1178,10 +1210,12 @@ private static bool Screen(MethodDeclaration/*!*/ ast, RubyContext/*!*/ context, } // over the typed body's parameters: the positionals, then a keyword method's keyword locals - int typedCount = mandatoryCount + ((keywords != null) ? keywords.Count : ps.Optional.Length); + int positionalCount = mandatoryCount + ((keywords != null) ? keywords.OptionalPositionals : 0); + int typedCount = positionalCount + ((keywords != null) ? keywords.Count : ps.Optional.Length); for (int i = 0; i < typedCount; i++) { string name = (i < mandatoryCount) ? ((LocalVariable)ps.Mandatory[i]).Name - : (keywords != null) ? keywords.LocalNames[i - mandatoryCount] + : (i < positionalCount) ? ((LocalVariable)ps.Optional[i - mandatoryCount].Left).Name + : (keywords != null) ? keywords.LocalNames[i - positionalCount] : ((LocalVariable)ps.Optional[i - mandatoryCount].Left).Name; if (!otherUses.Contains(name)) { if (storedOnly == null) { storedOnly = new bool[typedCount]; } diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index db012fb08..19e4325fd 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -3372,6 +3372,26 @@ private static void TraceRescue(RubyScope/*!*/ scope, Exception exception) { /// Same, but with MRI's variable-arity spelling of the expected count: "1..3", "1+" or "2, 3, or 5". /// The description is a compile-time constant produced by the binder. /// + /// + /// The arity error of a call to a method with keyword parameters, worded from the declared + /// signature as MRI words it: the keyword hash a call made is not one of the arguments + /// given, the hidden parameter it arrives in is not one of those expected, and a required + /// keyword is named ("given 4, expected 1..3; required keyword: j"). + /// + [Emitted] + public static ArgumentException/*!*/ MakeKeywordMethodArityError(object/*!*/ signature, int count, object lastArgument) { + var parameters = (RubyParameterSignature)signature; + var keywords = lastArgument as Hash; + if (keywords != null && !keywords.IsKeywordArguments) { + keywords = null; + } + int positional = count - (keywords != null ? 1 : 0); + int min = parameters.MinPositionalCount, max = parameters.MaxPositionalCount; + var inv = System.Globalization.CultureInfo.InvariantCulture; + string expected = (min == max) ? min.ToString(inv) : min.ToString(inv) + (max < 0 ? "+" : ".." + max.ToString(inv)); + return MakeWrongNumberOfArgumentsErrorN(positional, expected + MissingRequiredKeywordsNote(parameters, keywords)); + } + [Emitted] public static ArgumentException/*!*/ MakeWrongNumberOfArgumentsErrorN(int actual, string/*!*/ expected) { return new ArgumentException(String.Format("wrong number of arguments (given {0}, expected {1})", actual, expected)); From eed88f0f5b5eb411ad0ba6f9006faf48d42d63b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:47:36 +0000 Subject: [PATCH 3/4] Fixnum rounding: a result past Int32 is an Int64, as every other Integer is TryRoundFixnum made a BigInteger of it, which is not how IronRuby represents an Integer in Int64 range (Protocols.Normalize), and a Hash keyed by that Integer did not find it: {-3000000000 => :hit}[-2147483648.floor(-9)] was nil. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPwZf7MsYwQdhLBCZCkH2C --- Src/IronRuby.Tests/Runtime/MiscTests.cs | 2 ++ Src/Libraries/Extensions/NumericRounding.cs | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index 3b3fb50c5..d24d8d166 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -3290,6 +3290,7 @@ public void Kernel_ConversionsRoundingStep1() { t('utf16') { Integer(""12"".encode(""UTF-16LE""), exception: false) } t('to_i') { r = Object.new; def r.to_i; 3; end; [Integer(r), Integer(Rational(7, 2)), Float(Rational(7, 2))] } t('round') { [12345.round(-2), 12350.round(-2), -12350.round(-2), 12350.round(-2, half: :even), 12350.round(-2, half: :down), 25.round(-1, half: :even), 2147483647.round(-9), -2147483648.floor(-9), 5.ceil(-18), 5.ceil(-19), -5.floor(-19), 5.round(-25), 12345.truncate(-3), -12345.truncate(-3), 7.round(2)] } +t('round as a hash key') { {-3000000000 => :hit}[-2147483648.floor(-9)] } t('first/last') { [(1..10).first, (1...10).last, (1..10).first(3), (1..10).last(3), (..5).last(2), (...5).last(3), ('a'..'e').last(2)] } t('beginless first') { (..5).first } t('endless last') { (1..).last } @@ -3321,6 +3322,7 @@ public void Kernel_ConversionsRoundingStep1() { utf16: [Encoding::CompatibilityError, ""ASCII incompatible encoding: UTF-16LE""] to_i: [3, 3, 3.5] round: [12300, 12400, -12400, 12400, 12300, 20, 2000000000, -3000000000, 1000000000000000000, 10000000000000000000, -10000000000000000000, 0, 12000, -12000, 7] +round as a hash key: :hit first/last: [1, 10, [1, 2, 3], [8, 9, 10], [4, 5], [2, 3, 4], [""d"", ""e""]] beginless first: [RangeError, ""cannot get the first element of beginless range""] endless last: [RangeError, ""cannot get the last element of endless range""] diff --git a/Src/Libraries/Extensions/NumericRounding.cs b/Src/Libraries/Extensions/NumericRounding.cs index f50d47857..0acb543ab 100644 --- a/Src/Libraries/Extensions/NumericRounding.cs +++ b/Src/Libraries/Extensions/NumericRounding.cs @@ -426,9 +426,8 @@ internal static bool TryRoundFixnum(int value, int ndigits, IntegerRounding kind rounded = away ? (v < 0 ? down - f : down + f) : down; break; } - result = (rounded >= Int32.MinValue && rounded <= Int32.MaxValue) - ? ScriptingRuntimeHelpers.Int32ToObject((int)rounded) - : (object)new BigInteger(rounded); + // an Int32 when it fits and an Int64 otherwise, as every other Integer is represented + result = Protocols.Normalize(rounded); return true; } From 4ec91b98c9f59684b942b8aaa66d0dc9d05174e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:56:30 +0000 Subject: [PATCH 4/4] RubyOps: MakeWrongNumberOfArgumentsErrorN keeps its own doc comment MakeKeywordMethodArityError had been put between it and its summary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPwZf7MsYwQdhLBCZCkH2C --- Src/Ruby/Runtime/RubyOps.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Src/Ruby/Runtime/RubyOps.cs b/Src/Ruby/Runtime/RubyOps.cs index 19e4325fd..9ec308baa 100644 --- a/Src/Ruby/Runtime/RubyOps.cs +++ b/Src/Ruby/Runtime/RubyOps.cs @@ -3368,10 +3368,6 @@ private static void TraceRescue(RubyScope/*!*/ scope, Exception exception) { return new ArgumentException(String.Format("wrong number of arguments (given {0}, expected {1})", actual, expected)); } - /// - /// Same, but with MRI's variable-arity spelling of the expected count: "1..3", "1+" or "2, 3, or 5". - /// The description is a compile-time constant produced by the binder. - /// /// /// The arity error of a call to a method with keyword parameters, worded from the declared /// signature as MRI words it: the keyword hash a call made is not one of the arguments @@ -3392,6 +3388,10 @@ private static void TraceRescue(RubyScope/*!*/ scope, Exception exception) { return MakeWrongNumberOfArgumentsErrorN(positional, expected + MissingRequiredKeywordsNote(parameters, keywords)); } + /// + /// Same, but with MRI's variable-arity spelling of the expected count: "1..3", "1+" or "2, 3, or 5". + /// The description is a compile-time constant produced by the binder. + /// [Emitted] public static ArgumentException/*!*/ MakeWrongNumberOfArgumentsErrorN(int actual, string/*!*/ expected) { return new ArgumentException(String.Format("wrong number of arguments (given {0}, expected {1})", actual, expected));