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..54462d82 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( @@ -518,20 +522,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 +545,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 +553,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 +634,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 +678,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 +697,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 +732,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; + stack.Push(matchState.GetCapture(k)); } - 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; - } - } - 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 +757,7 @@ CancellationToken cancellationToken } else if (result.TryRead(out var rd)) { - builder.Append(rd); + AppendNumber(builder, rd); } else if (!result.ToBoolean()) { @@ -803,7 +768,7 @@ CancellationToken cancellationToken { throw new LuaRuntimeException( context.State, - $"invalid replacement value (a {result.Type})" + $"invalid replacement value (a {result.TypeToString()})" ); } @@ -890,33 +855,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 +911,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 +938,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 +968,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 +1045,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 +1066,26 @@ 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]); + } } 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/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/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/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/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); + } }