diff --git a/src/Lua/IO/MemoryStreams.cs b/src/Lua/IO/MemoryStreams.cs index fc54b77f..9755d3f1 100644 --- a/src/Lua/IO/MemoryStreams.cs +++ b/src/Lua/IO/MemoryStreams.cs @@ -112,18 +112,6 @@ public virtual ValueTask ReadAllAsync(CancellationToken cancellationToke } var remaining = contents[Position..].Span; - var startPos = Position; - - // Use the shared utility to scan for a number - var numberLength = NumberReaderHelper.ScanNumberLength(remaining, true); - - if (numberLength == 0) - { - Position = contents.Length; - return new((double?)null); - } - - // Find where the actual number starts (after whitespace) var whitespaceLength = 0; while ( whitespaceLength < remaining.Length && char.IsWhiteSpace(remaining[whitespaceLength]) @@ -132,12 +120,10 @@ public virtual ValueTask ReadAllAsync(CancellationToken cancellationToke whitespaceLength++; } - var numberSpan = remaining.Slice(whitespaceLength, numberLength); - Position = startPos + whitespaceLength + numberLength; - - // Parse using shared utility - var result = NumberReaderHelper.ParseNumber(numberSpan); - return new(result); + remaining = remaining[whitespaceLength..]; + var numberLength = NumberReaderHelper.ScanNumberLength(remaining); + Position += whitespaceLength + numberLength; + return new(NumberReaderHelper.ParseNumber(remaining[..numberLength])); } public ValueTask WriteAsync(ReadOnlyMemory content, CancellationToken cancellationToken) @@ -200,6 +186,7 @@ public override ValueTask ReadAllAsync(CancellationToken cancellationTok cancellationToken.ThrowIfCancellationRequested(); if (Position == 0) { + Position = content.Length; return new(content); } diff --git a/src/Lua/IO/NumberReaderHelper.cs b/src/Lua/IO/NumberReaderHelper.cs index 5da48bc9..ec348914 100644 --- a/src/Lua/IO/NumberReaderHelper.cs +++ b/src/Lua/IO/NumberReaderHelper.cs @@ -10,27 +10,16 @@ internal static class NumberReaderHelper /// Returns the length of the number portion, or 0 if no valid number is found. /// /// The span to scan - /// Whether to skip leading whitespace /// The length of the valid number portion - public static int ScanNumberLength(ReadOnlySpan span, bool skipWhitespace = true) + public static int ScanNumberLength(ReadOnlySpan span) { var position = 0; - // Skip leading whitespace - if (skipWhitespace) - { - while (position < span.Length && char.IsWhiteSpace(span[position])) - { - position++; - } - } - if (position >= span.Length) { return 0; } - var numberStart = position; var hasStarted = false; var isHex = false; var hasDecimal = false; @@ -129,7 +118,7 @@ public static int ScanNumberLength(ReadOnlySpan span, bool skipWhitespace } // Return the length of the number portion - return position - numberStart; + return position; } public static bool TryParseToDouble(ReadOnlySpan span, out double result) diff --git a/src/Lua/LuaFunctionExecutionContext.cs b/src/Lua/LuaFunctionExecutionContext.cs index 049437c7..bd6036c6 100644 --- a/src/Lua/LuaFunctionExecutionContext.cs +++ b/src/Lua/LuaFunctionExecutionContext.cs @@ -67,6 +67,23 @@ public T GetArgument(int index) return ReadArgument(index, in Arguments[index]); } + // Lua library strings accept numbers; typed C# arguments remain strict. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal string GetStringArgument(int index) + { + var argument = GetArgument(index); + if (argument.TryReadString(out var text)) + return text; + if (argument.Type == LuaValueType.Number) + { + text = argument.ToLuaString(State.Platform.NumberFormat); + State.Stack.Get(FrameBase + index) = text; + return text; + } + ThrowArgumentError(index, argument); + return null!; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal T GetArgumentOrDefault(int index, T defaultValue = default!) { @@ -110,7 +127,10 @@ T ReadArgument(int index, in LuaValue arg) void ThrowArgumentError(int index, in LuaValue arg) { var t = typeof(T); - if ((t == typeof(int) || t == typeof(long)) && arg.TryReadNumber(out _)) + if ( + (t == typeof(int) || t == typeof(long) || t == typeof(uint) || t == typeof(ulong)) + && arg.TryReadNumber(out _) + ) { LuaRuntimeException.BadArgumentNumberIsNotInteger(State, index + 1); } diff --git a/src/Lua/LuaValue.cs b/src/Lua/LuaValue.cs index b4f71adc..23df1d03 100644 --- a/src/Lua/LuaValue.cs +++ b/src/Lua/LuaValue.cs @@ -64,7 +64,7 @@ public bool TryRead(out T result) } else if (t == typeof(int)) { - if (!MathEx.IsInteger(value)) + if (!MathEx.IsInteger(value) || value < int.MinValue || value > int.MaxValue) { break; } @@ -75,7 +75,12 @@ public bool TryRead(out T result) } else if (t == typeof(long)) { - if (!MathEx.IsInteger(value)) + // The upper bound is exclusive: long.MaxValue rounds to 2^63 as a double. + if ( + !MathEx.IsInteger(value) + || value < long.MinValue + || value >= 9223372036854775808d + ) { break; } @@ -86,23 +91,23 @@ public bool TryRead(out T result) } else if (t == typeof(uint)) { - if (!MathEx.IsInteger(value)) + if (!MathEx.IsInteger(value) || value < 0 || value > uint.MaxValue) { break; } - var v = checked((uint)value); + var v = (uint)value; result = Unsafe.As(ref v); return true; } else if (t == typeof(ulong)) { - if (!MathEx.IsInteger(value)) + if (!MathEx.IsInteger(value) || value < 0 || value >= 18446744073709551616d) { break; } - var v = checked((ulong)value); + var v = (ulong)value; result = Unsafe.As(ref v); return true; } @@ -124,7 +129,7 @@ public bool TryRead(out T result) } else if (t == typeof(object)) { - result = (T)(object)value; + result = (T)(object)(value != 0); return true; } else diff --git a/src/Lua/Standard/BasicLibrary.cs b/src/Lua/Standard/BasicLibrary.cs index 5e379eaf..7df436ef 100644 --- a/src/Lua/Standard/BasicLibrary.cs +++ b/src/Lua/Standard/BasicLibrary.cs @@ -109,7 +109,7 @@ CancellationToken cancellationToken { if (context.HasArgument(0)) { - var option = context.GetArgument(0); + var option = context.GetStringArgument(0); if (!KnownCollectGarbageOptions.Contains(option)) { throw new LuaRuntimeException( @@ -129,7 +129,7 @@ public async ValueTask DoFile( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); + var arg0 = context.GetStringArgument(0); context.State.Stack.PopUntil(context.ReturnFrameBase); var closure = await context.State.LoadFileAsync( context.State.Platform.DecodeText(arg0), @@ -217,8 +217,8 @@ public async ValueTask LoadFile( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); - var mode = context.HasArgument(1) ? context.GetArgument(1) : "bt"; + var arg0 = context.GetStringArgument(0); + var mode = context.HasArgument(1) ? context.GetStringArgument(1) : "bt"; var hasEnvironment = context.ArgumentCount > 2; var environment = hasEnvironment ? context.GetArgument(2) : LuaValue.Nil; @@ -251,9 +251,9 @@ CancellationToken cancellationToken { var arg0 = context.GetArgument(0); - var name = context.HasArgument(1) ? context.GetArgument(1) : null; + var name = context.HasArgument(1) ? context.GetStringArgument(1) : null; - var mode = context.HasArgument(2) ? context.GetArgument(2) : "bt"; + var mode = context.HasArgument(2) ? context.GetStringArgument(2) : "bt"; var hasEnvironment = context.ArgumentCount > 3; var environment = hasEnvironment ? context.GetArgument(3) : LuaValue.Nil; diff --git a/src/Lua/Standard/FileHandle.cs b/src/Lua/Standard/FileHandle.cs index 296496e8..d8886628 100644 --- a/src/Lua/Standard/FileHandle.cs +++ b/src/Lua/Standard/FileHandle.cs @@ -244,8 +244,8 @@ static async (context, cancellationToken) => (context, cancellationToken) => { var file = context.GetArgument(0); - var whence = context.HasArgument(1) ? context.GetArgument(1) : "cur"; - var offset = context.HasArgument(2) ? context.GetArgument(2) : 0; + var whence = context.HasArgument(1) ? context.GetStringArgument(1) : "cur"; + var offset = context.HasArgument(2) ? context.GetArgument(2) : 0; if (whence is not ("set" or "cur" or "end")) { @@ -257,7 +257,7 @@ static async (context, cancellationToken) => try { - return new(context.Return(file.Seek(whence, (long)offset))); + return new(context.Return(file.Seek(whence, offset))); } catch (IOException ex) { @@ -277,7 +277,7 @@ static async (context, cancellationToken) => (context, cancellationToken) => { var file = context.GetArgument(0); - var mode = context.GetArgument(1); + var mode = context.GetStringArgument(1); var size = context.HasArgument(2) ? context.GetArgument(2) : -1; file.SetVBuf(mode, size); diff --git a/src/Lua/Standard/IOLibrary.cs b/src/Lua/Standard/IOLibrary.cs index 5eda6787..01cf8828 100644 --- a/src/Lua/Standard/IOLibrary.cs +++ b/src/Lua/Standard/IOLibrary.cs @@ -149,7 +149,7 @@ static async (context, cancellationToken) => } else { - var fileName = context.GetArgument(0); + var fileName = context.GetStringArgument(0); LuaValue[] formats = context.ArgumentCount > 1 ? context.Arguments[1..context.ArgumentCount].ToArray() @@ -207,8 +207,8 @@ public async ValueTask Open( CancellationToken cancellationToken ) { - var fileName = context.GetArgument(0); - var mode = context.HasArgument(1) ? context.GetArgument(1) : "r"; + var fileName = context.GetStringArgument(0); + var mode = context.HasArgument(1) ? context.GetStringArgument(1) : "r"; context.Return(); try { diff --git a/src/Lua/Standard/ModuleLibrary.cs b/src/Lua/Standard/ModuleLibrary.cs index 18c7d3a5..dbf9f9cf 100644 --- a/src/Lua/Standard/ModuleLibrary.cs +++ b/src/Lua/Standard/ModuleLibrary.cs @@ -24,7 +24,7 @@ public async ValueTask Require( CancellationToken cancellationToken ) { - var arg0 = context.GetArgument(0); + var arg0 = context.GetStringArgument(0); var loaded = context.GlobalState.LoadedModules; if (!loaded.TryGetValue(arg0, out var loadedTable)) @@ -74,10 +74,10 @@ public ValueTask SearchPath( CancellationToken cancellationToken ) { - var name = context.GetArgument(0); - var path = context.GetArgument(1); - var separator = context.GetArgument(2); - var dirSeparator = context.GetArgument(3); + var name = context.GetStringArgument(0); + var path = context.GetStringArgument(1); + var separator = context.GetStringArgument(2); + var dirSeparator = context.GetStringArgument(3); var fileName = SearchPath(context.State, name, path, separator, dirSeparator); return new(context.Return(fileName ?? LuaValue.Nil)); } @@ -170,7 +170,7 @@ public ValueTask SearcherPreload( CancellationToken cancellationToken ) { - var name = context.GetArgument(0); + var name = context.GetStringArgument(0); var preload = context.GlobalState.PreloadModules[name]; if (preload == LuaValue.Nil) { @@ -185,7 +185,7 @@ public async ValueTask SearcherLua( CancellationToken cancellationToken ) { - var name = context.GetArgument(0); + var name = context.GetStringArgument(0); var fileName = await FindFile( context.State, name, diff --git a/src/Lua/Standard/OpenLibsExtensions.cs b/src/Lua/Standard/OpenLibsExtensions.cs index 73947b1b..e987e917 100644 --- a/src/Lua/Standard/OpenLibsExtensions.cs +++ b/src/Lua/Standard/OpenLibsExtensions.cs @@ -141,7 +141,7 @@ public static void OpenStringLibrary(this LuaState state) "index", (context, cancellationToken) => { - context.GetArgument(0); + context.GetStringArgument(0); var key = context.GetArgument(1); return new(context.Return(@string[key])); } diff --git a/src/Lua/Standard/OperatingSystemLibrary.cs b/src/Lua/Standard/OperatingSystemLibrary.cs index 72f5accb..51d3433f 100644 --- a/src/Lua/Standard/OperatingSystemLibrary.cs +++ b/src/Lua/Standard/OperatingSystemLibrary.cs @@ -41,9 +41,7 @@ public ValueTask Date( CancellationToken cancellationToken ) { - var format = context.HasArgument(0) - ? context.GetArgument(0).AsSpan() - : "%c".AsSpan(); + var format = context.HasArgument(0) ? context.GetStringArgument(0).AsSpan() : "%c".AsSpan(); if (format.IsEmpty) return new(context.Return("")); @@ -155,7 +153,7 @@ public ValueTask GetEnv( CancellationToken cancellationToken ) { - var variable = context.GetArgument(0); + var variable = context.GetStringArgument(0); var platform = context.State.Platform; var value = platform.OsEnvironment.GetEnvironmentVariable(platform.DecodeText(variable)); return new(context.Return(value is null ? LuaValue.Nil : platform.EncodeText(value))); @@ -166,7 +164,7 @@ public async ValueTask Remove( CancellationToken cancellationToken ) { - var fileName = context.GetArgument(0); + var fileName = context.GetStringArgument(0); try { await context.GlobalState.Platform.FileSystem.Remove( @@ -190,8 +188,8 @@ public async ValueTask Rename( CancellationToken cancellationToken ) { - var oldName = context.GetArgument(0); - var newName = context.GetArgument(1); + var oldName = context.GetStringArgument(0); + var newName = context.GetStringArgument(1); try { await context.GlobalState.Platform.FileSystem.Rename( @@ -218,7 +216,7 @@ CancellationToken cancellationToken { // os.setlocale is not supported besides the standard "C" POSIX locale // Any locale besides "C" will return nil - var locale = context.GetArgument(0); + var locale = context.GetStringArgument(0); if (locale == "C") { return new(context.Return(locale)); diff --git a/src/Lua/Standard/StringLibrary.cs b/src/Lua/Standard/StringLibrary.cs index 1f8a92c8..3d4aef1c 100644 --- a/src/Lua/Standard/StringLibrary.cs +++ b/src/Lua/Standard/StringLibrary.cs @@ -42,7 +42,7 @@ public ValueTask Byte( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var i = context.HasArgument(1) ? ReadStringIndex(context, 1) : 1; var j = context.HasArgument(2) ? ReadStringIndex(context, 2) : i; @@ -112,7 +112,7 @@ public async ValueTask Format( CancellationToken cancellationToken ) { - var format = context.GetArgument(0); + var format = context.GetStringArgument(0); var stack = context.State.Stack; // TODO: pooling StringBuilder StringBuilder builder = new(format.Length * 2); @@ -463,8 +463,8 @@ public ValueTask GMatch( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); - var pattern = context.GetArgument(1); + var s = context.GetStringArgument(0); + var pattern = context.GetStringArgument(1); return new( context.Return( @@ -588,8 +588,8 @@ public async ValueTask GSub( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); - var pattern = context.GetArgument(1); + var s = context.GetStringArgument(0); + var pattern = context.GetStringArgument(1); var repl = context.GetArgument(2); var n_arg = context.HasArgument(3) ? context.GetArgument(3) : s.Length + 1; @@ -783,7 +783,7 @@ public ValueTask Len( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); return new(context.Return(s.Length)); } @@ -792,7 +792,7 @@ public ValueTask Lower( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); return new( context.Return( context.State.Platform.StringMode == LuaStringMode.Bytes @@ -812,8 +812,8 @@ CancellationToken cancellationToken public ValueTask FindAux(LuaFunctionExecutionContext context, bool find) { - var s = context.GetArgument(0); - var pattern = context.GetArgument(1); + var s = context.GetStringArgument(0); + var pattern = context.GetStringArgument(1); var init = context.HasArgument(2) ? ReadStringIndex(context, 2) : 1; var offset = Math.Max(1, StringHelper.RelativePosition(init, s.Length)) - 1; @@ -918,9 +918,9 @@ public ValueTask Rep( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var n_arg = context.GetArgument(1); - var sep = context.HasArgument(2) ? context.GetArgument(2) : null; + var sep = context.HasArgument(2) ? context.GetStringArgument(2) : null; if (!MathEx.IsFinite(n_arg)) { @@ -982,7 +982,7 @@ public ValueTask Reverse( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var result = s.Length <= 1 ? s @@ -1003,7 +1003,7 @@ public ValueTask Sub( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); var i = ReadStringIndex(context, 1); var j = context.HasArgument(2) ? ReadStringIndex(context, 2) : -1; @@ -1016,7 +1016,7 @@ public ValueTask Upper( CancellationToken cancellationToken ) { - var s = context.GetArgument(0); + var s = context.GetStringArgument(0); return new( context.Return( context.State.Platform.StringMode == LuaStringMode.Bytes diff --git a/src/Lua/Standard/TableLibrary.cs b/src/Lua/Standard/TableLibrary.cs index 2d30f3da..739193b2 100644 --- a/src/Lua/Standard/TableLibrary.cs +++ b/src/Lua/Standard/TableLibrary.cs @@ -78,7 +78,7 @@ CancellationToken cancellationToken ) { var arg0 = context.GetArgument(0); - var arg1 = context.HasArgument(1) ? context.GetArgument(1) : ""; + var arg1 = context.HasArgument(1) ? context.GetStringArgument(1) : ""; var arg2 = context.HasArgument(2) ? ToInteger(context.GetArgument(2)) : 1; var arg3 = context.HasArgument(3) ? ToInteger(context.GetArgument(3)) diff --git a/tests/Lua.Tests/FileSeekTests.cs b/tests/Lua.Tests/FileSeekTests.cs new file mode 100644 index 00000000..14780314 --- /dev/null +++ b/tests/Lua.Tests/FileSeekTests.cs @@ -0,0 +1,37 @@ +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +public class FileSeekTests +{ + [Test] + public async Task LuaSeekAcceptsOffsetsBeyondInt32() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var stream = new PositionStream(); + state.Environment["file"] = new LuaValue(new FileHandle(stream)); + var result = await state.DoStringAsync( + "return file:seek('set',3000000000),file:seek(),file:seek('cur',-2999999999)" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 3000000000d, 3000000000d, 1 })); + } + + sealed class PositionStream : ILuaStream + { + long position; + public bool IsOpen { get; private set; } = true; + public LuaFileOpenMode Mode => LuaFileOpenMode.Read; + + public void Dispose() => IsOpen = false; + + public long Seek(SeekOrigin origin, long offset) => + position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => position + offset, + _ => throw new NotSupportedException(), + }; + } +} diff --git a/tests/Lua.Tests/MemoryStreamReadTests.cs b/tests/Lua.Tests/MemoryStreamReadTests.cs new file mode 100644 index 00000000..f40127d9 --- /dev/null +++ b/tests/Lua.Tests/MemoryStreamReadTests.cs @@ -0,0 +1,72 @@ +using Lua.IO; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(false)] +[TestFixture(true)] +public class MemoryStreamReadTests(bool stringBacked) +{ + ILuaStream Create(string text) => + stringBacked + ? ILuaStream.CreateFromString(text) + : ILuaStream.CreateFromMemory(text.AsMemory()); + + [Test] + public async Task ReadAllConsumesWholeAndRemainingContent() + { + using var stream = Create("abc"); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo("abc")); + Assert.That(stream.Seek(SeekOrigin.Current, 0), Is.EqualTo(3)); + Assert.That(await stream.ReadAsync(1, default), Is.Null); + Assert.That(await stream.ReadAllAsync(default), Is.Empty); + stream.Seek(SeekOrigin.Begin, 0); + Assert.That(await stream.ReadAsync(1, default), Is.EqualTo("a")); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo("bc")); + Assert.That(await stream.ReadAllAsync(default), Is.Empty); + } + + [TestCase("x12", "x12")] + [TestCase(" x12", "x12")] + [TestCase(" \t\r\n", "")] + [TestCase("", "")] + public async Task FailedNumericReadsConsumeOnlyLeadingWhitespace(string input, string remainder) + { + using var stream = Create(input); + Assert.That(await stream.ReadNumberAsync(default), Is.Null); + Assert.That(await stream.ReadNumberAsync(default), Is.Null); + Assert.That( + stream.Seek(SeekOrigin.Current, 0), + Is.EqualTo(input.Length - remainder.Length) + ); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo(remainder)); + } + + [TestCase(" 12 rest", 12d)] + [TestCase(" -3.5 rest", -3.5)] + [TestCase(" 0x1.fp2 rest", 7.75)] + public async Task SuccessfulNumericReadsPreserveFollowingText(string input, double expected) + { + using var stream = Create(input); + Assert.That(await stream.ReadNumberAsync(default), Is.EqualTo(expected)); + Assert.That(await stream.ReadAllAsync(default), Is.EqualTo(" rest")); + } + + [Test] + public async Task LuaFileReadsFollowTheSameCursorRules() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + using var whole = Create("abc"); + using var numeric = Create(" x12"); + state.Environment["whole"] = new LuaValue(new FileHandle(whole)); + state.Environment["numeric"] = new LuaValue(new FileHandle(numeric)); + var result = await state.DoStringAsync( + "return whole:read('*a'),whole:read(1),whole:read('*a'),numeric:read('*n'),numeric:read('*a')" + ); + Assert.That( + result, + Is.EqualTo(new LuaValue[] { "abc", LuaValue.Nil, "", LuaValue.Nil, "x12" }) + ); + } +} diff --git a/tests/Lua.Tests/StringArgumentConversionTests.cs b/tests/Lua.Tests/StringArgumentConversionTests.cs new file mode 100644 index 00000000..f2dfcece --- /dev/null +++ b/tests/Lua.Tests/StringArgumentConversionTests.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using Lua.Platforms; +using Lua.Standard; + +namespace Lua.Tests; + +[TestFixture(LuaNumberFormat.DotNet)] +[TestFixture(LuaNumberFormat.Lua)] +public class StringArgumentConversionTests(LuaNumberFormat format) +{ + LuaState CreateState() + { + var state = LuaState.Create(LuaPlatform.Default with { NumberFormat = format }); + state.OpenStandardLibraries(); + return state; + } + + [Test] + public async Task NumericStringArgumentsWorkAcrossStringFunctions() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local first,last=string.find(12345,23) + local iterator=string.gmatch(123,'%d') + return string.byte(123,2),string.format(123),string.len(123), + string.lower(123),string.upper(123),string.reverse(123), + string.sub(12345,2,4),string.rep(12,2,3),string.match(12345,23), + (string.gsub(12345,23,0)),first,last,iterator(),iterator(),iterator(), + table.concat({'a','b'},12) + """ + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + 50, + "123", + 3, + "123", + "123", + "321", + "234", + "12312", + "23", + "1045", + 2, + 3, + "1", + "2", + "3", + "a12b", + } + ) + ); + } + + [TestCase(1d / 3)] + [TestCase(-0d)] + [TestCase(double.PositiveInfinity)] + [TestCase(double.NaN)] + public async Task NumericStringArgumentsUseTheSelectedInvariantFormat(double number) + { + using var state = CreateState(); + state.Environment["number"] = number; + var expected = (await state.DoStringAsync("return tostring(number)"))[0].Read(); + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var result = await state.DoStringAsync( + "return string.len(number),string.sub(number,1),string.format(number),string.match(number,'.+'),table.concat({'a','b'},number)" + ); + Assert.That( + result, + Is.EqualTo( + new LuaValue[] + { + expected.Length, + expected, + expected, + expected, + "a" + expected + "b", + } + ) + ); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + [TestCase("nil")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("setmetatable({}, {__tostring=function() error('unexpected tostring') end})")] + public async Task StringArgumentsRejectOtherTypesWithoutCallingTostring(string expression) + { + using var state = CreateState(); + var result = await state.DoStringAsync($"return pcall(string.len,{expression})"); + Assert.That(result[0], Is.EqualTo(new LuaValue(false))); + Assert.That(result[1].Read(), Does.Contain("string expected")); + } + + [Test] + public async Task OtherLuaLibraryStringArgumentsAcceptNumbers() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + "package.preload['123']=function() return 42 end;local f=assert(load('return 43',123));return require(123),f()" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 42, 43 })); + } + + [Test] + public async Task CoercedArgumentsRemainStringsDuringReentrantCallbacks() + { + using var state = CreateState(); + var result = await state.DoStringAsync( + """ + local source,search=123,2 + local subject,pattern + local replaced=string.gsub(source,search,function() + local _,value=debug.getlocal(2,1) + subject=value + _,value=debug.getlocal(2,2) + pattern=value + return 'x' + end) + return subject,pattern,replaced,source,search + """ + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { "123", "2", "1x3", 123, 2 })); + } +} diff --git a/tests/Lua.Tests/ValueConversionTests.cs b/tests/Lua.Tests/ValueConversionTests.cs new file mode 100644 index 00000000..f531640a --- /dev/null +++ b/tests/Lua.Tests/ValueConversionTests.cs @@ -0,0 +1,136 @@ +using Lua.Standard; + +namespace Lua.Tests; + +[LuaObject] +public partial class ValueConversionObject +{ + [LuaMember] + public static int Int32(int value) => value; + + [LuaMember] + public static long Int64(long value) => value; + + [LuaMember] + public static uint UInt32(uint value) => value; + + [LuaMember] + public static ulong UInt64(ulong value) => value; + + [LuaMember] + public static string ObjectType(object value) => value.GetType().Name; + + [LuaMember] + public static string StringEcho(string value) => value; +} + +public class ValueConversionTests +{ + static void AssertRead(double number, T expected) + { + LuaValue value = number; + Assert.That(value.TryRead(out var actual), Is.True); + Assert.That(actual, Is.EqualTo(expected)); + Assert.That(value.Read(), Is.EqualTo(expected)); + } + + static void AssertRejected(double number) + { + LuaValue value = number; + Assert.That(value.TryRead(out var actual), Is.False); + Assert.That(actual, Is.EqualTo(default(T))); + Assert.Throws(() => value.Read()); + } + + [Test] + public void IntegerReadsAcceptRepresentableBounds() + { + AssertRead(int.MinValue, int.MinValue); + AssertRead(int.MaxValue, int.MaxValue); + AssertRead(long.MinValue, long.MinValue); + AssertRead(9223372036854774784d, 9223372036854774784L); + AssertRead(uint.MaxValue, uint.MaxValue); + AssertRead(18446744073709549568d, 18446744073709549568UL); + AssertRead(-0d, 0); + AssertRead(-0d, 0L); + AssertRead(-0d, 0U); + AssertRead(-0d, 0UL); + } + + [Test] + public void IntegerReadsRejectAdjacentOutOfRangeValues() + { + AssertRejected((double)int.MinValue - 1); + AssertRejected((double)int.MaxValue + 1); + AssertRejected(Math.BitDecrement((double)long.MinValue)); + AssertRejected(9223372036854775808d); + AssertRejected(-1); + AssertRejected((double)uint.MaxValue + 1); + AssertRejected(-1); + AssertRejected(18446744073709551616d); + } + + [TestCase(double.NaN)] + [TestCase(double.NegativeInfinity)] + [TestCase(double.PositiveInfinity)] + [TestCase(1.25)] + [TestCase(1e30)] + public void InvalidIntegerReadsReturnFalseWithoutThrowing(double number) + { + AssertRejected(number); + AssertRejected(number); + AssertRejected(number); + AssertRejected(number); + } + + [TestCase(false)] + [TestCase(true)] + public void BooleansReadAsObjectsPreserveTheirTypeAndRoundTrip(bool boolean) + { + LuaValue value = boolean; + Assert.That(value.TryRead(out var boxed), Is.True); + Assert.That(boxed, Is.TypeOf().And.EqualTo(boolean)); + Assert.That(value.Read(), Is.TypeOf().And.EqualTo(boolean)); + Assert.That(LuaValue.FromObject(boxed), Is.EqualTo(value)); + } + + [TestCase("Int32", 2147483648d)] + [TestCase("Int64", 9223372036854775808d)] + [TestCase("UInt32", -1d)] + [TestCase("UInt64", 18446744073709551616d)] + public async Task GeneratedIntegerArgumentsRejectOverflowAsLuaErrors( + string method, + double number + ) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["target"] = new ValueConversionObject(); + state.Environment["number"] = number; + var result = await state.DoStringAsync($"return pcall(target.{method},number)"); + Assert.That(result[0], Is.EqualTo(new LuaValue(false))); + Assert.That( + result[1].Read(), + Does.Contain("bad argument #1").And.Contain("integer representation") + ); + Assert.That( + (await state.DoStringAsync($"return target.{method}(42)"))[0], + Is.EqualTo(new LuaValue(42)) + ); + Assert.That(state.Stack.Count, Is.Zero); + } + + [Test] + public async Task GeneratedObjectArgumentsReceiveBooleansAndStringArgumentsStayStrict() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["target"] = new ValueConversionObject(); + var result = await state.DoStringAsync( + "return target.ObjectType(true),target.ObjectType(false),target.ObjectType(12),pcall(target.StringEcho,123)" + ); + Assert.That(result.Take(3), Is.EqualTo(new LuaValue[] { "Boolean", "Boolean", "Double" })); + Assert.That(result[3], Is.EqualTo(new LuaValue(false))); + Assert.That(new LuaValue(123).TryRead(out _), Is.False); + } +}