diff --git a/src/Lua/LuaTable.cs b/src/Lua/LuaTable.cs index 6ac61c47..da5b8820 100644 --- a/src/Lua/LuaTable.cs +++ b/src/Lua/LuaTable.cs @@ -243,6 +243,78 @@ public void Insert(int index, LuaValue value) } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal LuaValue GetIntegerValue(int index) + { + return index > 0 && index <= array.Length ? array[index - 1] : this[index]; + } + + internal void SwapIntegerValues(int i, int j) + { + if (i > 0 && j > 0 && i <= array.Length && j <= array.Length) + { + (array[i - 1], array[j - 1]) = (array[j - 1], array[i - 1]); + if (array[i - 1].Type is LuaValueType.Nil) + { + knownArrayPrefixLength = Math.Min(knownArrayPrefixLength, i - 1); + } + if (array[j - 1].Type is LuaValueType.Nil) + { + knownArrayPrefixLength = Math.Min(knownArrayPrefixLength, j - 1); + } + } + else + { + (this[i], this[j]) = (this[j], this[i]); + } + } + + // Table-library moves stop at the Lua length, which may differ from both + // ArrayLength and capacity. Keep host Insert/RemoveAt behavior separate. + internal bool TryInsertWithinArray(int index, int length, LuaValue value) + { + if (index < 1 || length < 0 || length > array.Length || length >= MaxArraySize) + { + return false; + } + + if (length + 1 >= array.Length) + { + EnsureArrayCapacity(length + 1); + } + var arrayIndex = index - 1; + array.AsSpan(arrayIndex, length - arrayIndex).CopyTo(array.AsSpan(arrayIndex + 1)); + array[arrayIndex] = value; + if (knownArrayPrefixLength >= arrayIndex) + { + knownArrayPrefixLength = + value.Type is LuaValueType.Nil + ? arrayIndex + : Math.Min(knownArrayPrefixLength, length) + 1; + } + return true; + } + + internal bool TryRemoveWithinArray(int index, int length, out LuaValue value) + { + if (length > array.Length) + { + value = default; + return false; + } + + var arrayIndex = index - 1; + value = array[arrayIndex]; + array.AsSpan(index, length - index).CopyTo(array.AsSpan(arrayIndex)); + array[length - 1] = default; + knownArrayPrefixLength = Math.Min(knownArrayPrefixLength, length); + if (arrayIndex < knownArrayPrefixLength) + { + knownArrayPrefixLength--; + } + return true; + } + public bool TryGetNext(LuaValue key, out KeyValuePair pair) { var index = -1; diff --git a/src/Lua/Standard/TableLibrary.cs b/src/Lua/Standard/TableLibrary.cs index 609d8788..75815ac4 100644 --- a/src/Lua/Standard/TableLibrary.cs +++ b/src/Lua/Standard/TableLibrary.cs @@ -26,7 +26,40 @@ public TableLibrary() public readonly LibraryFunction[] Functions; - public ValueTask Concat( + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + static async ValueTask GetLengthAsync( + LuaState state, + LuaTable table, + CancellationToken cancellationToken + ) + { + LuaValue value = table; + if (!value.TryGetMetamethod(state.GlobalState, Metamethods.Len, out _)) + { + return table.ArrayLength; + } + + var result = await LuaVirtualMachine.ExecuteUnaryOperationMetaMethod( + state, + value, + OpCode.Len, + cancellationToken + ); + if (!result.TryReadDouble(out var length)) + { + throw new LuaRuntimeException(state, "object length is not a number"); + } + + if (double.IsNaN(length) || length < int.MinValue || length > int.MaxValue) + { + throw new LuaRuntimeException(state, "object length is out of range"); + } + + return (int)length; + } + + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + public async ValueTask Concat( LuaFunctionExecutionContext context, CancellationToken cancellationToken ) @@ -34,7 +67,9 @@ CancellationToken cancellationToken var arg0 = context.GetArgument(0); var arg1 = context.HasArgument(1) ? context.GetArgument(1) : ""; var arg2 = context.HasArgument(2) ? (long)context.GetArgument(2) : 1; - var arg3 = context.HasArgument(3) ? (long)context.GetArgument(3) : arg0.ArrayLength; + var arg3 = context.HasArgument(3) + ? (long)context.GetArgument(3) + : await GetLengthAsync(context.State, arg0, cancellationToken); using PooledList builder = new(512); @@ -64,36 +99,50 @@ CancellationToken cancellationToken } } - return new(context.Return(builder.AsSpan().ToString())); + return context.Return(builder.AsSpan().ToString()); } - public ValueTask Insert( + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + public async ValueTask Insert( LuaFunctionExecutionContext context, CancellationToken cancellationToken ) { var table = context.GetArgument(0); - var value = context.HasArgument(2) ? context.GetArgument(2) : context.GetArgument(1); - - var pos_arg = context.HasArgument(2) - ? context.GetArgument(1) - : table.ArrayLength + 1; - - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, pos_arg); + var length = await GetLengthAsync(context.State, table, cancellationToken); + if (length == int.MaxValue) + { + throw new LuaRuntimeException(context.State, "table length is out of range"); + } - try + if (context.ArgumentCount is not (2 or 3)) { - table.Insert((int)pos_arg, value); + throw new LuaRuntimeException(context.State, "wrong number of arguments to 'insert'"); } - catch (IndexOutOfRangeException) + 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); + if (context.ArgumentCount == 3 && (position < 1 || position > length + 1)) { throw new LuaRuntimeException( context.State, "bad argument #2 to 'insert' (position out of bounds)" ); } - return new(context.Return()); + + // Lua 5.2 uses the logical length but raw element reads and writes. + var index = (int)position; + if (table.TryInsertWithinArray(index, length, value)) + { + return context.Return(); + } + for (var i = length; i >= index; i--) + { + table[i + 1] = table[i]; + } + table[index] = value; + return context.Return(); } public ValueTask Pack( @@ -114,24 +163,17 @@ CancellationToken cancellationToken return new(context.Return(table)); } - public ValueTask Remove( + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + public async ValueTask Remove( LuaFunctionExecutionContext context, CancellationToken cancellationToken ) { var table = context.GetArgument(0); - var n_arg = context.HasArgument(1) ? context.GetArgument(1) : table.ArrayLength; - - LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, n_arg); - - var n = (int)n_arg; - - if ((!context.HasArgument(1) && n == 0) || n == table.GetArraySpan().Length + 1) - { - return new(context.Return(LuaValue.Nil)); - } - - if (n <= 0 || n > table.GetArraySpan().Length) + var length = await GetLengthAsync(context.State, table, cancellationToken); + var position = context.HasArgument(1) ? context.GetArgument(1) : length; + LuaRuntimeException.ThrowBadArgumentIfNumberIsNotInteger(context.State, 2, position); + if (position != length && (position < 1 || position > (long)length + 1)) { throw new LuaRuntimeException( context.State, @@ -139,10 +181,26 @@ CancellationToken cancellationToken ); } - return new(context.Return(table.RemoveAt(n))); + var index = (long)position; + if ( + index >= 1 + && index <= length + && table.TryRemoveWithinArray((int)index, length, out var removed) + ) + { + return context.Return(removed); + } + var value = table[index]; + for (; index < length; index++) + { + table[index] = table[index + 1]; + } + table[index] = LuaValue.Nil; + return context.Return(value); } - public ValueTask Sort( + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + public async ValueTask Sort( LuaFunctionExecutionContext context, CancellationToken cancellationToken ) @@ -150,43 +208,19 @@ CancellationToken cancellationToken var table = context.GetArgument(0); var comparer = context.HasArgument(1) ? context.GetArgument(1) : null; - var length = table.ArrayLength; - if (length <= 1) + var length = await GetLengthAsync(context.State, table, cancellationToken); + // A __len result can extend beyond the backing array; use raw table keys. + if (length > 1) { - return new(context.Return()); + await AuxSortAsync(context.State, table, 0, length - 1, comparer, cancellationToken); } - - var sortTask = AuxSortAsync( - context.State, - table.GetArrayMemory()[..length], - 0, - length - 1, - comparer, - cancellationToken - ); - if (sortTask.IsCompletedSuccessfully) - { - sortTask.GetAwaiter().GetResult(); - return new(context.Return()); - } - - return AwaitSortAsync(context, sortTask); - } - - [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] - static async ValueTask AwaitSortAsync( - LuaFunctionExecutionContext context, - ValueTask sortTask - ) - { - await sortTask; return context.Return(); } [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder))] async ValueTask AuxSortAsync( LuaState state, - Memory memory, + LuaTable table, int lo, int up, LuaFunction? comparer, @@ -199,13 +233,13 @@ CancellationToken cancellationToken await CompareAsync( state, comparer, - memory.Span[up], - memory.Span[lo], + table.GetIntegerValue(up + 1), + table.GetIntegerValue(lo + 1), cancellationToken ) ) { - Swap(memory.Span, lo, up); + Swap(table, lo, up); } if (up - lo == 1) @@ -218,25 +252,25 @@ await CompareAsync( await CompareAsync( state, comparer, - memory.Span[pivotIndex], - memory.Span[lo], + table.GetIntegerValue(pivotIndex + 1), + table.GetIntegerValue(lo + 1), cancellationToken ) ) { - Swap(memory.Span, pivotIndex, lo); + Swap(table, pivotIndex, lo); } else if ( await CompareAsync( state, comparer, - memory.Span[up], - memory.Span[pivotIndex], + table.GetIntegerValue(up + 1), + table.GetIntegerValue(pivotIndex + 1), cancellationToken ) ) { - Swap(memory.Span, pivotIndex, up); + Swap(table, pivotIndex, up); } if (up - lo == 2) @@ -244,14 +278,14 @@ await CompareAsync( return; } - Swap(memory.Span, pivotIndex, up - 1); - pivotIndex = await PartitionAsync(state, memory, lo, up, comparer, cancellationToken); + Swap(table, pivotIndex, up - 1); + pivotIndex = await PartitionAsync(state, table, lo, up, comparer, cancellationToken); if (pivotIndex - lo < up - pivotIndex) { var sortTask = AuxSortAsync( state, - memory, + table, lo, pivotIndex - 1, comparer, @@ -268,7 +302,7 @@ await CompareAsync( { var sortTask = AuxSortAsync( state, - memory, + table, pivotIndex + 1, up, comparer, @@ -287,20 +321,28 @@ await CompareAsync( [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] async ValueTask PartitionAsync( LuaState state, - Memory memory, + LuaTable table, int lo, int up, LuaFunction? comparer, CancellationToken cancellationToken ) { - var pivot = memory.Span[up - 1]; + var pivot = table.GetIntegerValue(up); var i = lo; var j = up - 1; while (true) { - while (await CompareAsync(state, comparer, memory.Span[++i], pivot, cancellationToken)) + while ( + await CompareAsync( + state, + comparer, + table.GetIntegerValue((++i) + 1), + pivot, + cancellationToken + ) + ) { if (i == up - 1) { @@ -308,7 +350,15 @@ CancellationToken cancellationToken } } - while (await CompareAsync(state, comparer, pivot, memory.Span[--j], cancellationToken)) + while ( + await CompareAsync( + state, + comparer, + pivot, + table.GetIntegerValue((--j) + 1), + cancellationToken + ) + ) { if (j < i) { @@ -318,11 +368,11 @@ CancellationToken cancellationToken if (j < i) { - Swap(memory.Span, up - 1, i); + Swap(table, up - 1, i); return i; } - Swap(memory.Span, i, j); + Swap(table, i, j); } } @@ -371,19 +421,22 @@ static async ValueTask AwaitCompareAsync(LuaState state, int top, ValueTas } [MethodImpl(MethodImplOptions.AggressiveInlining)] - void Swap(Span span, int i, int j) + void Swap(LuaTable table, int i, int j) { - (span[i], span[j]) = (span[j], span[i]); + table.SwapIntegerValues(i + 1, j + 1); } - public ValueTask Unpack( + [AsyncMethodBuilder(typeof(LightAsyncValueTaskMethodBuilder<>))] + public async ValueTask Unpack( LuaFunctionExecutionContext context, CancellationToken cancellationToken ) { var arg0 = context.GetArgument(0); var arg1 = context.HasArgument(1) ? (long)context.GetArgument(1) : 1; - var arg2 = context.HasArgument(2) ? (long)context.GetArgument(2) : arg0.ArrayLength; + var arg2 = context.HasArgument(2) + ? (long)context.GetArgument(2) + : await GetLengthAsync(context.State, arg0, cancellationToken); var index = 0; arg1 = Math.Min(arg1, arg2 + 1); @@ -395,6 +448,6 @@ CancellationToken cancellationToken index++; } - return new(index); + return index; } } diff --git a/tests/Lua.Tests/TableLibraryMetatableTests.cs b/tests/Lua.Tests/TableLibraryMetatableTests.cs new file mode 100644 index 00000000..085566f8 --- /dev/null +++ b/tests/Lua.Tests/TableLibraryMetatableTests.cs @@ -0,0 +1,317 @@ +using Lua.Standard; + +namespace Lua.Tests; + +public class TableLibraryMetatableTests +{ + [Test] + public async Task Test_InsertAtArrayBoundaryPromotesConsecutiveHashKeys() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["t"] = new LuaTable(0, 0); + await state.DoStringAsync( + """ + t[9] = 99 + for i=1,7 do t[i]=i end + assert(#t == 7) + table.insert(t,1,42) + assert(#t == 9 and t[1] == 42 and t[8] == 7 and t[9] == 99) + """ + ); + } + + [Test] + public async Task Test_MovesRespectRetainedWritableArray() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + var table = new LuaTable(16, 0); + table[1] = 1; + table[2] = 2; + table[3] = 3; + Assert.That(table.ArrayLength, Is.EqualTo(3)); + var memory = table.GetArrayMemory(); + state.Environment["t"] = table; + await state.DoStringAsync("table.insert(t,1,42); assert(table.remove(t,2) == 1)"); + memory.Span[1] = LuaValue.Nil; + Assert.That(table.ArrayLength, Is.EqualTo(1)); + await state.DoStringAsync( + "table.insert(t,5); assert(#t == 3 and t[1] == 42 and t[2] == 5 and t[3] == 3)" + ); + } + + [Test] + public async Task Test_InsertNilValueAndArgumentCount() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = {10,20} + table.insert(t, 1, nil) + assert(rawget(t,1) == nil and t[2] == 10 and t[3] == 20) + assert(not pcall(table.insert, t)) + assert(not pcall(table.insert, t, 1, 42, 99)) + """ + ); + } + + [TestCase(-2)] + [TestCase(-1)] + public async Task Test_AppendWithNegativeLength(int length) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + "local t = setmetatable({}, {__len=function() return " + + length + + " end}); table.insert(t,42); assert(rawget(t," + + (length + 1) + + ") == 42)" + ); + } + + [TestCase(0)] + [TestCase(2)] + [TestCase(8)] + [TestCase(16)] + public async Task Test_MovesPreserveValuesBeyondLogicalEnd(int length) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + "local n = " + + length + + "; " + + """ + local t = setmetatable({}, {__len=function() return n end}) + for i=1,n do t[i]=i end + t[n+2]=99 + table.insert(t,1,42) + assert(t[1] == 42 and rawget(t,n+2) == 99) + for i=2,n+1 do assert(t[i] == i-1) end + local v = table.remove(t,1) + assert(v == 42 and rawget(t,n+2) == 99) + for i=1,n-1 do assert(t[i] == i) end + assert(rawget(t,n) == nil and rawget(t,n+1) == (n > 0 and n or nil)) + """ + ); + } + + [Test] + public async Task Test_MovesAcrossHolesKeepLengthCacheValid() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = {1,2,3,4} + assert(#t == 4) + setmetatable(t, {__len=function() return 4 end}) + t[3] = nil + table.insert(t,2,5) + setmetatable(t,nil) + assert(#t == 3 and t[4] == nil and t[5] == 4) + setmetatable(t, {__len=function() return 5 end}) + assert(table.remove(t,2) == 5) + setmetatable(t,nil) + assert(#t == 2 and t[3] == nil and t[4] == 4 and t[5] == nil) + """ + ); + } + + [Test] + public async Task Test_SortUsesCurrentArrayAfterComparatorResizesTable() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = {3,2,1} + assert(#t == 3) + table.sort(t, function(a,b) t[64] = 99; return a < b end) + assert(t[1] == 1 and t[2] == 2 and t[3] == 3 and t[64] == 99 and #t == 3) + """ + ); + } + + [TestCase("local a,b,c = table.unpack(t); assert(a == 30 and b == 10 and c == nil)")] + [TestCase("assert(table.concat(t, ',') == '30,10')")] + [TestCase( + "table.insert(t, 5); assert(t[1] == 30 and t[2] == 10 and t[3] == 5 and t[4] == nil)" + )] + [TestCase( + "table.insert(t, 1, 5); assert(t[1] == 5 and t[2] == 30 and t[3] == 10 and t[4] == nil)" + )] + [TestCase("assert(table.remove(t) == 10); assert(t[1] == 30 and t[2] == nil and t[3] == 99)")] + [TestCase( + "assert(table.remove(t, 1) == 30); assert(t[1] == 10 and t[2] == nil and t[3] == 99)" + )] + [TestCase("table.sort(t); assert(t[1] == 10 and t[2] == 30 and t[3] == 99)")] + public async Task Test_LengthControlsOperationRange(string operation) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local calls = 0 + local t + t = setmetatable({30, 10, 99}, {__len = function(self) + assert(self == t) + calls = calls + 1 + return 2 + end}) + """ + + operation + + "; assert(calls == 1)" + ); + } + + [TestCase("table.unpack(t, 1, 2)")] + [TestCase("table.concat(t, ',', 1, 2)")] + public async Task Test_ExplicitEndDoesNotEvaluateLength(string operation) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + "local t = setmetatable({1,2}, {__len = function() error('unexpected length') end}); " + + operation + ); + } + + [TestCase("assert(select('#', table.unpack(t, nil, nil)) == 1)")] + [TestCase("assert(table.concat(t, nil, nil, nil) == '1')")] + public async Task Test_NilEndUsesLength(string operation) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + "local t = setmetatable({1,2}, {__len = function() return 1 end}); " + operation + ); + } + + [TestCase("table.unpack(t)")] + [TestCase("table.concat(t)")] + [TestCase("table.insert(t, 4)")] + [TestCase("table.remove(t)")] + [TestCase("table.sort(t)")] + public async Task Test_LengthErrorIsProtected(string operation) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = setmetatable({1,2,3}, {__len = function() error('length failed', 0) end}) + local ok, err = pcall(function() + """ + + operation + + " end); assert(not ok and string.find(err, 'length failed', 1, true))" + ); + Assert.That((await state.DoStringAsync("return 42"))[0].Read(), Is.EqualTo(42)); + } + + [TestCase("table.unpack(t)")] + [TestCase("table.concat(t)")] + [TestCase("table.insert(t, 4)")] + [TestCase("table.remove(t)")] + [TestCase("table.sort(t)")] + public async Task Test_InvalidLengthResult(string operation) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = setmetatable({1,2,3}, {__len = function() return {} end}) + local ok, err = pcall(function() + """ + + operation + + " end); assert(not ok and string.find(err, 'length', 1, true))" + ); + } + + [Test] + public async Task Test_RawElementAccessAndLengthBeyondStorage() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local mt = { + __len = function() return 3 end, + __index = function() error('unexpected index') end, + __newindex = function() error('unexpected newindex') end + } + local t = setmetatable({}, mt) + local values = table.pack(table.unpack(t)) + assert(values.n == 3 and values[1] == nil and values[3] == nil) + table.insert(t, 7) + assert(rawget(t, 4) == 7) + assert(table.remove(t, 4) == 7 and rawget(t, 4) == nil) + table.sort(t, function(a,b) return false end) + assert(next(t) == nil) + """ + ); + } + + [Test] + public async Task Test_LengthCanMutateTable() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = setmetatable({}, {__len = function(self) + self[1] = 2 + self[2] = 1 + return 2 + end}) + table.sort(t) + assert(t[1] == 1 and t[2] == 2) + """ + ); + } + + [Test] + public async Task Test_EmptyLengthRemoveAndInsert() + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + await state.DoStringAsync( + """ + local t = setmetatable({99}, {__len = function() return 0 end}) + t[0] = 42 + assert(table.remove(t) == 42 and rawget(t,0) == nil and t[1] == 99) + assert(not pcall(table.insert, t, 2, 7)) + table.insert(t, 7) + assert(t[1] == 7 and t[2] == nil) + """ + ); + } + + [TestCase("local a,b,c = table.unpack(t); assert(a == 2 and b == 1 and c == nil)")] + [TestCase("assert(table.concat(t, ',') == '2,1')")] + [TestCase("table.insert(t, 3); assert(t[3] == 3 and t[4] == nil)")] + [TestCase("assert(table.remove(t) == 1 and t[2] == nil and t[3] == 99)")] + [TestCase("table.sort(t); assert(t[1] == 1 and t[2] == 2 and t[3] == 99)")] + public async Task Test_AsyncLength(string operation) + { + var state = LuaState.Create(); + state.OpenStandardLibraries(); + var calls = 0; + state.Environment["length"] = new LuaFunction( + "length", + async (context, ct) => + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + calls++; + return context.Return(2); + } + ); + await state.DoStringAsync( + "local t = setmetatable({2,1,99}, {__len = length}); " + operation + ); + Assert.That(calls, Is.EqualTo(1)); + } +}