From bb8311464f2047638076feaa2dff74b0be90db1d Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:39:23 +0900 Subject: [PATCH 1/2] fix: align Lua 5.2 VM comparisons and numeric operators --- src/Lua/LuaValue.cs | 5 +- src/Lua/Runtime/LuaVirtualMachine.cs | 63 ++++++++++++++----- .../ArithmeticExecutionRegressionTests.cs | 17 +++++ tests/Lua.Tests/CompilerTests.cs | 12 ++++ tests/Lua.Tests/NativeCallbackResultTests.cs | 26 ++++++++ .../Lua.Tests/VirtualMachineExecutionTests.cs | 38 +++++++++++ 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index 9a11b54d..ce3a914a 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -769,8 +769,9 @@ CancellationToken cancellationToken var count = await LuaVirtualMachine.Call(state, top, top, cancellationToken); var result = count > 0 ? stack.Get(top) : Nil; stack.SetTop(top + 1); - // Return one value, padding missing results and discarding extras. - stack.FastGet(top) = result; + // Lua 5.2 converts a numeric __tostring result to text. + stack.FastGet(top) = + result.Type == LuaValueType.Number ? new LuaValue(result.ToString()) : result; return 1; } catch diff --git a/src/Lua/Runtime/LuaVirtualMachine.cs b/src/Lua/Runtime/LuaVirtualMachine.cs index 4f991cf2..54b4a946 100644 --- a/src/Lua/Runtime/LuaVirtualMachine.cs +++ b/src/Lua/Runtime/LuaVirtualMachine.cs @@ -810,6 +810,21 @@ out doRestart continue; } + // Lua 5.2 only invokes __eq for tables/userdata of the + // same type. Primitive inequality needs no slow-path call. + if ( + vb.Type != vc.Type + || vb.Type is not (LuaValueType.Table or LuaValueType.UserData) + ) + { + if (iA == 1) + { + context.Pc++; + } + + continue; + } + if ( ExecuteCompareOperationMetaMethod( vb, @@ -950,7 +965,7 @@ out doRestart var step = Unsafe.Add(ref indexRef, 2).UnsafeReadDouble(); var index = indexRef.UnsafeReadDouble() + step; - if (step >= 0 ? index <= limit : limit <= index) + if (step > 0 ? index <= limit : limit <= index) { context.Pc += instruction.SBx; indexRef = index; @@ -1128,13 +1143,9 @@ OpCode opcode [MethodImpl(MethodImplOptions.NoInlining)] internal static double Mod(double a, double b) { - var mod = a % b; - if ((b > 0 && mod < 0) || (b < 0 && mod > 0)) - { - mod += b; - } - - return mod; + // Lua 5.2 uses this expression, including its floating-point rounding. + // A sign-adjusted remainder differs for values such as 5 and 0.1. + return a - (Math.Floor(a / b) * b); } static void SelfPostOperation( @@ -2810,6 +2821,32 @@ CancellationToken cancellationToken } } + static bool TryGetCompareMetamethod( + LuaGlobalState state, + LuaValue left, + LuaValue right, + string name, + out LuaValue metamethod + ) + { + if (name == Metamethods.Eq) + { + metamethod = default; + return left.Type == right.Type + && left.Type is LuaValueType.Table or LuaValueType.UserData + && state.TryGetMetatable(left, out var leftMetatable) + && leftMetatable.TryGetValue(name, out metamethod) + && state.TryGetMetatable(right, out var rightMetatable) + && ( + leftMetatable == rightMetatable + || rightMetatable.TryGetValue(name, out var other) && metamethod == other + ); + } + + return left.TryGetMetamethod(state, name, out metamethod) + || right.TryGetMetamethod(state, name, out metamethod); + } + [MethodImpl(MethodImplOptions.NoInlining)] static bool ExecuteCompareOperationMetaMethod( LuaValue vb, @@ -2823,10 +2860,7 @@ out bool doRestart doRestart = false; var reverseLe = false; ReCheck: - if ( - vb.TryGetMetamethod(context.GlobalState, name, out var metamethod) - || vc.TryGetMetamethod(context.GlobalState, name, out metamethod) - ) + if (TryGetCompareMetamethod(context.GlobalState, vb, vc, name, out var metamethod)) { var stack = context.Stack; var newBase = stack.Count; @@ -2959,10 +2993,7 @@ CancellationToken cancellationToken var (name, description) = opCode.GetNameAndDescription(); var reverseLe = false; ReCheck: - if ( - vb.TryGetMetamethod(state.GlobalState, name, out var metamethod) - || vc.TryGetMetamethod(state.GlobalState, name, out metamethod) - ) + if (TryGetCompareMetamethod(state.GlobalState, vb, vc, name, out var metamethod)) { var stack = state.Stack; var top = stack.Count; diff --git a/tests/Lua.Tests/ArithmeticExecutionRegressionTests.cs b/tests/Lua.Tests/ArithmeticExecutionRegressionTests.cs index 98193305..76b58a14 100644 --- a/tests/Lua.Tests/ArithmeticExecutionRegressionTests.cs +++ b/tests/Lua.Tests/ArithmeticExecutionRegressionTests.cs @@ -85,4 +85,21 @@ public async Task ArithmeticBytecodeRoundTripAndSpecialNumbers() var results = await state.ExecuteAsync(restored); Assert.That(results[0].Read(), Is.EqualTo(8)); } + + [Test] + public async Task ZeroStepNumericForUsesLua52Direction() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local count = 0 + for i = 1, 2, 0 do count = count + 1; break end + assert(count == 0) + for i = 2, 1, 0 do count = count + 1; break end + assert(count == 1) + for i = 1, 1, -0.0 do count = count + 1; break end + assert(count == 2) + """ + ); + } } diff --git a/tests/Lua.Tests/CompilerTests.cs b/tests/Lua.Tests/CompilerTests.cs index b8587e54..c3e65bd4 100644 --- a/tests/Lua.Tests/CompilerTests.cs +++ b/tests/Lua.Tests/CompilerTests.cs @@ -126,4 +126,16 @@ public async Task SyntaxDepthFailureRestoresCounter() } Assert.That((await state.DoStringAsync("return 42"))[0].Read(), Is.EqualTo(42)); } + + [TestCase("5", "0.1")] + [TestCase("1e308", "3")] + [TestCase("-5", "0.1")] + [TestCase("5", "-0.1")] + public async Task FoldedAndRuntimeModuloUseLua52Formula(string a, string b) + { + using var state = CreateState(); + var results = await state.DoStringAsync($"local a,b={a},{b}; return {a}%{b},a%b"); + Assert.That(results[0].Read(), Is.Zero); + Assert.That(results[1].Read(), Is.Zero); + } } diff --git a/tests/Lua.Tests/NativeCallbackResultTests.cs b/tests/Lua.Tests/NativeCallbackResultTests.cs index fe81975e..f456537b 100644 --- a/tests/Lua.Tests/NativeCallbackResultTests.cs +++ b/tests/Lua.Tests/NativeCallbackResultTests.cs @@ -203,4 +203,30 @@ public async Task ManagedCallbacksDistinguishNoResultsFromNil( Assert.That(state.Stack.Pop().Read(), Is.EqualTo(12345)); Assert.That(state.CallStackFrameCount, Is.Zero); } + + [Test] + public async Task ToStringUsesOneStringResult([Values] bool hooks, [Values] bool suspend) + { + using var state = CreateState(); + state.Environment["format"] = new LuaFunction( + async (context, token) => + { + if (suspend) + await Task.Yield(); + return context.Return(12, "extra"); + } + ); + if (hooks) + await state.DoStringAsync("debug.sethook(function() end,'cr')"); + await state.DoStringAsync( + """ + local x=setmetatable({}, {__tostring=format}) + assert(select('#',tostring(x))==1) + assert(tostring(x)=='12') + assert(string.format('%s:%s',x,'tail')=='12:tail') + """ + ); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } } diff --git a/tests/Lua.Tests/VirtualMachineExecutionTests.cs b/tests/Lua.Tests/VirtualMachineExecutionTests.cs index 1fd8d475..ff64ab7f 100644 --- a/tests/Lua.Tests/VirtualMachineExecutionTests.cs +++ b/tests/Lua.Tests/VirtualMachineExecutionTests.cs @@ -282,4 +282,42 @@ await state.DoStringAsync( Assert.That(state.Stack.Pop().Read(), Is.EqualTo(12345)); Assert.That(state.CallStackFrameCount, Is.Zero); } + + [Test] + public async Task EqualityRequiresMatchingTypesAndSharedMetamethod() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local calls = 0 + local function eq(a,b) calls=calls+1; return true end + local a = setmetatable({}, {__eq=eq}) + local b = {} + assert(not (a == b) and not (b == a) and calls == 0) + setmetatable(b, {__eq=function() return true end}) + assert(not (a == b) and calls == 0) + setmetatable(b, {__eq=eq}) + assert(a == b and calls == 1) + assert(a == a and calls == 1) + assert(not (a == 1) and not (1 == a) and calls == 1) + debug.setmetatable(1, {__eq=function() error('numeric __eq must not run') end}) + local x,y = 1,2 + assert(x ~= y) + debug.setmetatable(1, nil) + -- A shared metatable selects even a non-reflexive NaN tag method. + -- Calling it must fail; separate metatables do not share that value. + local nan=0/0 + local mt={__eq=nan} + setmetatable(a, mt); setmetatable(b, mt) + assert(not pcall(function() return a == b end)) + setmetatable(b, {__eq=nan}) + assert(not (a == b)) + """ + ); + var a = state.Environment["a"] = new LuaTable(); + var b = state.Environment["b"] = new LuaTable(); + await state.DoStringAsync("setmetatable(a, {__eq=function() return true end})"); + Assert.That(await state.EqualsAsync(a, b), Is.False); + Assert.That(await state.EqualsAsync(b, a), Is.False); + } } From 8aa71081dcbd348a5408f05163164f901460ee5c Mon Sep 17 00:00:00 2001 From: akeit0 <90429982+Akeit0@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:42:08 +0900 Subject: [PATCH 2/2] fix: align numeric coercion and math selection with Lua 5.2 --- src/Lua/LuaValue.cs | 17 +- src/Lua/Standard/BasicLibrary.cs | 225 +++++------------- src/Lua/Standard/BitwiseLibrary.cs | 33 --- src/Lua/Standard/MathematicsLibrary.cs | 29 ++- tests/Lua.Tests/BitwiseLibraryTests.cs | 29 +++ tests/Lua.Tests/ManagedNumericTextTests.cs | 36 +++ tests/Lua.Tests/MathematicsLibraryTests.cs | 23 ++ tests/Lua.Tests/NumericCoercionTests.cs | 54 +++++ tests/Lua.Tests/NumericConversionTests.cs | 78 ++++++ .../NumericLibraryRegressionTests.cs | 83 +++++++ tests/Lua.Tests/SelectTests.cs | 60 +++++ 11 files changed, 461 insertions(+), 206 deletions(-) create mode 100644 tests/Lua.Tests/ManagedNumericTextTests.cs create mode 100644 tests/Lua.Tests/NumericCoercionTests.cs create mode 100644 tests/Lua.Tests/NumericConversionTests.cs create mode 100644 tests/Lua.Tests/NumericLibraryRegressionTests.cs create mode 100644 tests/Lua.Tests/SelectTests.cs diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index ce3a914a..ae3e4e89 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -361,13 +361,18 @@ bool TryParseToDouble(out double result) } var str = Unsafe.As(referenceValue!); - var span = str.AsSpan().Trim(); - if (span.Length == 0) + var span = str.AsSpan(); + if (!span.IsEmpty && (span[0] <= ' ' || span[^1] <= ' ')) + span = span.Trim(" \f\n\r\t\v"); + // .NET's parser accepts trailing NULs, Unicode whitespace and named + // infinities/NaNs. A Lua numeral must end with a hex digit or decimal point. + if (span.Length == 0 || !(StringHelper.IsDigit(span[^1]) || span[^1] == '.')) { result = default!; return false; } + var numberText = span; var sign = 1; var first = span[0]; if (first is '+') @@ -381,6 +386,12 @@ bool TryParseToDouble(out double result) span = span[1..]; } + if (span.IsEmpty || !(span[0] is >= '0' and <= '9' or '.')) + { + result = default; + return false; + } + if (span.Length > 2 && span[0] is '0' && span[1] is 'x' or 'X') { // TODO: optimize @@ -399,7 +410,7 @@ bool TryParseToDouble(out double result) else { return double.TryParse( - str, + numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out result diff --git a/src/Lua/Standard/BasicLibrary.cs b/src/Lua/Standard/BasicLibrary.cs index 1008621a..542ee7bd 100644 --- a/src/Lua/Standard/BasicLibrary.cs +++ b/src/Lua/Standard/BasicLibrary.cs @@ -491,34 +491,24 @@ public ValueTask Select( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); - - if (arg0.TryRead(out var index)) - { - if (Math.Abs(index) > context.ArgumentCount) - { - throw new LuaRuntimeException( - context.State, - "bad argument #1 to 'select' (index out of range)" - ); - } - - var span = - index >= 0 - ? context.Arguments[index..] - : context.Arguments[(context.ArgumentCount + index)..]; - - return new(context.Return(span)); - } - else if (arg0.TryRead(out var str) && str == "#") - { - return new(context.Return(context.ArgumentCount - 1)); - } - else - { - LuaRuntimeException.BadArgument(context.State, 1, LuaValueType.Number, arg0.Type); - return default; - } + var selector = context.GetArgument(0); + if (selector.Type == LuaValueType.String) + { + var text = selector.UnsafeReadString(); + if (text.Length > 0 && text[0] == '#') + return new(context.Return(context.ArgumentCount - 1)); + } + + var index = Math.Truncate(context.GetArgument(0)); + if (!MathEx.IsFinite(index)) + context.ThrowBadArgument(1, "index out of range"); + if (index < 0) + index += context.ArgumentCount; + else if (index > context.ArgumentCount) + index = context.ArgumentCount; + if (index < 1) + context.ThrowBadArgument(1, "index out of range"); + return new(context.Return(context.Arguments[(int)index..])); } public ValueTask SetMetatable( @@ -560,152 +550,65 @@ public ValueTask ToNumber( CancellationToken cancellationToken ) { - var e = context.GetArgument(0); - int? toBase = context.HasArgument(1) ? (int)context.GetArgument(1) : null; - - if (toBase != null && (toBase < 2 || toBase > 36)) - { - throw new LuaRuntimeException( - context.State, - "bad argument #2 to 'tonumber' (base out of range)" + var value = context.GetArgument(0); + if (!context.HasArgument(1)) + { + if (value.Type == LuaValueType.Number) + return new(context.Return(value)); + return new( + context.Return( + value.TryReadDouble(out var number) && !double.IsNaN(number) + ? new LuaValue(number) + : LuaValue.Nil + ) ); } - double? value = null; - if (e.Type is LuaValueType.Number) - { - value = e.UnsafeRead(); - } - else if (e.TryRead(out var str)) - { - if (toBase == null) - { - if (e.TryRead(out var result)) - { - value = result; - } - } - else if (toBase == 10) - { - if ( - double.TryParse( - str, - NumberStyles.Float, - CultureInfo.InvariantCulture, - out var result - ) - ) - { - value = result; - } - } - else - { - try - { - // if the base is not 10, str cannot contain a minus sign - var span = str.AsSpan().Trim(); - if (span.Length == 0) - { - goto END; - } - - var first = span[0]; - var sign = first == '-' ? -1 : 1; - if (first is '+' or '-') - { - span = span[1..]; - } - - if (span.Length == 0) - { - goto END; - } - - if (toBase == 16 && span.Length > 2 && span[0] is '0' && span[1] is 'x' or 'X') - { - value = sign * HexConverter.ToDouble(span); - } - else - { - value = sign * StringToDouble(span, toBase.Value); - } - } - catch (FormatException) - { - goto END; - } - } - } + // Lua coerces numeric inputs to text before interpreting an explicit base. + string text; + if (value.Type == LuaValueType.String) + text = value.UnsafeReadString(); + else if (value.Type == LuaValueType.Number) + text = value.ToString(); else { - goto END; - } - - END: - if (value is double.NaN) - { - value = null; + LuaRuntimeException.BadArgument(context.State, 1, LuaValueType.String, value.Type); + return default; } - return new(context.Return(value ?? LuaValue.Nil)); + var toBase = (int)context.GetArgument(1); + if (toBase is < 2 or > 36) + context.ThrowBadArgument(2, "base out of range"); + var parsed = StringToDouble(text.AsSpan(), toBase); + return new(context.Return(parsed.HasValue ? new LuaValue(parsed.Value) : LuaValue.Nil)); } - static double StringToDouble(ReadOnlySpan text, int toBase) + static double? StringToDouble(ReadOnlySpan text, int toBase) { - var value = 0.0; - for (var i = 0; i < text.Length; i++) - { - var v = text[i] switch + text = text.Trim(" \f\n\r\t\v"); + if (text.IsEmpty) + return null; + var negative = text[0] == '-'; + if (text[0] is '+' or '-') + text = text[1..]; + if (text.IsEmpty) + return null; + + double value = 0; + foreach (var c in text) + { + var digit = c switch { - '0' => 0, - '1' => 1, - '2' => 2, - '3' => 3, - '4' => 4, - '5' => 5, - '6' => 6, - '7' => 7, - '8' => 8, - '9' => 9, - 'a' or 'A' => 10, - 'b' or 'B' => 11, - 'c' or 'C' => 12, - 'd' or 'D' => 13, - 'e' or 'E' => 14, - 'f' or 'F' => 15, - 'g' or 'G' => 16, - 'h' or 'H' => 17, - 'i' or 'I' => 18, - 'j' or 'J' => 19, - 'k' or 'K' => 20, - 'l' or 'L' => 21, - 'm' or 'M' => 22, - 'n' or 'N' => 23, - 'o' or 'O' => 24, - 'p' or 'P' => 25, - 'q' or 'Q' => 26, - 'r' or 'R' => 27, - 's' or 'S' => 28, - 't' or 'T' => 29, - 'u' or 'U' => 30, - 'v' or 'V' => 31, - 'w' or 'W' => 32, - 'x' or 'X' => 33, - 'y' or 'Y' => 34, - 'z' or 'Z' => 35, - _ => throw new FormatException("Invalid digit."), + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'Z' => c - 'A' + 10, + >= 'a' and <= 'z' => c - 'a' + 10, + _ => 36, }; - - if (v >= toBase) - { - throw new FormatException(); - } - - value += v * Math.Pow(toBase, text.Length - i - 1); + if (digit >= toBase) + return null; + value = value * toBase + digit; } - - return value; + return negative ? -value : value; } public ValueTask ToString( diff --git a/src/Lua/Standard/BitwiseLibrary.cs b/src/Lua/Standard/BitwiseLibrary.cs index b0b775a8..246a1402 100644 --- a/src/Lua/Standard/BitwiseLibrary.cs +++ b/src/Lua/Standard/BitwiseLibrary.cs @@ -36,9 +36,6 @@ CancellationToken cancellationToken var x = context.GetArgument(0); var disp = context.GetArgument(1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, x); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, disp); - var v = Bit32Helper.ToInt32(x); var a = (int)disp; @@ -63,14 +60,12 @@ CancellationToken cancellationToken } var arg0 = context.GetArgument(0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); var value = Bit32Helper.ToUInt32(arg0); for (var i = 1; i < context.ArgumentCount; i++) { var arg = context.GetArgument(i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1 + i, arg); var v = Bit32Helper.ToUInt32(arg); value &= v; @@ -85,7 +80,6 @@ CancellationToken cancellationToken ) { var arg0 = context.GetArgument(0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); var value = Bit32Helper.ToUInt32(arg0); return new(context.Return(~value)); @@ -102,14 +96,12 @@ CancellationToken cancellationToken } var arg0 = context.GetArgument(0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); var value = Bit32Helper.ToUInt32(arg0); for (var i = 1; i < context.ArgumentCount; i++) { var arg = context.GetArgument(i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1 + i, arg); var v = Bit32Helper.ToUInt32(arg); value |= v; @@ -130,14 +122,12 @@ CancellationToken cancellationToken } var arg0 = context.GetArgument(0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); var value = Bit32Helper.ToUInt32(arg0); for (var i = 1; i < context.ArgumentCount; i++) { var arg = context.GetArgument(i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1 + i, arg); var v = Bit32Helper.ToUInt32(arg); value &= v; @@ -157,14 +147,12 @@ CancellationToken cancellationToken } var arg0 = context.GetArgument(0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); var value = Bit32Helper.ToUInt32(arg0); for (var i = 1; i < context.ArgumentCount; i++) { var arg = context.GetArgument(i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1 + i, arg); var v = Bit32Helper.ToUInt32(arg); value ^= v; @@ -182,10 +170,6 @@ CancellationToken cancellationToken var arg1 = context.GetArgument(1); var arg2 = context.HasArgument(2) ? context.GetArgument(2) : 1; - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, arg1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 3, arg2); - var n = Bit32Helper.ToUInt32(arg0); var field = (int)arg1; var width = (int)arg2; @@ -204,9 +188,6 @@ CancellationToken cancellationToken var x = context.GetArgument(0); var disp = context.GetArgument(1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, x); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, disp); - var v = Bit32Helper.ToUInt32(x); var a = (int)disp & 31; return new(context.Return((v << a) | (v >> (32 - a)))); @@ -220,9 +201,6 @@ CancellationToken cancellationToken var x = context.GetArgument(0); var disp = context.GetArgument(1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, x); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, disp); - var v = Bit32Helper.ToUInt32(x); var a = (int)disp; @@ -252,11 +230,6 @@ CancellationToken cancellationToken var arg2 = context.GetArgument(2); var arg3 = context.HasArgument(3) ? context.GetArgument(3) : 1; - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, arg1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 3, arg2); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 4, arg3); - var n = Bit32Helper.ToUInt32(arg0); var v = Bit32Helper.ToUInt32(arg1); var field = (int)arg2; @@ -278,9 +251,6 @@ CancellationToken cancellationToken var x = context.GetArgument(0); var disp = context.GetArgument(1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, x); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, disp); - var v = Bit32Helper.ToUInt32(x); var a = (int)disp & 31; return new(context.Return((v >> a) | (v << (32 - a)))); @@ -294,9 +264,6 @@ CancellationToken cancellationToken var x = context.GetArgument(0); var disp = context.GetArgument(1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, x); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, disp); - var v = Bit32Helper.ToUInt32(x); var a = (int)disp; diff --git a/src/Lua/Standard/MathematicsLibrary.cs b/src/Lua/Standard/MathematicsLibrary.cs index 29c5e8ac..60c6f02a 100644 --- a/src/Lua/Standard/MathematicsLibrary.cs +++ b/src/Lua/Standard/MathematicsLibrary.cs @@ -197,14 +197,17 @@ CancellationToken cancellationToken { var arg0 = context.GetArgument(0); - if (context.ArgumentCount == 1) + if (!context.HasArgument(1)) { return new(context.Return(Math.Log(arg0))); } else { var arg1 = context.GetArgument(1); - return new(context.Return(Math.Log(arg0, arg1))); + // Match Lua 5.2's formula, including signed zero for base zero. + return new( + context.Return(arg1 == 10 ? Math.Log10(arg0) : Math.Log(arg0) / Math.Log(arg1)) + ); } } @@ -216,7 +219,11 @@ CancellationToken cancellationToken var x = context.GetArgument(0); for (var i = 1; i < context.ArgumentCount; i++) { - x = Math.Max(x, context.GetArgument(i)); + var next = context.GetArgument(i); + if (x < next) + { + x = next; + } } return new(context.Return(x)); @@ -230,7 +237,11 @@ CancellationToken cancellationToken var x = context.GetArgument(0); for (var i = 1; i < context.ArgumentCount; i++) { - x = Math.Min(x, context.GetArgument(i)); + var next = context.GetArgument(i); + if (next < x) + { + x = next; + } } return new(context.Return(x)); @@ -281,8 +292,7 @@ CancellationToken cancellationToken if (context.ArgumentCount == 1) { var arg0 = context.GetArgument(0); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0); - if (arg0 < 1) + if (!(arg0 >= 1)) { context.ThrowBadArgument(1, "interval is empty"); } @@ -290,11 +300,12 @@ CancellationToken cancellationToken return new(context.Return(Math.Floor(rand.NextDouble() * arg0) + 1)); } + if (context.ArgumentCount != 2) + throw new LuaRuntimeException(context.State, "wrong number of arguments"); + var arg0Min = context.GetArgument(0); var arg1Max = context.GetArgument(1); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 1, arg0Min); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, arg1Max); - if (arg0Min > arg1Max) + if (!(arg0Min <= arg1Max)) { context.ThrowBadArgument(2, "interval is empty"); } diff --git a/tests/Lua.Tests/BitwiseLibraryTests.cs b/tests/Lua.Tests/BitwiseLibraryTests.cs index 9018f266..a88f2249 100644 --- a/tests/Lua.Tests/BitwiseLibraryTests.cs +++ b/tests/Lua.Tests/BitwiseLibraryTests.cs @@ -93,4 +93,33 @@ public async Task FieldWidthCannotOverflowValidation(string expression) Assert.That(result[0].Read(), Is.False); Assert.That(result[1].Read(), Does.Contain("non-existent bits")); } + + [TestCase("band(0.5)", 0d)] + [TestCase("band(1.5)", 2d)] + [TestCase("band(2.5)", 2d)] + [TestCase("band(-1.5)", 4294967294d)] + [TestCase("band('1.5',3)", 2d)] + [TestCase("band(4294967295.5)", 0d)] + [TestCase("bnot(1.5)", 4294967293d)] + [TestCase("bor(1.5,0.5)", 2d)] + [TestCase("bxor(1.5,2.5)", 0d)] + [TestCase("lshift(1.5,1.5)", 4d)] + [TestCase("rshift(1.5,-1.5)", 4d)] + [TestCase("arshift(1.5,1.5)", 1d)] + [TestCase("lrotate(1.5,-1.5)", 1d)] + [TestCase("rrotate(1.5,-1.5)", 4d)] + [TestCase("extract(15.5,1.5,2.9)", 0d)] + [TestCase("replace(0.5,3.5,1.5,2.9)", 0d)] + [TestCase("extract(0xffffffff,0,32)", 4294967295d)] + [TestCase("replace(0,0xffffffff,0,32)", 4294967295d)] + public async Task FractionalWordsAndIntegerArgumentsFollowLua52( + string expression, + double expected + ) + { + using var state = CreateState(); + var result = await state.DoStringAsync("return bit32." + expression); + Assert.That(result[0].Read(), Is.EqualTo(expected)); + await state.DoStringAsync("assert(not bit32.btest(0.5)); assert(bit32.btest(1.5))"); + } } diff --git a/tests/Lua.Tests/ManagedNumericTextTests.cs b/tests/Lua.Tests/ManagedNumericTextTests.cs new file mode 100644 index 00000000..0ea61c4d --- /dev/null +++ b/tests/Lua.Tests/ManagedNumericTextTests.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using Lua.Standard; + +namespace Lua.Tests; + +public class ManagedNumericTextTests +{ + [TestCase(1d / 3)] + [TestCase(1e20)] + [TestCase(double.Epsilon)] + [TestCase(double.PositiveInfinity)] + [TestCase(double.NegativeInfinity)] + [TestCase(double.NaN)] + public async Task ImplicitTextKeepsInvariantManagedDefault(double number) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["number"] = number; + var culture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var values = await state.DoStringAsync( + "return tostring(number),string.format('%s',number),number..'',table.concat({number})" + ); + Assert.That( + values.Select(v => v.Read()), + Is.All.EqualTo(number.ToString(CultureInfo.InvariantCulture)) + ); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } +} diff --git a/tests/Lua.Tests/MathematicsLibraryTests.cs b/tests/Lua.Tests/MathematicsLibraryTests.cs index 10dee4fd..14d0ba9a 100644 --- a/tests/Lua.Tests/MathematicsLibraryTests.cs +++ b/tests/Lua.Tests/MathematicsLibraryTests.cs @@ -123,4 +123,27 @@ local function sequence(seed) """ ); } + + [Test] + public async Task RandomAcceptsLua52FractionalBoundsAndRejectsInvalidIntervals() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + math.randomseed(42) + for i=1,100 do + local x=math.random(1.5) + assert(x==1 or x==2) + local y=math.random(0.5,2.5) + assert(y==0.5 or y==1.5 or y==2.5) + end + assert(not pcall(math.random,1,2,3)) + assert(not pcall(math.random,0/0)) + assert(not pcall(math.random,0/0,2)) + assert(not pcall(math.random,1,0/0)) + assert(not pcall(math.random,0.5)) + assert(not pcall(math.random,2,1)) + """ + ); + } } diff --git a/tests/Lua.Tests/NumericCoercionTests.cs b/tests/Lua.Tests/NumericCoercionTests.cs new file mode 100644 index 00000000..9589083c --- /dev/null +++ b/tests/Lua.Tests/NumericCoercionTests.cs @@ -0,0 +1,54 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class NumericCoercionTests +{ + static LuaState CreateState() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("NaN")] + [TestCase("nan")] + [TestCase("Infinity")] + [TestCase("-Infinity")] + [TestCase("2\0")] + [TestCase("2\0\0 ")] + [TestCase("0x2\0")] + [TestCase("\u00a02")] + [TestCase("2\u00a0")] + [TestCase("0x2\u00a0")] + [TestCase("\u20032")] + [TestCase("2\u2003")] + public async Task NumericCoercionRejectsNonLuaSpellings(string text) + { + using var state = CreateState(); + state.Environment["text"] = text; + await state.DoStringAsync( + """ + assert(tonumber(text)==nil) + local ok,e=pcall(function() return text+1 end) + assert(not ok and e:find('string',1,true)) + ok,e=pcall(math.abs,text) + assert(not ok and e:find('number expected',1,true)) + """ + ); + Assert.That(state.Stack.Count, Is.Zero); + } + + [TestCase(" \f\n\r\t\v-2.5 \t", -2.5)] + [TestCase("+.5", 0.5)] + [TestCase("-0x1.8p2", -6)] + [TestCase("1e309", double.PositiveInfinity)] + public async Task LuaNumericWhitespaceSignsAndOverflowStillWork(string text, double expected) + { + using var state = CreateState(); + state.Environment["text"] = text; + var values = await state.DoStringAsync("return tonumber(text),math.abs(text)"); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + Assert.That(values[1].Read(), Is.EqualTo(Math.Abs(expected))); + } +} diff --git a/tests/Lua.Tests/NumericConversionTests.cs b/tests/Lua.Tests/NumericConversionTests.cs new file mode 100644 index 00000000..0f5a859c --- /dev/null +++ b/tests/Lua.Tests/NumericConversionTests.cs @@ -0,0 +1,78 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class NumericConversionTests +{ + static LuaState CreateState() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("101010", 2, 42d)] + [TestCase("755", 8, 493d)] + [TestCase("123456", 10, 123456d)] + [TestCase("-ff", 16, -255d)] + [TestCase("+aBc", 16, 2748d)] + [TestCase(" \t\r\n-ff\v\f ", 16, -255d)] + [TestCase("z1", 36, 1261d)] + [TestCase("ffffffffffffffff", 36, 3.410854761405601E24)] + [TestCase("18446744073709551616", 10, 18446744073709551616d)] + public async Task ExplicitBaseAccumulatesDigitsLikeLua52( + string text, + int radix, + double expected + ) + { + using var state = CreateState(); + var values = await state.CallAsync(state.Environment["tonumber"], [text, radix]); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + Assert.That(state.Stack.Count, Is.Zero); + } + + [TestCase("?", 2)] + [TestCase("g", 16)] + [TestCase("1.5", 10)] + [TestCase("1.5", 16)] + [TestCase("1e2", 10)] + [TestCase("0x10", 16)] + [TestCase("0x1p2", 16)] + [TestCase("10 1", 2)] + [TestCase("- 12", 10)] + [TestCase("\u00a010\u00a0", 10)] + [TestCase("10\0", 10)] + [TestCase("", 10)] + [TestCase("+", 16)] + [TestCase("-", 36)] + public async Task ExplicitBaseRejectsMalformedIntegers(string text, int radix) + { + using var state = CreateState(); + var values = await state.CallAsync(state.Environment["tonumber"], [text, radix]); + Assert.That(values[0], Is.EqualTo(LuaValue.Nil)); + } + + [Test] + public async Task NumberConversionKeepsNumericNaNAndSeparatesExplicitBaseParsing() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local nan=0/0 + assert(tonumber(nan)~=tonumber(nan)) + assert(tonumber(math.huge)==math.huge) + assert(tonumber(123,16)==291) + assert(tonumber(123,2)==nil) + assert(tonumber(1e14,10)==1e14) -- retains the managed numeric-text policy + assert(tonumber('+12',10.9)==12) + assert(tonumber('0x10',nil)==16) + assert(tonumber(false)==nil) + assert(not pcall(tonumber,{},10)) + assert(not pcall(tonumber,'12',1)) + assert(not pcall(tonumber,'12',37)) + assert(1/tonumber('-0',16)==-math.huge) + """ + ); + } +} diff --git a/tests/Lua.Tests/NumericLibraryRegressionTests.cs b/tests/Lua.Tests/NumericLibraryRegressionTests.cs new file mode 100644 index 00000000..31c631fb --- /dev/null +++ b/tests/Lua.Tests/NumericLibraryRegressionTests.cs @@ -0,0 +1,83 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class NumericLibraryRegressionTests +{ + static LuaState CreateState() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + return state; + } + + [Test] + public async Task NumberArgumentsKeepConversionAndErrors() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + assert(math.abs('-3') == 3) + assert(math.sqrt('0x10') == 4) + assert(math.max('1', '2', '3') == 3) + assert(not pcall(math.abs)) + assert(not pcall(math.abs, nil)) + assert(not pcall(math.abs, false)) + local ok, message = pcall(math.max, 1, {}) + assert(not ok and string.find(message, '#2', 1, true)) + """ + ); + } + + [Test] + public async Task MathKeepsLua52NanAndOptionalArgumentRules() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local nan = 0/0 + assert(math.max(1, nan) == 1) + assert(math.min(1, nan) == 1) + local m = math.max(nan, 1) + assert(m ~= m) + m = math.min(nan, 1) + assert(m ~= m) + assert(1/math.max(-0.0, 0.0) == -math.huge) + assert(1/math.min(0.0, -0.0) == math.huge) + assert(math.log(2, nil) == math.log(2)) + """ + ); + } + + [Test] + public async Task LogarithmKeepsLua52BaseEdgeCases() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + for _, base in ipairs({0.0, -0.0, '0'}) do + local negativeZero = math.log(2, base) + assert(negativeZero == 0 and 1/negativeZero == -math.huge) + local positiveZero = math.log(0.5, base) + assert(positiveZero == 0 and 1/positiveZero == math.huge) + local one = math.log(1, base) + assert(one == 0 and 1/one == -math.huge) + local zero = math.log(0, base) + assert(zero ~= zero) + local negative = math.log(-1, base) + assert(negative ~= negative) + local infinite = math.log(math.huge, base) + assert(infinite ~= infinite) + end + assert(math.log(2, 1) == math.huge) + assert(math.log(0.5, 1) == -math.huge) + local undefined = math.log(1, 1) + assert(undefined ~= undefined) + assert(math.log(8, 2) == 3) + assert(math.log(100, 10) == 2) + assert(math.log(2, nil) == math.log(2)) + assert(not pcall(math.log, 2, false)) + """ + ); + } +} diff --git a/tests/Lua.Tests/SelectTests.cs b/tests/Lua.Tests/SelectTests.cs new file mode 100644 index 00000000..8ea441d6 --- /dev/null +++ b/tests/Lua.Tests/SelectTests.cs @@ -0,0 +1,60 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class SelectTests +{ + static LuaState CreateState() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("1", 2)] + [TestCase("2", 1)] + [TestCase("3", 0)] + [TestCase("4", 0)] + [TestCase("2147483647", 0)] + [TestCase("-1", 1)] + [TestCase("-2", 2)] + [TestCase("1.5", 2)] + [TestCase("'2'", 1)] + public async Task SelectReturnsTheExpectedNumberOfValues(string selector, int count) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return select({selector},'a','b')"); + Assert.That(values.Length, Is.EqualTo(count)); + if (count > 0) + Assert.That(values[^1].Read(), Is.EqualTo("b")); + Assert.That(state.Stack.Count, Is.Zero); + } + + [TestCase("0")] + [TestCase("-3")] + [TestCase("-2147483648")] + [TestCase("0.5")] + [TestCase("0/0")] + [TestCase("math.huge")] + public async Task SelectRejectsInvalidIndicesWithLuaRangeErrors(string selector) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return pcall(select,{selector},'a','b')"); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("index out of range")); + } + + [Test] + public async Task SelectPreservesNilValuesAndRecognizesCountPrefixes() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + assert(select('#anything','a',nil)==2) + assert(select('#anything')==0) + local t=table.pack(select(2,'skip',nil,3,nil)) + assert(t.n==3 and t[1]==nil and t[2]==3 and t[3]==nil) + """ + ); + } +}