diff --git a/src/Lua/Internal/HexConverter.cs b/src/Lua/Internal/HexConverter.cs index 071cad64..453ee396 100644 --- a/src/Lua/Internal/HexConverter.cs +++ b/src/Lua/Internal/HexConverter.cs @@ -143,4 +143,85 @@ public static string FromDouble(double value) var mantissaHex = mantissa.ToString("x13").TrimEnd('0'); return $"{sign}0x{leadingDigit}.{mantissaHex}p{adjustedExponent:+0;-0}"; } + + internal static string FormatDouble( + double value, + int precision, + bool alternateForm, + bool upperCase + ) + { + string text; + if (double.IsNaN(value) || double.IsInfinity(value)) + { + text = value.ToString(CultureInfo.InvariantCulture); + } + else + { + text = FormatFinite(value, precision, alternateForm); + } + return upperCase ? text.ToUpperInvariant() : text; + } + + static string FormatFinite(double value, int precision, bool alternateForm) + { + const ulong fractionMask = (1UL << 52) - 1; + var bits = BitConverter.DoubleToInt64Bits(value); + var exponent = (int)((bits >> 52) & 0x7ff); + var significand = ((ulong)bits & fractionMask) | (exponent == 0 ? 0 : 1UL << 52); + var adjustedExponent = exponent == 0 ? (significand == 0 ? 0 : -1022) : exponent - 1023; + + if (precision is >= 0 and < 13) + { + var shift = 52 - precision * 4; + var retained = significand >> shift; + var discarded = significand & ((1UL << shift) - 1); + var halfway = 1UL << (shift - 1); + // Round to nearest, with ties to an even final hexadecimal digit. + if (discarded > halfway || (discarded == halfway && (retained & 1) != 0)) + retained++; + significand = retained << shift; + } + + var sign = bits < 0 ? "-" : ""; + var leadingDigit = (char)('0' + (significand >> 52)); + var fraction = significand & fractionMask; + var digits = ""; + if (precision != 0 && (precision >= 0 || fraction != 0)) + { + digits = fraction.ToString("x13", CultureInfo.InvariantCulture); + digits = + precision < 0 ? digits.TrimEnd('0') + : precision < 13 ? digits[..precision] + : digits.PadRight(precision, '0'); + } +#if NET6_0_OR_GREATER + if (digits.Length == 0 && !alternateForm) + return string.Create( + CultureInfo.InvariantCulture, + $"{sign}0x{leadingDigit}p{adjustedExponent:+0;-0}" + ); + return string.Create( + CultureInfo.InvariantCulture, + $"{sign}0x{leadingDigit}.{digits}p{adjustedExponent:+0;-0}" + ); +#else + if (digits.Length == 0 && !alternateForm) + return string.Format( + CultureInfo.InvariantCulture, + "{0}0x{1}p{2:+0;-0}", + sign, + leadingDigit, + adjustedExponent + ); + return string.Format( + CultureInfo.InvariantCulture, + "{0}0x{1}.{2}p{3:+0;-0}", + sign, + leadingDigit, + digits, + adjustedExponent + ); +#endif + } } diff --git a/src/Lua/Internal/StringHelper.cs b/src/Lua/Internal/StringHelper.cs index 9fe008c3..2aaa1da4 100644 --- a/src/Lua/Internal/StringHelper.cs +++ b/src/Lua/Internal/StringHelper.cs @@ -8,10 +8,8 @@ static class StringHelper [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ReadOnlySpan Slice(string s, int i, int j) { - if (i < 0) - i = s.Length + i + 1; - if (j < 0) - j = s.Length + j + 1; + i = RelativePosition(i, s.Length); + j = RelativePosition(j, s.Length); if (i < 1) i = 1; diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index 9a11b54d..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 @@ -769,8 +780,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/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/Internal/MatchState.cs b/src/Lua/Standard/Internal/MatchState.cs index 3aed4afb..ff7da8a9 100644 --- a/src/Lua/Standard/Internal/MatchState.cs +++ b/src/Lua/Standard/Internal/MatchState.cs @@ -25,9 +25,21 @@ internal struct Capture public readonly string Source = source; public readonly string Pattern = pattern; public int Level = 0; + + // Only slots below Level are live; StartCapture initializes them before use. internal readonly Capture[] Captures = new Capture[LuaMaxCaptures]; public int MatchDepth = MaxCalls; + public LuaValue GetCapture(int index) + { + var capture = Captures[index]; + if (capture.IsUnfinished) + throw new LuaRuntimeException(State, "unfinished capture"); + return capture.IsPosition + ? new LuaValue(capture.Init + 1) + : new LuaValue(Source.AsSpan(capture.Init, capture.Len).ToString()); + } + public static bool NoSpecials(ReadOnlySpan pattern) { #if NET8_0_OR_GREATER 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/src/Lua/Standard/StringLibrary.cs b/src/Lua/Standard/StringLibrary.cs index 3a18c469..bf2b9696 100644 --- a/src/Lua/Standard/StringLibrary.cs +++ b/src/Lua/Standard/StringLibrary.cs @@ -43,13 +43,10 @@ CancellationToken cancellationToken ) { var s = context.GetArgument(0); - var i = context.HasArgument(1) ? context.GetArgument(1) : 1; - var j = context.HasArgument(2) ? context.GetArgument(2) : i; + var i = context.HasArgument(1) ? ReadStringIndex(context, 1) : 1; + var j = context.HasArgument(2) ? ReadStringIndex(context, 2) : i; - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 3, j); - - var span = StringHelper.Slice(s, (int)i, (int)j); + var span = StringHelper.Slice(s, i, j); var buffer = context.GetReturnBuffer(span.Length); for (var k = 0; k < span.Length; k++) { @@ -71,19 +68,26 @@ CancellationToken cancellationToken var byteStrings = context.State.Platform.StringMode == LuaStringMode.Bytes; ValueStringBuilder builder = new(context.ArgumentCount); - for (var i = 0; i < context.ArgumentCount; i++) + try { - var arg = context.GetArgument(i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, i + 1, arg); - if (byteStrings && (arg < 0 || arg > 255)) + for (var i = 0; i < context.ArgumentCount; i++) { - LuaRuntimeException.BadArgument(context.State, i + 1, "value out of range"); + var arg = Math.Truncate(context.GetArgument(i)); + LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, i + 1, arg); + if (byteStrings && (arg < 0 || arg > 255)) + { + LuaRuntimeException.BadArgument(context.State, i + 1, "value out of range"); + } + + builder.Append((char)arg); } - builder.Append((char)arg); + return new(context.Return(builder.ToString())); + } + finally + { + builder.Dispose(); } - - return new(context.Return(builder.ToString())); } public ValueTask Dump( @@ -119,6 +123,8 @@ CancellationToken cancellationToken if (format[i] == '%') { i++; + if (i == format.Length) + throw new LuaRuntimeException(context.State, "invalid option '%' to 'format'"); // escape if (format[i] == '%') @@ -135,123 +141,54 @@ CancellationToken cancellationToken var width = 0; var precision = -1; - // Process flags - while (true) + // Lua 5.2 permits repeated flags, with at most five in total. + var flagCount = 0; + while (i < format.Length) { - var c = format[i]; - switch (c) + switch (format[i]) { case '-': - if (leftJustify) - { - throw new LuaRuntimeException( - context.State, - "invalid format (repeated flags)" - ); - } - leftJustify = true; break; case '+': - if (plusSign) - { - throw new LuaRuntimeException( - context.State, - "invalid format (repeated flags)" - ); - } - plusSign = true; break; case '0': - if (zeroPadding) - { - throw new LuaRuntimeException( - context.State, - "invalid format (repeated flags)" - ); - } - zeroPadding = true; break; case '#': - if (alternateForm) - { - throw new LuaRuntimeException( - context.State, - "invalid format (repeated flags)" - ); - } - alternateForm = true; break; case ' ': - if (blank) - { - throw new LuaRuntimeException( - context.State, - "invalid format (repeated flags)" - ); - } - blank = true; break; default: goto PROCESS_WIDTH; } - + if (++flagCount > 5) + throw new LuaRuntimeException( + context.State, + "invalid format (repeated flags)" + ); i++; } PROCESS_WIDTH: // Process width - var start = i; - if (char.IsDigit(format[i])) - { - i++; - if (char.IsDigit(format[i])) - { - i++; - } - - if (char.IsDigit(format[i])) - { - throw new LuaRuntimeException( - context.State, - "invalid format (width or precision too long)" - ); - } - - width = int.Parse(format.AsSpan()[start..i]); - } + if (i < format.Length && format[i] is >= '0' and <= '9') + width = ReadFormatDigits(context.State, format, ref i); // Process precision - if (format[i] == '.') + if (i < format.Length && format[i] == '.') { i++; - start = i; - if (char.IsDigit(format[i])) - { - i++; - } - - if (char.IsDigit(format[i])) - { - i++; - } - - if (char.IsDigit(format[i])) - { - throw new LuaRuntimeException( - context.State, - "invalid format (width or precision too long)" - ); - } - - precision = int.Parse(format.AsSpan()[start..i]); + precision = ReadFormatDigits(context.State, format, ref i); } + if (i == format.Length) + throw new LuaRuntimeException(context.State, "invalid option '%' to 'format'"); + // Process conversion specifier var specifier = format[i]; @@ -269,54 +206,84 @@ CancellationToken cancellationToken string formattedValue = default!; switch (specifier) { + case 'a': + case 'A': + if (!parameter.TryRead(out var a)) + LuaRuntimeException.BadArgument( + context.State, + parameterIndex, + LuaValueType.Number, + parameter.Type + ); + + formattedValue = HexConverter.FormatDouble( + a, + precision, + alternateForm, + specifier == 'A' + ); + if (BitConverter.DoubleToInt64Bits(a) >= 0) + { + if (plusSign) + formattedValue = "+" + formattedValue; + else if (blank) + formattedValue = " " + formattedValue; + } + if (double.IsNaN(a) || double.IsInfinity(a)) + zeroPadding = false; + break; + case 'o': + if (!parameter.TryRead(out var octal)) + LuaRuntimeException.BadArgument( + context.State, + parameterIndex, + LuaValueType.Number, + parameter.Type + ); + if (!(octal > -1 && octal < 18446744073709551616d)) + LuaRuntimeException.BadArgument( + context.State, + parameterIndex, + "not a non-negative number in proper range" + ); + + var unsigned = (ulong)Math.Truncate(octal); + formattedValue = + unsigned == 0 && precision == 0 + ? "" + : Convert.ToString(unchecked((long)unsigned), 8); + if (precision > formattedValue.Length) + formattedValue = formattedValue.PadLeft(precision, '0'); + if ( + alternateForm + && (formattedValue.Length == 0 || formattedValue[0] != '0') + ) + formattedValue = "0" + formattedValue; + if (precision >= 0) + zeroPadding = false; + break; case 'f': case 'e': + case 'E': case 'g': case 'G': if (!parameter.TryRead(out var f)) - { LuaRuntimeException.BadArgument( context.State, - parameterIndex + 1, + parameterIndex, LuaValueType.Number, parameter.Type ); - } - - switch (specifier) - { - case 'f': - formattedValue = - precision < 0 - ? f.ToString(CultureInfo.InvariantCulture) - : f.ToString($"F{precision}", CultureInfo.InvariantCulture); - break; - case 'e': - formattedValue = - precision < 0 - ? f.ToString(CultureInfo.InvariantCulture) - : f.ToString($"E{precision}", CultureInfo.InvariantCulture); - break; - case 'g': - formattedValue = - precision < 0 - ? f.ToString(CultureInfo.InvariantCulture) - : f.ToString($"G{precision}", CultureInfo.InvariantCulture); - break; - case 'G': - formattedValue = - precision < 0 - ? f.ToString(CultureInfo.InvariantCulture).ToUpper() - : f.ToString($"G{precision}", CultureInfo.InvariantCulture) - .ToUpper(); - break; - } - - if (plusSign && f >= 0) + formattedValue = FormatFloat(f, specifier, precision, alternateForm); + if (double.IsNaN(f) || double.IsInfinity(f)) + zeroPadding = false; + if (BitConverter.DoubleToInt64Bits(f) >= 0) { - formattedValue = $"+{formattedValue}"; + if (plusSign) + formattedValue = "+" + formattedValue; + else if (blank) + formattedValue = " " + formattedValue; } - break; case 's': { @@ -324,7 +291,7 @@ CancellationToken cancellationToken formattedValue = stack.Pop().Read(); } - if (specifier is 's' && precision > 0 && precision <= formattedValue.Length) + if (precision >= 0 && precision < formattedValue.Length) { formattedValue = formattedValue[..precision]; } @@ -333,45 +300,21 @@ CancellationToken cancellationToken case 'q': switch (parameter.Type) { - case LuaValueType.Nil: - formattedValue = "nil"; - break; - case LuaValueType.Boolean: - formattedValue = parameter.Read() ? "true" : "false"; - break; case LuaValueType.String: - formattedValue = StringHelper.Quote(parameter.Read()); - break; case LuaValueType.Number: - formattedValue = DoubleToQFormat(parameter.Read()); - - static string DoubleToQFormat(double value) - { - if (MathEx.IsInteger(value)) - { - return value.ToString(CultureInfo.InvariantCulture); - } - - return HexConverter.FromDouble(value); - } - + formattedValue = StringHelper.Quote(parameter.ToString()); break; default: - { - var top = stack.Count; - stack.Push(default); - await parameter.CallToStringAsync( - context with - { - ReturnFrameBase = top, - }, - cancellationToken - ); - formattedValue = stack.Pop().Read(); - } + LuaRuntimeException.BadArgument( + context.State, + parameterIndex, + LuaValueType.String, + parameter.Type + ); break; } + width = 0; // Lua ignores width/precision/flags for %q. break; case 'i': case 'd': @@ -380,78 +323,79 @@ context with case 'x': case 'X': if (!parameter.TryRead(out var x)) - { LuaRuntimeException.BadArgument( context.State, - parameterIndex + 1, + parameterIndex, LuaValueType.Number, parameter.Type ); + x = Math.Truncate(x); + var signed = specifier is 'i' or 'd'; + if ( + !MathEx.IsFinite(x) + || ( + specifier != 'c' + && ( + signed + ? x < long.MinValue || x >= 9223372036854775808d + : x < 0 || x >= 18446744073709551616d + ) + ) + ) + LuaRuntimeException.BadArgument( + context.State, + parameterIndex, + signed || specifier == 'c' + ? "not a number in proper range" + : "not a non-negative number in proper range" + ); + if (specifier == 'c') + { + formattedValue = ( + context.State.Platform.StringMode == LuaStringMode.Bytes + ? (char)((int)x & 0xFF) + : (char)(int)x + ).ToString(); } - - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger( - context.State, - parameterIndex + 1, - x - ); - - switch (specifier) + else { - case 'i': - case 'd': + if (signed) + { + var integer = (long)x; + formattedValue = + integer == 0 && precision == 0 + ? "" + : integer.ToString( + precision < 0 ? "D" : $"D{precision}", + CultureInfo.InvariantCulture + ); + if (integer >= 0) { - var integer = checked((long)x); - formattedValue = - precision < 0 - ? integer.ToString() - : integer.ToString($"D{precision}"); + if (plusSign) + formattedValue = "+" + formattedValue; + else if (blank) + formattedValue = " " + formattedValue; } - break; - case 'u': - { - var integer = checked((ulong)x); + } + else + { + var integer = (ulong)x; + formattedValue = + integer == 0 && precision == 0 + ? "" + : integer.ToString( + specifier is 'x' or 'X' ? specifier.ToString() : "D", + CultureInfo.InvariantCulture + ); + if (precision > formattedValue.Length) + formattedValue = formattedValue.PadLeft(precision, '0'); + if (alternateForm && integer != 0 && specifier is 'x' or 'X') formattedValue = - precision < 0 - ? integer.ToString() - : integer.ToString($"D{precision}"); - } - break; - case 'c': - formattedValue = ( - context.State.Platform.StringMode == LuaStringMode.Bytes - ? (char)((int)x & 0xFF) - : (char)(int)x - ).ToString(); - break; - case 'x': - { - var integer = checked((ulong)x); - formattedValue = alternateForm - ? $"0x{integer:x}" - : $"{integer:x}"; - } - break; - case 'X': - { - var integer = checked((ulong)x); - formattedValue = alternateForm - ? $"0X{integer:X}" - : $"{integer:X}"; - } - break; - case 'o': - { - var integer = checked((long)x); - formattedValue = Convert.ToString(integer, 8); - } - break; - } - - if (plusSign && x >= 0) - { - formattedValue = $"+{formattedValue}"; + (specifier == 'x' ? "0x" : "0X") + formattedValue; + } + if (precision >= 0) + zeroPadding = false; } - break; default: throw new LuaRuntimeException( @@ -460,15 +404,6 @@ context with ); } - // Apply blank (' ') flag for positive numbers - if (specifier is 'd' or 'i' or 'f' or 'g' or 'G') - { - if (blank && !leftJustify && !zeroPadding && parameter.Read() >= 0) - { - formattedValue = $" {formattedValue}"; - } - } - // Apply width and padding if (width > formattedValue.Length) { @@ -478,9 +413,28 @@ context with } else { - formattedValue = zeroPadding - ? formattedValue.PadLeft(width, '0') - : formattedValue.PadLeft(width); + if (zeroPadding) + { + var prefix = + specifier is not ('c' or 's') + && formattedValue.Length > 0 + && formattedValue[0] is '-' or '+' or ' ' + ? 1 + : 0; + if ( + specifier is 'a' or 'A' or 'x' or 'X' + && formattedValue + .AsSpan(prefix) + .StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ) + prefix += 2; + formattedValue = formattedValue.Insert( + prefix, + new string('0', width - formattedValue.Length) + ); + } + else + formattedValue = formattedValue.PadLeft(width); } } @@ -518,20 +472,14 @@ CancellationToken cancellationToken MatchState matchState = new(context.State, s, pattern); var captures = matchState.Captures; - // Check for anchor at start - var anchor = pattern.Length > 0 && pattern[0] == '^'; - var pIdx = anchor ? 1 : 0; - // Include the end position so patterns can match an empty string there. for (var sIdx = start; sIdx <= s.Length; sIdx++) { // Reset match state for each attempt matchState.Level = 0; matchState.MatchDepth = MatchState.MaxCalls; - // Clear captures to avoid stale data - Array.Clear(captures, 0, captures.Length); - var res = matchState.Match(sIdx, pIdx); + var res = matchState.Match(sIdx, 0); if (res >= 0) { @@ -547,15 +495,7 @@ CancellationToken cancellationToken var buffer = context.GetReturnBuffer(resultLength); for (var i = 0; i < matchState.Level; i++) { - var capture = captures[i]; - if (capture.IsPosition) - { - buffer[i] = capture.Init + 1; // 1-based position - } - else - { - buffer[i] = s.AsSpan(capture.Init, capture.Len).ToString(); - } + buffer[i] = matchState.GetCapture(i); } // Update start index for next iteration @@ -563,15 +503,9 @@ CancellationToken cancellationToken upValues[2] = res > sIdx ? res : sIdx + 1; return new(resultLength); } - - // For anchored patterns, only try once - if (anchor) - { - break; - } } - return new(context.Return(LuaValue.Nil)); + return new(context.Return()); } ) ) @@ -650,18 +584,36 @@ CancellationToken cancellationToken var repl = context.GetArgument(2); var n_arg = context.HasArgument(3) ? context.GetArgument(3) : s.Length + 1; - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 4, n_arg); - - var n = (int)n_arg; + n_arg = Math.Truncate(n_arg); + if (!MathEx.IsFinite(n_arg)) + LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, 4); + if ( + repl.Type + is not ( + LuaValueType.String + or LuaValueType.Number + or LuaValueType.Table + or LuaValueType.Function + ) + ) + LuaRuntimeException.BadArgument(context.State, 3, "string/function/table expected"); + // Lua 5.2 stores the limit as size_t; a negative integer means no practical limit. + var n = n_arg < 0 || n_arg > int.MaxValue ? int.MaxValue : (int)n_arg; + if (n == 0) + return context.Return(s, 0); - // Use MatchState instead of regex MatchState matchState = new(context.State, s, pattern); var captures = matchState.Captures; StringBuilder builder = new(); + var replacementText = + repl.Type == LuaValueType.String ? repl.UnsafeReadString() + : repl.Type == LuaValueType.Number + ? repl.UnsafeReadDouble().ToString(default, CultureInfo.InvariantCulture) + : null; var replacedBuilder = - repl.Type == LuaValueType.String - ? new StringBuilder(repl.UnsafeReadString().Length) + replacementText != null && replacementText.IndexOf('%') >= 0 + ? new StringBuilder(replacementText.Length) : null; var lastIndex = 0; var replaceCount = 0; @@ -676,11 +628,6 @@ CancellationToken cancellationToken // Reset match state for each attempt matchState.Level = 0; Debug.Assert(matchState.MatchDepth == MatchState.MaxCalls); - // Clear captures array to avoid stale data - for (var i = 0; i < captures.Length; i++) - { - captures[i] = default; - } // Always start pattern from beginning (0 or 1 if anchored) var pIdx = anchor ? 1 : 0; @@ -700,34 +647,30 @@ CancellationToken cancellationToken } LuaValue result; - if (repl.TryRead(out var str)) + if (replacementText != null) { - if (!str.Contains("%")) + if (replacedBuilder == null) { - result = str; // No special characters, use as is + result = replacementText; } else { // String replacement - replacedBuilder!.Clear(); - AppendReplacement(replacedBuilder, str, s, sIdx, res, matchState); + replacedBuilder.Clear(); + AppendReplacement( + replacedBuilder, + replacementText, + s, + sIdx, + res, + matchState + ); result = replacedBuilder.ToString(); } } else if (repl.TryRead(out var table)) { - // Table lookup - use first capture or whole match - LuaValue key; - if (matchState.Level > 0) - { - key = captures[0].IsPosition - ? captures[0].Init + 1 - : s.AsSpan(captures[0].Init, captures[0].Len).ToString(); - } - else - { - key = s.AsSpan(sIdx, res - sIdx).ToString(); - } + var key = matchState.GetCapture(0); if (!table.TryGetValue(key, out result) && table.Metatable != null) { @@ -739,52 +682,24 @@ CancellationToken cancellationToken ); } } - else if (repl.TryRead(out var func)) + else { + var func = repl.UnsafeRead(); // Function call with captures as arguments var stack = context.State.Stack; - if (matchState.Level == 0) + for (var k = 0; k < matchState.Level; k++) { - // No captures, pass whole match - stack.Push(s.AsSpan(sIdx, res - sIdx).ToString()); - var retCount = await context.State.RunAsync(func, 1, cancellationToken); - using var results = context.State.ReadStack(retCount); - result = results.Count > 0 ? results[0] : LuaValue.Nil; - } - else - { - // Pass all captures - for (var k = 0; k < matchState.Level; k++) - { - var capture = captures[k]; - if (capture.IsPosition) - { - stack.Push(capture.Init + 1); // 1-based position - } - else - { - stack.Push(s.AsSpan(capture.Init, capture.Len).ToString()); - } - } - - var retCount = await context.State.RunAsync( - func, - matchState.Level, - cancellationToken - ); - using var results = context.State.ReadStack(retCount); - result = results.Count > 0 ? results[0] : LuaValue.Nil; + stack.Push(matchState.GetCapture(k)); } - } - else - { - throw new LuaRuntimeException( - context.State, - "bad argument #3 to 'gsub' (string/function/table expected)" + var retCount = await context.State.RunAsync( + func, + matchState.Level, + cancellationToken ); + using var results = context.State.ReadStack(retCount); + result = results.Count > 0 ? results[0] : LuaValue.Nil; } - // Handle replacement result if (result.TryRead(out var rs)) { @@ -792,7 +707,7 @@ CancellationToken cancellationToken } else if (result.TryRead(out var rd)) { - builder.Append(rd); + AppendNumber(builder, rd); } else if (!result.ToBoolean()) { @@ -803,7 +718,7 @@ CancellationToken cancellationToken { throw new LuaRuntimeException( context.State, - $"invalid replacement value (a {result.Type})" + $"invalid replacement value (a {result.TypeToString()})" ); } @@ -890,33 +805,22 @@ public ValueTask FindAux(LuaFunctionExecutionContext context, bool find) { var s = context.GetArgument(0); var pattern = context.GetArgument(1); - var init = context.HasArgument(2) ? context.GetArgument(2) : 1; - - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 3, init); - - // Convert to 0-based index - if (init < 0) - { - init = s.Length + init + 1; - } - - init--; // Convert from 1-based to 0-based + var init = context.HasArgument(2) ? ReadStringIndex(context, 2) : 1; + var offset = Math.Max(1, StringHelper.RelativePosition(init, s.Length)) - 1; // Check if init is beyond string bounds - if (init > s.Length) + if (offset > s.Length) { return new(context.Return(LuaValue.Nil)); } - init = Math.Max(0, init); // Clamp to 0 if negative - // Check for plain search mode (4th parameter = true) or if pattern has no special characters if (find && (context.GetArgumentOrDefault(3).ToBoolean() || MatchState.NoSpecials(pattern))) { - return PlainSearch(context, s, pattern, init); + return PlainSearch(context, s, pattern, offset); } - return PatternSearch(context, s, pattern, init, find); + return PatternSearch(context, s, pattern, offset, find); } static ValueTask PlainSearch( @@ -957,7 +861,6 @@ bool find // Reset match state for each attempt matchState.Level = 0; matchState.MatchDepth = MatchState.MaxCalls; - Array.Clear(captures, 0, captures.Length); var res = matchState.Match(sIdx, pIdx); @@ -985,15 +888,7 @@ bool find // Return captures for (var i = 0; i < matchState.Level; i++) { - var capture = captures[i]; - if (capture.IsPosition) - { - buffer[i] = capture.Init + 1; // 1-based position - } - else - { - buffer[i] = s.AsSpan(capture.Init, capture.Len).ToString(); - } + buffer[i] = matchState.GetCapture(i); } return new(resultLength); @@ -1023,7 +918,9 @@ CancellationToken cancellationToken LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, 2); } - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, n_arg); + // Lua 5.2 converts fractional counts to integers instead of rejecting + // them. Truncate before the range checks and result-length calculation. + n_arg = Math.Truncate(n_arg); if (n_arg <= 0 || (s.Length == 0 && string.IsNullOrEmpty(sep))) { @@ -1098,13 +995,10 @@ CancellationToken cancellationToken ) { var s = context.GetArgument(0); - var i = context.GetArgument(1); - var j = context.HasArgument(2) ? context.GetArgument(2) : -1; - - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, i); - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 3, j); + var i = ReadStringIndex(context, 1); + var j = context.HasArgument(2) ? ReadStringIndex(context, 2) : -1; - var slice = StringHelper.Slice(s, (int)i, (int)j); + var slice = StringHelper.Slice(s, i, j); return new(context.Return(slice.Length == s.Length ? s : slice.ToString())); } @@ -1122,4 +1016,81 @@ CancellationToken cancellationToken ) ); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static int ReadStringIndex(in LuaFunctionExecutionContext context, int index) + { + var number = context.GetArgument(index); + if (!MathEx.IsFinite(number)) + LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, index + 1); + // Indices outside Int32 cannot address a CLR string. Saturate before + // relative indexing, including on targets predating saturating casts. +#if NET10_0_OR_GREATER + return (int)number; +#else + return (int)Math.Min(int.MaxValue, Math.Max(int.MinValue, number)); +#endif + } + + static void AppendNumber(StringBuilder builder, double value) + { + Span buffer = stackalloc char[32]; + value.TryFormat(buffer, out var written, default, CultureInfo.InvariantCulture); + builder.Append(buffer[..written]); + } + + static string FormatFloat(double value, char specifier, int precision, bool alternateForm) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + var special = value.ToString(CultureInfo.InvariantCulture); + return specifier is 'E' or 'G' ? special.ToUpperInvariant() : special; + } + var general = specifier is 'g' or 'G'; + precision = + precision < 0 ? 6 + : general ? Math.Max(1, precision) + : precision; + var text = value.ToString($"{specifier}{precision}", CultureInfo.InvariantCulture); + var exponent = text.AsSpan().IndexOfAny('e', 'E'); + // .NET's scientific format has three exponent digits; C requires at least two. + if (exponent >= 0 && text.Length - exponent == 5 && text[exponent + 2] == '0') + text = text.Remove(exponent + 2, 1); + if (!alternateForm) + return text; + var end = exponent < 0 ? text.Length : exponent; + if (text.AsSpan(0, end).IndexOf('.') < 0) + { + text = text.Insert(end, "."); + end++; + } + if (general) + { + var significant = 0; + for (var j = 0; j < end; j++) + if (text[j] is >= '0' and <= '9' && (text[j] != '0' || significant > 0)) + significant++; + if (significant == 0) + significant = 1; + if (precision > significant) + text = text.Insert(end, new string('0', precision - significant)); + } + return text; + } + + static int ReadFormatDigits(LuaState state, string format, ref int index) + { + var start = index; + var value = 0; + while (index < format.Length && format[index] is >= '0' and <= '9') + { + if (index - start == 2) + throw new LuaRuntimeException( + state, + "invalid format (width or precision too long)" + ); + value = value * 10 + format[index++] - '0'; + } + return value; + } } diff --git a/src/Lua/Standard/TableLibrary.cs b/src/Lua/Standard/TableLibrary.cs index 10172e06..5362163d 100644 --- a/src/Lua/Standard/TableLibrary.cs +++ b/src/Lua/Standard/TableLibrary.cs @@ -154,8 +154,10 @@ CancellationToken cancellationToken throw new LuaRuntimeException(context.State, "wrong number of arguments to 'insert'"); } var value = context.ArgumentCount == 3 ? context.GetArgument(2) : context.GetArgument(1); - var position = context.ArgumentCount == 3 ? context.GetArgument(1) : length + 1; - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, position); + var position = + context.ArgumentCount == 3 ? Math.Truncate(context.GetArgument(1)) : length + 1; + if (!MathEx.IsFinite(position)) + LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, 2); if (context.ArgumentCount == 3 && (position < 1 || position > length + 1)) { throw new LuaRuntimeException( @@ -204,8 +206,11 @@ CancellationToken cancellationToken { var table = context.GetArgument(0); var length = await GetLengthAsync(context.State, table, cancellationToken); - var position = context.HasArgument(1) ? context.GetArgument(1) : length; - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, position); + var position = context.HasArgument(1) + ? Math.Truncate(context.GetArgument(1)) + : length; + if (!MathEx.IsFinite(position)) + LuaRuntimeException.BadArgumentNumberIsNotInteger(context.State, 2); if (position != length && (position < 1 || position > (long)length + 1)) { throw new LuaRuntimeException( @@ -239,9 +244,9 @@ CancellationToken cancellationToken ) { var table = context.GetArgument(0); - var comparer = context.HasArgument(1) ? context.GetArgument(1) : null; var length = await GetLengthAsync(context.State, table, cancellationToken); + var comparer = context.HasArgument(1) ? context.GetArgument(1) : null; // A __len result can extend beyond the backing array; use raw table keys. if (length > 1) { 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/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/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/FormatCompatibilityTests.cs b/tests/Lua.Tests/FormatCompatibilityTests.cs new file mode 100644 index 00000000..494ff99c --- /dev/null +++ b/tests/Lua.Tests/FormatCompatibilityTests.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using Lua.Standard; + +namespace Lua.Tests; + +public class FormatCompatibilityTests +{ + [TestCase("%d", "1.9", "1")] + [TestCase("%i", "-1.9", "-1")] + [TestCase("%u", "-0.5", "0")] + [TestCase("%x", "15.9", "f")] + [TestCase("%X", "15.9", "F")] + [TestCase("%c", "65.9", "A")] + [TestCase("%+08d", "-12.75", "-0000012")] + [TestCase("% 08d", "12.75", " 0000012")] + [TestCase("%+ 8d", "12.75", " +12")] + [TestCase("%+8u", "12.75", " 12")] + [TestCase("%#08x", "15.9", "0x00000f")] + [TestCase("%#08X", "15.9", "0X00000F")] + [TestCase("%#8x", "0", " 0")] + [TestCase("%08.3x", "15.9", " 00f")] + [TestCase("%.0d", "0", "")] + [TestCase("%.0u", "0", "")] + [TestCase("%.0x", "0", "")] + [TestCase("%f", "1.5", "1.500000")] + [TestCase("%e", "1.5", "1.500000e+00")] + [TestCase("%E", "1.5", "1.500000E+00")] + [TestCase("%g", "0.000012345", "1.2345e-05")] + [TestCase("%.0g", "12.75", "1e+01")] + [TestCase("%#.3g", "1.5", "1.50")] + [TestCase("%#.3g", "0.0015", "0.00150")] + [TestCase("%#.3g", "0", "0.00")] + [TestCase("%#.0f", "1.5", "2.")] + [TestCase("%#.0e", "1.5", "2.e+00")] + [TestCase("%f", "-0.0", "-0.000000")] + [TestCase("%++d", "2", "+2")] + [TestCase("%+++++d", "2", "+2")] + [TestCase("%08s", "'0x'", "0000000x")] + [TestCase("%08s", "'-x'", "000000-x")] + [TestCase("%08c", "45", "0000000-")] + [TestCase("%20.0q", "'text'", "\"text\"")] + [TestCase("%d", "-2^63", "-9223372036854775808")] + [TestCase("%u", "2^64-2048", "18446744073709549568")] + public async Task FormatsFollowLua52(string format, string expression, string expected) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var originalCulture = CultureInfo.CurrentCulture; + try + { + var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NegativeSign = "minus"; + CultureInfo.CurrentCulture = culture; + var values = await state.DoStringAsync( + $"return string.format('{format}',{expression})" + ); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [TestCase("%d", "2^63", "2")] + [TestCase("%u", "2^64", "2")] + [TestCase("%x", "-1", "2")] + [TestCase("%f", "false", "2")] + [TestCase("%d", "false", "2")] + [TestCase("%s:%d", "'prefix',false", "3")] + public async Task FormatErrorsUseLuaExceptionsAndCorrectArgumentNumbers( + string format, + string arguments, + string number + ) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var values = await state.DoStringAsync( + $"return pcall(string.format,'{format}',{arguments})" + ); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("bad argument #" + number)); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [Test] + public async Task SixFlagsStillReject() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var values = await state.DoStringAsync("return pcall(string.format,'%++++++d',1)"); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("repeated flags")); + } +} diff --git a/tests/Lua.Tests/HexConverterTests.cs b/tests/Lua.Tests/HexConverterTests.cs index ef4246c6..c038e70d 100644 --- a/tests/Lua.Tests/HexConverterTests.cs +++ b/tests/Lua.Tests/HexConverterTests.cs @@ -56,4 +56,20 @@ public void FiniteHexFormattingRoundTripsBitExactly() ); } } + + [Test] + public async Task HexFormatPreservesSubnormalValues() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + for _,x in ipairs({0x1p-1074,-0x1p-1074,0x1.8p-1023,0x0.fffffffffffffp-1022}) do + local literal=string.format('%a',x) + assert(tonumber(literal)==x) + assert(load('return '..literal)()==x) + end + """ + ); + } } diff --git a/tests/Lua.Tests/ManagedNumericTextTests.cs b/tests/Lua.Tests/ManagedNumericTextTests.cs new file mode 100644 index 00000000..905be6e1 --- /dev/null +++ b/tests/Lua.Tests/ManagedNumericTextTests.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using Lua.Standard; + +namespace Lua.Tests; + +public class ManagedNumericTextTests +{ + [TestCase("f")] + [TestCase("e")] + [TestCase("E")] + [TestCase("g")] + [TestCase("G")] + [TestCase("a")] + [TestCase("A")] + public async Task ExplicitSpecialValuesKeepManagedWords(string specifier) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["nan"] = double.NaN; + var values = await state.DoStringAsync( + $"return string.format('%{specifier}',math.huge),string.format('%{specifier}',-math.huge),string.format('%{specifier}',nan)" + ); + var expected = new[] { "Infinity", "-Infinity", "NaN" }; + if (specifier is "E" or "G" or "A") + expected = expected.Select(s => s.ToUpperInvariant()).ToArray(); + Assert.That(values.Select(v => v.Read()), Is.EqualTo(expected)); + } + + [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/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/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/NumericQuotingTests.cs b/tests/Lua.Tests/NumericQuotingTests.cs new file mode 100644 index 00000000..082e2b1c --- /dev/null +++ b/tests/Lua.Tests/NumericQuotingTests.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using Lua.Standard; + +namespace Lua.Tests; + +public class NumericQuotingTests +{ + [TestCase(12d)] + [TestCase(1d / 3)] + [TestCase(-0d)] + [TestCase(double.Epsilon)] + [TestCase(double.PositiveInfinity)] + [TestCase(double.NaN)] + public async Task NumericQuoteReturnsAQuotedStringUsingManagedText(double number) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["number"] = number; + foreach (var format in new[] { "%q", "%20q", "%+.2q" }) + { + var values = await state.DoStringAsync( + $"local q=string.format('{format}',number); return q,assert(load('return '..q))()" + ); + var text = number.ToString(CultureInfo.InvariantCulture); + Assert.That(values[0].Read(), Is.EqualTo('"' + text + '"')); + Assert.That(values[1].Type, Is.EqualTo(LuaValueType.String)); + Assert.That(values[1].Read(), Is.EqualTo(text)); + } + } +} 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) + """ + ); + } +} diff --git a/tests/Lua.Tests/StringFormatTests.cs b/tests/Lua.Tests/StringFormatTests.cs new file mode 100644 index 00000000..593ed679 --- /dev/null +++ b/tests/Lua.Tests/StringFormatTests.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using Lua.Standard; + +namespace Lua.Tests; + +public class StringFormatTests +{ + LuaState CreateState() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("true", "boolean")] + [TestCase("false", "boolean")] + [TestCase("nil", "nil")] + [TestCase("{}", "table")] + [TestCase("function() end", "function")] + [TestCase("coroutine.create(function() end)", "thread")] + public async Task QuotedFormatRejectsUnsupportedTypes(string expression, string type) + { + using var state = CreateState(); + foreach ( + var (format, arguments, argumentNumber) in new[] + { + ("%q", expression, 2), + ("%s:%q", "'prefix'," + expression, 3), + } + ) + { + var values = await state.DoStringAsync( + $"return pcall(string.format,'{format}',{arguments})" + ); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain($"bad argument #{argumentNumber}")); + Assert.That(values[1].Read(), Does.Contain("string expected")); + Assert.That(values[1].Read(), Does.Contain("got " + type)); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + } + + [Test] + public async Task QuotedFormatDoesNotInvokeToStringMetamethod() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local calls=0 + local value=setmetatable({}, {__tostring=function() calls=calls+1; return 'converted' end}) + local ok,e=pcall(string.format,'%q',value) + assert(not ok and e:find('string expected',1,true) and calls==0) + assert(string.format('%s',value)=='converted' and calls==1) + assert(string.format('%s,%s,%s',true,false,nil)=='true,false,nil') + """ + ); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase("%a", "0", "0x0p+0")] + [TestCase("%a", "-0.0", "-0x0p+0")] + [TestCase("%a", "0x1p-1074", "0x0.0000000000001p-1022")] + [TestCase("%A", "0x1fffffffffffff", "0X1.FFFFFFFFFFFFFP+52")] + [TestCase("%.2a", "0.5", "0x1.00p-1")] + [TestCase("%.4a", "-3", "-0x1.8000p+1")] + [TestCase("%.0a", "0x1.8p0", "0x2p+0")] + [TestCase("%#.0a", "0x1.ffp0", "0x2.p+0")] + [TestCase("%.1a", "0x1.28p0", "0x1.2p+0")] + [TestCase("%.1a", "0x1.38p0", "0x1.4p+0")] + [TestCase("%.1a", "0x1.ffp0", "0x2.0p+0")] + [TestCase("%.1a", "0x0.fffffffffffffp-1022", "0x1.0p-1022")] + [TestCase("%+020.2a", "1.5", "+0x00000000001.80p+0")] + [TestCase("% 20.1A", "0x1.ffp0", " 0X2.0P+0")] + [TestCase("%-20.1a", "0x1.ffp0", "0x2.0p+0 ")] + [TestCase("%.14a", "1.5", "0x1.80000000000000p+0")] + [TestCase("%#.a", "1", "0x1.p+0")] + [TestCase("%a", "math.huge", "Infinity")] + [TestCase("%+A", "math.huge", "+INFINITY")] + [TestCase("%010a", "-math.huge", " -Infinity")] + [TestCase("%o", "8", "10")] + [TestCase("%o", "1.5", "1")] + [TestCase("%o", "-0.5", "0")] + [TestCase("%o", "2^63", "1000000000000000000000")] + [TestCase("%o", "2^64-2048", "1777777777777777774000")] + [TestCase("%#.0o", "0", "0")] + [TestCase("%.0o", "0", "")] + [TestCase("%#08o", "8", "00000010")] + [TestCase("%-8.5o", "8", "00010 ")] + [TestCase("%08.5o", "8", " 00010")] + [TestCase("%+o", "8", "10")] + [TestCase("% o", "8", "10")] + [TestCase("%.o", "0", "")] + [TestCase("%.0s", "'text'", "")] + public async Task FormatSpecifiersAndPrecisionFollowLua52( + string format, + string value, + string expected + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return string.format('{format}',{value})"); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + } + + [TestCase("%")] + [TestCase("%1")] + [TestCase("%#")] + [TestCase("%.")] + [TestCase("%10.")] + [TestCase("%100o")] + [TestCase("%.100a")] + public async Task InvalidFormatsRaiseLuaErrors(string format) + { + using var state = CreateState(); + var values = await state.DoStringAsync( + $"local ok,e=pcall(string.format,'{format}',1); return ok,e" + ); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("format")); + } + + [TestCase("-1")] + [TestCase("2^64")] + [TestCase("math.huge")] + [TestCase("0/0")] + [TestCase("{}")] + public async Task OctalRejectsValuesOutsideUnsignedRange(string value) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return pcall(string.format,'%o',{value})"); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("bad argument #2")); + } + + [Test] + public async Task HexadecimalFormatRoundTripsFiniteNumbersUnderOtherCultures() + { + using var state = CreateState(); + var format = state.Environment["string"].Read()["format"]; + var originalCulture = CultureInfo.CurrentCulture; + try + { + var culture = (CultureInfo)CultureInfo.GetCultureInfo("fr-FR").Clone(); + culture.NumberFormat.NegativeSign = "~"; + culture.NumberFormat.PositiveSign = "!"; + CultureInfo.CurrentCulture = culture; + var random = new Random(42); + for (var i = 0; i < 512; i++) + { + var bits = random.NextInt64(long.MinValue, long.MaxValue); + if ((i & 1) == 0) + bits &= unchecked((long)0x800fffffffffffff); + var number = BitConverter.Int64BitsToDouble(bits); + if (!double.IsFinite(number)) + continue; + var values = await state.CallAsync(format, [i % 2 == 0 ? "%a" : "%A", number]); + var text = values[0].Read(); + Assert.That( + BitConverter.DoubleToInt64Bits(Internal.HexConverter.ToDouble(text)), + Is.EqualTo(bits), + text + ); + } + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } +} diff --git a/tests/Lua.Tests/StringIndexAndQuotingTests.cs b/tests/Lua.Tests/StringIndexAndQuotingTests.cs index 86d70bb8..e80afdb3 100644 --- a/tests/Lua.Tests/StringIndexAndQuotingTests.cs +++ b/tests/Lua.Tests/StringIndexAndQuotingTests.cs @@ -48,4 +48,93 @@ await state.DoStringAsync( "assert(assert(load('return '..string.format('%q',source)))()==source)" ); } + + [TestCase("1.9", "-1.9", "abc")] + [TestCase("-2.9", "-1", "bc")] + [TestCase("-0.5", "2.9", "ab")] + [TestCase("'2.9'", "nil", "bc")] + [TestCase("-2147483649", "2147483648", "abc")] + [TestCase("2147483648", "2147483648", "")] + [TestCase("1", "-2147483649", "")] + public async Task StringSlicesTruncateBeforeRelativeIndexing( + string first, + string last, + string expected + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return string.sub('abc',{first},{last})"); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + } + + [TestCase("1.9", "2.9", "ab")] + [TestCase("-2.9", "-1.9", "bc")] + [TestCase("-2147483649", "2147483648", "abc")] + [TestCase("2147483648", "2147483648", "")] + public async Task ByteSlicesHaveTheSameIndexConversion( + string first, + string last, + string expected + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return string.byte('abc',{first},{last})"); + Assert.That( + values.Select(v => v.Read()), + Is.EqualTo(expected.Select(c => (double)c)) + ); + } + + [TestCase(-0.5, 0)] + [TestCase(0.5, 0)] + [TestCase(65.9, 65)] + [TestCase(255.9, 255)] + public async Task CharTruncatesBeforeCheckingItsRange(double input, int expected) + { + using var state = CreateState(); + state.Environment["input"] = input; + var values = await state.DoStringAsync("return string.byte(string.char(input))"); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + } + + [Test] + public async Task CharFailureCanBeFollowedByAnotherCall() + { + using var state = CreateState(); + await state.DoStringAsync( + "local ok,e=pcall(string.char,65,false); assert(not ok and e:find('bad argument #2',1,true)); assert(string.char('65.9')=='A')" + ); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase("-0.5", 1)] + [TestCase("1.9", 1)] + [TestCase("'2.9'", 3)] + [TestCase("-2.9", 3)] + [TestCase("-2147483649", 1)] + public async Task PatternAndPlainSearchesAcceptFractionalAndStringIndices( + string start, + int expected + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync( + $"return string.find('aba','a',{start}), string.match('aba','()a',{start})" + ); + Assert.That( + values.Select(v => v.Read()), + Is.EqualTo(new double[] { expected, expected }) + ); + } + + [Test] + public async Task SearchIndexBeyondInt32DoesNotWrap() + { + using var state = CreateState(); + var values = await state.DoStringAsync( + "return string.find('abc','',2147483648),string.match('abc','',2147483648)" + ); + Assert.That(values, Is.EqualTo(new[] { LuaValue.Nil, LuaValue.Nil })); + } } diff --git a/tests/Lua.Tests/StringOperationRegressionTests.cs b/tests/Lua.Tests/StringOperationRegressionTests.cs index 343fc606..2ee4d4ef 100644 --- a/tests/Lua.Tests/StringOperationRegressionTests.cs +++ b/tests/Lua.Tests/StringOperationRegressionTests.cs @@ -62,4 +62,29 @@ await state.DoStringAsync( """ ); } + + [Test] + public async Task FractionalRepeatCountsTruncateTowardZero() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + assert(string.rep('ab', 2.9, ':') == 'ab:ab') + assert(string.rep('ab', '2.9', ':') == 'ab:ab') + assert(string.rep('ab', 1.9, ':') == 'ab') + assert(string.rep('ab', 0.9, ':') == '') + assert(string.rep('ab', -0.9, ':') == '') + assert(string.rep('ab', -2.9, ':') == '') + assert(string.rep('', 2.9, ':') == ':') + assert(string.rep('a\0b', 2.9, '\0') == 'a\0b\0a\0b') + assert(not pcall(string.rep, 'ab', 0.9, false)) + assert(not pcall(string.rep, 'ab', {})) + assert(not pcall(string.rep, 'ab', 0/0)) + assert(not pcall(string.rep, 'ab', math.huge)) + assert(not pcall(string.rep, 'ab', -math.huge)) + local ok, message = pcall(string.rep, 'xx', 2147483647.5, ':') + assert(not ok and string.find(message, 'resulting string too large', 1, true)) + """ + ); + } } diff --git a/tests/Lua.Tests/StringPatternTests.cs b/tests/Lua.Tests/StringPatternTests.cs new file mode 100644 index 00000000..6b5a8b5e --- /dev/null +++ b/tests/Lua.Tests/StringPatternTests.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaStringMode.Utf16)] +[TestFixture(LuaStringMode.Bytes)] +public class StringPatternTests(LuaStringMode mode) +{ + LuaState CreateState() + { + var state = LuaState.Create( + mode == LuaStringMode.Bytes ? LuaPlatform.DefaultByteStrings : LuaPlatform.Default + ); + state.OpenStandardLibraries(); + return state; + } + + [TestCase("abc", "^.", "")] + [TestCase("^a ^b", "^.", "^a,^b")] + [TestCase("abc", "^", "")] + [TestCase("^a^^b", "^", "^,^,^")] + public async Task GmatchDoesNotInterpretCaretAsAnchor( + string source, + string pattern, + string expected + ) + { + using var state = CreateState(); + state.Environment["source"] = source; + state.Environment["pattern"] = pattern; + var values = await state.DoStringAsync( + "local t={}; for x in string.gmatch(source,pattern) do t[#t+1]=x end; return table.concat(t,',')" + ); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + } + + [Test] + public async Task GmatchExhaustionHasNoResultsIncludingAfterAnEmptyMatch() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local f=string.gmatch('x','x') + assert(f()=='x' and select('#',f())==0 and select('#',f())==0) + f=string.gmatch('','') + assert(f()=='' and select('#',f())==0) + f=string.gmatch('abc','z') + assert(select('#',f())==0) + """ + ); + } + + [TestCase("string.match('abc','(')")] + [TestCase("string.find('abc','(')")] + [TestCase("string.gmatch('abc','(')()")] + [TestCase("string.gsub('abc','(',{})")] + [TestCase("string.gsub('a','(a)(',function(...) return 'x' end)")] + public async Task UnfinishedCapturesRaiseLuaErrorsAndCleanTheStack(string operation) + { + using var state = CreateState(); + var values = await state.DoStringAsync( + "return pcall(function() return " + operation + " end)" + ); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("unfinished capture")); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } + + [TestCase("false", "'x'", "0")] + [TestCase("true", "'x'", "1")] + [TestCase("nil", "'a'", "0")] + [TestCase("false", "'a'", "-1")] + public async Task ReplacementTypeIsCheckedEvenWithoutAMatch( + string replacement, + string pattern, + string limit + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync( + $"return pcall(string.gsub,'abc',{pattern},{replacement},{limit})" + ); + Assert.That(values[0].Read(), Is.False); + Assert.That(values[1].Read(), Does.Contain("bad argument #3")); + } + + [TestCase("1.5", "xaa", 1)] + [TestCase("2.5", "xxa", 2)] + [TestCase("0.5", "aaa", 0)] + [TestCase("-0.5", "aaa", 0)] + [TestCase("-1", "xxx", 3)] + [TestCase("-1.5", "xxx", 3)] + public async Task GsubLimitsFollowLua52IntegerAndUnsignedConversion( + string limit, + string expected, + int count + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync($"return string.gsub('aaa','a','x',{limit})"); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + Assert.That(values[1].Read(), Is.EqualTo(count)); + } + + [Test] + public async Task ReusedCaptureStorageHandlesFailedAttemptsBackreferencesAndMaximumCaptures() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local a,b=string.match('aaac aab','((a+)b)'); assert(a=='aab' and b=='aa') + a,b=string.match('ac ab','(a)(b)'); assert(a=='a' and b=='b') + assert(string.match('ac abab','((ab)%2)')=='abab') + local pattern=string.rep('()',32)..'a' + local t=table.pack(string.match('xa',pattern)) + assert(t.n==32); for i=1,32 do assert(t[i]==2) end + local s,n=string.gsub('ac ab','(a)(b)','%2%1'); assert(s=='ac ba' and n==1) + s,n=string.gsub('ab','a','%1%0'); assert(s=='aab' and n==1) + s,n=string.gsub('abc','(','x'); assert(s=='xaxbxcx' and n==4) + """ + ); + } + + [Test] + public async Task NumericReplacementIsCultureIndependentAndCallbacksKeepTheirArguments() + { + using var state = CreateState(); + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + await state.DoStringAsync( + """ + local s,n=string.gsub('aba','a',1.5); assert(s=='1.5b1.5' and n==2) + s,n=string.gsub('aba','a',function(...) assert(select('#',...)==1 and (...)=='a'); return 1.5 end) + assert(s=='1.5b1.5' and n==2) + s,n=string.gsub('ab','()',function(pos) return pos end); assert(s=='1a2b3' and n==3) + s,n=string.gsub('ab','(.)',{a=12.5,b=false}); assert(s=='12.5b' and n==2) + """ + ); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [Test] + public async Task SuspendedReplacementPreservesCapturesAndRestoresStack() + { + using var state = CreateState(); + var calls = 0; + state.Environment["replace"] = new LuaFunction( + async (context, token) => + { + var a = context.GetArgument(0); + var b = context.GetArgument(1); + await Task.Yield(); + calls++; + return context.Return(b + a); + } + ); + var values = await state.DoStringAsync("return string.gsub('ac ab ab','(a)(b)',replace)"); + Assert.That(values[0].Read(), Is.EqualTo("ac ba ba")); + Assert.That(values[1].Read(), Is.EqualTo(2)); + Assert.That(calls, Is.EqualTo(2)); + Assert.That(state.Stack.Count, Is.Zero); + Assert.That(state.CallStackFrameCount, Is.Zero); + } +} diff --git a/tests/Lua.Tests/TableLibraryTests.cs b/tests/Lua.Tests/TableLibraryTests.cs index 79254ba0..d84f74d4 100644 --- a/tests/Lua.Tests/TableLibraryTests.cs +++ b/tests/Lua.Tests/TableLibraryTests.cs @@ -105,4 +105,38 @@ public async Task ExcessiveUnpackRangesAreRejectedWithoutStackGrowth(string rang Assert.That(state.Stack.Count, Is.Zero); Assert.That((await state.DoStringAsync("return 42"))[0].Read(), Is.EqualTo(42)); } + + [TestCase("table.insert(t,1.5,99)", "99,11,22,33")] + [TestCase("table.insert(t,'2.5',99)", "11,99,22,33")] + [TestCase("assert(table.remove(t,1.5)==11)", "22,33")] + [TestCase("assert(table.remove(t,'2.5')==22)", "11,33")] + public async Task FractionalPositionsTruncateBeforeBoundsChecks( + string operation, + string expected + ) + { + using var state = CreateState(); + var values = await state.DoStringAsync( + "local t={11,22,33}; " + operation + "; return table.concat(t,',')" + ); + Assert.That(values[0].Read(), Is.EqualTo(expected)); + } + + [Test] + public async Task SortEvaluatesLengthBeforeValidatingComparator() + { + using var state = CreateState(); + await state.DoStringAsync( + """ + local calls=0 + local t=setmetatable({3,2,1},{__len=function() calls=calls+1; return 3 end}) + local ok,e=pcall(table.sort,t,false) + assert(not ok and calls==1) + setmetatable(t,{__len=function() error('length first',0) end}) + ok,e=pcall(table.sort,t,false) + assert(not ok and string.find(e,'length first',1,true)) + """ + ); + Assert.That(state.Stack.Count, 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); + } } diff --git a/tests/Lua.Tests/tests-lua/strings.lua b/tests/Lua.Tests/tests-lua/strings.lua index fd13d450..02c35944 100644 --- a/tests/Lua.Tests/tests-lua/strings.lua +++ b/tests/Lua.Tests/tests-lua/strings.lua @@ -1,6 +1,6 @@ print('testing strings and string library') -local _noformatA = true +local _noformatA = false local _port = true assert('alo' < 'alo1')