diff --git a/README.md b/README.md index e254236f..497efeb3 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,38 @@ Return types such as `void`, `Task/Task`, `ValueTask/ValueTask`, `UniTask/ If the type is not supported, the Source Generator will output a compile-time error. +### Variable-length arguments + +A final `ReadOnlySpan` or `ReadOnlyMemory` parameter receives the remaining Lua arguments. An injected `CancellationToken` may follow it. Fixed arguments are read first; an instance method's `self` is excluded, and `out` parameters consume no Lua arguments. No remaining arguments produces an empty view, and `nil` values are preserved. + +```cs +[LuaObject] +public partial class Calculator +{ + [LuaMember] + public double Sum(double first, ReadOnlySpan rest) + { + foreach (var value in rest) + first += value.Read(); + return first; + } + + [LuaMember] + public async Task CountAsync(ReadOnlyMemory values, CancellationToken ct) + { + await Task.Delay(1, ct); + return values.Length; + } +} +``` + +```lua +local total = calculator:Sum(1, 2, 3) -- 6 +local count = calculator:CountAsync(1, nil, 3) -- 3 +``` + +These are borrowed, read-only views of the Lua argument stack. Use `ReadOnlyMemory` when an async method needs the values after an `await`. Copy with `ToArray()` if the values must be retained after the method returns. Mutable views, `ref`/`out`/`in` views, other element types and C# `params LuaValue[]` are not supported. Pass the view by value, after any fixed parameters and before an optional injected token. + ### LuaMetamethod By adding the `[LuaMetamethod]` attribute, you can designate a C# method to be used as a Lua metamethod. @@ -646,4 +678,4 @@ While `collectgarbage()` is available, it simply calls the corresponding .NET ga ## License -Lua-CSharp is licensed under the [MIT License](LICENSE). \ No newline at end of file +Lua-CSharp is licensed under the [MIT License](LICENSE). diff --git a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs index ed649329..567b89b3 100644 --- a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs +++ b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs @@ -441,13 +441,7 @@ typeSymbol is not INamedTypeSymbol namedTypeSymbol continue; } - if ( - index == method.Symbol.Parameters.Length - 1 - && SymbolEqualityComparer.Default.Equals( - typeSymbol, - references.CancellationToken - ) - ) + if (index >= method.FixedParameterCount) { continue; } @@ -518,15 +512,27 @@ typeSymbol is not INamedTypeSymbol namedTypeSymbol int contextArgumentIndex ) { - var parameterIndex = methodMetadata.IsStatic - ? contextArgumentIndex - : contextArgumentIndex - 1; - if (parameterIndex < 0 || parameterIndex >= methodMetadata.Symbol.Parameters.Length) + var luaArgumentIndex = methodMetadata.IsStatic ? 0 : 1; + for ( + var parameterIndex = 0; + parameterIndex < methodMetadata.FixedParameterCount; + parameterIndex++ + ) { - return null; + var parameter = methodMetadata.Symbol.Parameters[parameterIndex]; + if (parameter.RefKind == RefKind.Out) + { + continue; + } + if (luaArgumentIndex == contextArgumentIndex) + { + return parameter; + } + luaArgumentIndex++; } - return methodMetadata.Symbol.Parameters[parameterIndex]; + // Variadic arguments are not a single typed key to preload for __index/__newindex. + return null; } static bool IsPreloadableArgumentType( @@ -1096,9 +1102,7 @@ static void EmitMethodFunctionBody( luaArgumentIndex++; } - var hasCancellationToken = false; - - for (var i = 0; i < methodMetadata.Symbol.Parameters.Length; i++) + for (var i = 0; i < methodMetadata.FixedParameterCount; i++) { var parameter = methodMetadata.Symbol.Parameters[i]; var parameterType = parameter.Type; @@ -1111,18 +1115,6 @@ static void EmitMethodFunctionBody( references.LuaValue ); - if ( - i == methodMetadata.Symbol.Parameters.Length - 1 - && SymbolEqualityComparer.Default.Equals( - parameterType, - references.CancellationToken - ) - ) - { - hasCancellationToken = true; - break; - } - var allowNull = parameter.ContainsAttribute(references.AllowNullAttribute); if (parameter.RefKind == RefKind.Out) @@ -1231,6 +1223,25 @@ static void EmitMethodFunctionBody( luaArgumentIndex++; } + if (methodMetadata.HasParamsLikeParameter) + { + var arguments = methodMetadata.ParamsAsMemory + ? "context.ArgumentsMemory" + : "context.Arguments"; + // Out parameters consume no Lua arguments; an instance receiver consumes one. + // Missing optional fixed arguments still leave a valid, empty variadic view. + callArguments.Add( + luaArgumentIndex == 0 + ? arguments + : $"{arguments}.Slice(global::System.Math.Min(context.ArgumentCount, {luaArgumentIndex}))" + ); + } + + if (methodMetadata.HasCancellationTokenParameter) + { + callArguments.Add("ct"); + } + if (methodMetadata.HasReturnValue) { builder.Append("var result = "); @@ -1249,11 +1260,6 @@ static void EmitMethodFunctionBody( ); builder.Append(string.Join(",", callArguments), false); - if (hasCancellationToken) - { - builder.Append(callArguments.Count > 0 ? ",ct" : "ct", false); - } - builder.AppendLine(");", false); } else @@ -1264,11 +1270,6 @@ static void EmitMethodFunctionBody( ); builder.Append(string.Join(",", callArguments), false); - if (hasCancellationToken) - { - builder.Append(callArguments.Count > 0 ? ",ct" : "ct", false); - } - builder.AppendLine(");", false); } diff --git a/src/Lua.SourceGenerator/MethodMetadata.cs b/src/Lua.SourceGenerator/MethodMetadata.cs index 528c8d41..376bd6cd 100644 --- a/src/Lua.SourceGenerator/MethodMetadata.cs +++ b/src/Lua.SourceGenerator/MethodMetadata.cs @@ -10,6 +10,10 @@ class MethodMetadata public bool HasReturnValue { get; } public bool HasMemberAttribute { get; set; } public bool HasMetamethodAttribute { get; set; } + public bool HasCancellationTokenParameter { get; } + public bool HasParamsLikeParameter { get; } + public bool ParamsAsMemory { get; } + public int FixedParameterCount { get; } public string LuaMemberName { get; } public LuaObjectMetamethod Metamethod { get; } @@ -63,5 +67,51 @@ returnType is INamedTypeSymbol namedType && namedType.ContainingNamespace is { } metamethodAttribute.ConstructorArguments[0].Value!.ToString() ); } + + var parameters = symbol.Parameters; + var parameterCount = parameters.Length; + if ( + parameterCount > 0 + && parameters[parameterCount - 1].RefKind == RefKind.None + && SymbolEqualityComparer.Default.Equals( + parameters[parameterCount - 1].Type, + references.CancellationToken + ) + ) + { + HasCancellationTokenParameter = true; + parameterCount--; + } + + // A borrowed argument view must be last, except for the injected cancellation token. + // In particular, a token-only method has no preceding parameter to inspect. + if ( + parameterCount > 0 + && parameters[parameterCount - 1].RefKind == RefKind.None + && parameters[parameterCount - 1].Type + is INamedTypeSymbol { TypeArguments.Length: 1 } parameterType + && SymbolEqualityComparer.Default.Equals( + parameterType.TypeArguments[0], + references.LuaValue + ) + ) + { + ParamsAsMemory = SymbolEqualityComparer.Default.Equals( + parameterType.OriginalDefinition, + references.ReadOnlyMemory + ); + HasParamsLikeParameter = + ParamsAsMemory + || SymbolEqualityComparer.Default.Equals( + parameterType.OriginalDefinition, + references.ReadOnlySpan + ); + if (HasParamsLikeParameter) + { + parameterCount--; + } + } + + FixedParameterCount = parameterCount; } } diff --git a/src/Lua.SourceGenerator/SymbolReferences.cs b/src/Lua.SourceGenerator/SymbolReferences.cs index 9f727587..4c6a55e1 100644 --- a/src/Lua.SourceGenerator/SymbolReferences.cs +++ b/src/Lua.SourceGenerator/SymbolReferences.cs @@ -34,6 +34,8 @@ public sealed class SymbolReferences LuaThread = compilation.GetTypeByMetadataName("Lua.LuaThread")!, LuaTable = compilation.GetTypeByMetadataName("Lua.LuaTable")!, LuaUserData = compilation.GetTypeByMetadataName("Lua.ILuaUserData")!, + ReadOnlyMemory = compilation.GetTypeByMetadataName("System.ReadOnlyMemory`1")!, + ReadOnlySpan = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1")!, CancellationToken = compilation.GetTypeByMetadataName( "System.Threading.CancellationToken" )!, @@ -54,5 +56,7 @@ public sealed class SymbolReferences public INamedTypeSymbol LuaThread { get; private set; } = null!; public INamedTypeSymbol LuaTable { get; private set; } = null!; public INamedTypeSymbol LuaUserData { get; private set; } = null!; + public INamedTypeSymbol ReadOnlyMemory { get; private set; } = null!; + public INamedTypeSymbol ReadOnlySpan { get; private set; } = null!; public INamedTypeSymbol CancellationToken { get; private set; } = null!; } diff --git a/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs b/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs index 9369b793..c65ef88d 100644 --- a/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs +++ b/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs @@ -7,6 +7,133 @@ namespace Lua.SourceGenerator.Tests; public class LuaObjectGeneratorTests { + [TestCase("public static int Read(System.ReadOnlySpan values) => values.Length;")] + [TestCase("public int Read(System.ReadOnlySpan values) => values.Length;")] + [TestCase( + "public static int Read(int first, System.ReadOnlySpan values) => first + values.Length;" + )] + [TestCase( + "public int Read(int first, System.ReadOnlySpan values) => first + values.Length;" + )] + [TestCase( + "public int Read(System.ReadOnlySpan values, System.Threading.CancellationToken ct) => values.Length;" + )] + [TestCase("public static int Read(System.ReadOnlyMemory values) => values.Length;")] + [TestCase("public int Read(System.ReadOnlyMemory values) => values.Length;")] + [TestCase( + "public static int Read(int first, System.ReadOnlyMemory values, System.Threading.CancellationToken ct) => first + values.Length;" + )] + [TestCase( + "public int Read(int first, System.ReadOnlyMemory values, System.Threading.CancellationToken ct) => first + values.Length;" + )] + [TestCase( + "public int Read(int first = 1, System.ReadOnlySpan values = default) => first + values.Length;" + )] + [TestCase( + "public int Read(out int extra, ref int first, System.ReadOnlyMemory values) { extra = first++; return values.Length; }" + )] + [TestCase( + "public System.Threading.Tasks.Task Read(System.ReadOnlyMemory values) => System.Threading.Tasks.Task.FromResult(values.Length);" + )] + [TestCase( + "public System.Threading.Tasks.ValueTask Read(System.ReadOnlyMemory values) => new System.Threading.Tasks.ValueTask(values.Length);" + )] + [TestCase( + "public System.Threading.Tasks.Task Read(System.ReadOnlySpan values) => System.Threading.Tasks.Task.FromResult(values.Length);" + )] + [TestCase("public void Read(System.Threading.CancellationToken ct) { }")] + public void VariadicParameters_GenerateWithoutDiagnostics(string method) + { + var result = CompileGeneratedSource( + $$""" + using Lua; + [LuaObject] + internal partial class VariadicObject + { + [LuaMember] {{method}} + } + internal static class Consumer + { + public static LuaValue Convert() => new VariadicObject(); + } + """ + ); + Assert.That(result.Success, Is.True, string.Join("\n", result.Diagnostics)); + Assert.That(result.Diagnostics, Is.Empty); + } + + [TestCase( + "System.ReadOnlySpan values, int last", + "System.ReadOnlySpan" + )] + [TestCase( + "System.ReadOnlyMemory values, int last", + "System.ReadOnlyMemory" + )] + [TestCase("System.ReadOnlySpan values", "System.ReadOnlySpan")] + [TestCase("System.ReadOnlyMemory values", "System.ReadOnlyMemory")] + [TestCase("System.Span values", "System.Span")] + [TestCase("System.Memory values", "System.Memory")] + [TestCase("ref System.ReadOnlySpan values", "System.ReadOnlySpan")] + [TestCase("out System.ReadOnlyMemory values", "System.ReadOnlyMemory")] + [TestCase("in System.ReadOnlyMemory values", "System.ReadOnlyMemory")] + [TestCase("System.ReadOnlyMemory? values", "System.ReadOnlyMemory")] + [TestCase("params LuaValue[] values", "Lua.LuaValue[]")] + public void VariadicParameters_RejectUnsupportedShapes(string parameters, string typeName) + { + var result = CompileGeneratedSource( + $$""" + using Lua; + [LuaObject] + internal partial class UnsupportedObject + { + [LuaMember] public void Read({{parameters}}) => throw new System.NotSupportedException(); + } + [LuaObject] + internal partial class HealthyObject + { + [LuaMember] public int Value => 1; + } + internal static class Consumer + { + public static LuaValue Convert() => new HealthyObject(); + } + """ + ); + AssertUnsupportedMemberDiagnostic( + result.Diagnostics, + result.UserSource, + "LUACS006", + "values", + typeName + ); + } + + [TestCase("System.ReadOnlySpan")] + [TestCase("System.ReadOnlyMemory")] + public void VariadicParameters_StillValidateAllowNull(string type) + { + var result = CompileGeneratedSource( + $$""" + using Lua; + [LuaObject] + internal partial class UnsupportedObject + { + [LuaMember] public void Read([System.Diagnostics.CodeAnalysis.AllowNull] {{type}} values) { } + } + [LuaObject] + internal partial class HealthyObject { } + """ + ); + AssertUnsupportedMemberDiagnostic( + result.Diagnostics, + result.UserSource, + "LUACS008", + "values", + "values" + ); + } + [TestCase("LuaValue[]", "System.Array.Empty()", "Lua.LuaValue[]")] [TestCase("LuaValue[,]", "new LuaValue[0, 0]", "Lua.LuaValue[*,*]")] [TestCase("int[]", "System.Array.Empty()", "int[]")] diff --git a/tests/Lua.Tests/LuaObjectVariadicTests.cs b/tests/Lua.Tests/LuaObjectVariadicTests.cs new file mode 100644 index 00000000..afd6535f --- /dev/null +++ b/tests/Lua.Tests/LuaObjectVariadicTests.cs @@ -0,0 +1,259 @@ +using Lua.Standard; + +namespace Lua.Tests; + +[LuaObject] +public partial class VariadicUserData +{ + public CancellationToken ObservedToken { get; private set; } + + [LuaMember] + public static int StaticSpan(ReadOnlySpan values) => values.Length; + + [LuaMember] + public int InstanceSpan(ReadOnlySpan values) => values.Length; + + [LuaMember] + public static int StaticMemory(ReadOnlyMemory values) => values.Length; + + [LuaMember] + public int InstanceMemory(ReadOnlyMemory values) => values.Length; + + [LuaMember] + public static LuaTable StaticPrefix( + int first, + ReadOnlySpan values, + CancellationToken ct + ) => Capture(first, values); + + [LuaMember] + public LuaTable InstancePrefix( + int first, + ReadOnlyMemory values, + CancellationToken ct + ) => Capture(first, values.Span); + + [LuaMember] + public int OptionalPrefix(int first = 10, ReadOnlySpan values = default) => + first + values.Length; + + [LuaMember] + public int RefOut( + out int extra, + ref int first, + ReadOnlySpan values, + CancellationToken ct + ) + { + extra = ++first; + return values.Length; + } + + [LuaMember] + public static bool TokenOnly(CancellationToken ct) => ct.CanBeCanceled; + + [LuaMember] + public bool SpanToken(ReadOnlySpan values, CancellationToken ct) => + ct.CanBeCanceled && values.Length == 2; + + [LuaMember] + public static async Task AsyncMemory( + int first, + ReadOnlyMemory values, + CancellationToken ct + ) + { + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + return Capture(first, values.Span); + } + + [LuaMember] + public async ValueTask AsyncInstanceMemory(ReadOnlyMemory values) + { + await Task.Yield(); + return Capture(0, values.Span); + } + + [LuaMember] + public async Task AsyncToken(ReadOnlyMemory values, CancellationToken ct) + { + ObservedToken = ct; + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + return values.Length; + } + + static LuaTable Capture(int first, ReadOnlySpan values) + { + var result = new LuaTable { ["first"] = first, ["count"] = values.Length }; + for (var i = 0; i < values.Length; i++) + result[i + 1] = values[i]; + return result; + } +} + +public abstract partial class VariadicBase +{ + [LuaMember] + public virtual int Inherited(ReadOnlyMemory values) => values.Length; +} + +[LuaObject] +public partial class VariadicDerived : VariadicBase { } + +[LuaObject] +public partial class VariadicMetamethodUserData +{ + public LuaValue LastKey { get; private set; } + public LuaValue LastValue { get; private set; } + + [LuaMetamethod(LuaObjectMetamethod.Call)] + public int Call(ReadOnlySpan values) => values.Length; + + [LuaMetamethod(LuaObjectMetamethod.Index)] + public LuaValue Index(out int extra, ReadOnlySpan values) + { + extra = values.Length; + return values[0]; + } + + [LuaMetamethod(LuaObjectMetamethod.NewIndex)] + public void NewIndex(out int extra, ReadOnlyMemory values) + { + extra = values.Length; + LastKey = values.Span[0]; + LastValue = values.Span[1]; + } +} + +public class LuaObjectVariadicTests +{ + [TestCase("target.StaticSpan", "")] + [TestCase("target:InstanceSpan", "")] + [TestCase("target.StaticMemory", "")] + [TestCase("target:InstanceMemory", "")] + [TestCase("target.StaticSpan", "1, nil, 'three'")] + [TestCase("target:InstanceSpan", "1, nil, 'three'")] + [TestCase("target.StaticMemory", "1, nil, 'three'")] + [TestCase("target:InstanceMemory", "1, nil, 'three'")] + public async Task ArgumentViews_ExcludeReceiverAndPreserveCount(string method, string arguments) + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + var result = await state.DoStringAsync($"return {method}({arguments})"); + Assert.That(result, Is.EqualTo(new LuaValue[] { arguments.Length == 0 ? 0 : 3 })); + } + + [TestCase("target.StaticPrefix")] + [TestCase("target:InstancePrefix")] + [TestCase("target.AsyncMemory")] + public async Task FixedArguments_AreExcludedAndNilIsPreserved(string method) + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + var result = await state.DoStringAsync( + $"local t = {method}(7, nil, 'last', nil) return t.first, t.count, t[1], t[2], t[3]" + ); + Assert.That( + result, + Is.EqualTo(new LuaValue[] { 7, 3, LuaValue.Nil, "last", LuaValue.Nil }) + ); + } + + [TestCase("", 10)] + [TestCase("nil", 10)] + [TestCase("7", 7)] + [TestCase("7, nil, 'last'", 9)] + public async Task MissingOptionalPrefix_LeavesAnEmptyView(string arguments, int expected) + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + var result = await state.DoStringAsync($"return target:OptionalPrefix({arguments})"); + Assert.That(result, Is.EqualTo(new LuaValue[] { expected })); + } + + [Test] + public async Task OutParameters_DoNotConsumeVariadicValues() + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + var result = await state.DoStringAsync("return target:RefOut(5, 100, 200)"); + Assert.That(result, Is.EqualTo(new LuaValue[] { 2, 6, 6 })); + } + + [Test] + public async Task CancellationToken_IsInjectedWithoutConsumingLuaArguments() + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + using var source = new CancellationTokenSource(); + var result = await state.DoStringAsync( + "return target.TokenOnly(), target:SpanToken(1, 2)", + cancellationToken: source.Token + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { true, true })); + } + + [Test] + public async Task AsyncMemory_ReceivesCancellationToken() + { + using var state = LuaState.Create(); + var target = new VariadicUserData(); + state.Environment["target"] = target; + using var source = new CancellationTokenSource(); + var result = await state.DoStringAsync( + "return target:AsyncToken(7, 1, 2)", + cancellationToken: source.Token + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 3 })); + Assert.That(target.ObservedToken, Is.EqualTo(source.Token)); + } + + [Test] + public async Task AsyncInstanceMemory_PreservesArgumentsAcrossAwait() + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + var result = await state.DoStringAsync( + "local t = target:AsyncInstanceMemory(1, nil, 3) return t.count, t[1], t[2], t[3]" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 3, 1, LuaValue.Nil, 3 })); + } + + [Test] + public void MissingRequiredPrefix_ReportsLuaArgumentError() + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicUserData(); + var exception = Assert.ThrowsAsync(async () => + await state.DoStringAsync("return target:InstancePrefix()") + ); + Assert.That(exception!.Message, Does.Contain("bad argument #1")); + } + + [Test] + public async Task VariadicMetamethods_ReceiveKeyValueAndCallArguments() + { + using var state = LuaState.Create(); + var target = new VariadicMetamethodUserData(); + state.Environment["target"] = target; + var result = await state.DoStringAsync( + "target.answer = 42; return target(), target(1, nil, 3), target.unknown, target[7]" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 0, 3, "unknown", 7 })); + Assert.That(target.LastKey, Is.EqualTo(new LuaValue("answer"))); + Assert.That(target.LastValue, Is.EqualTo(new LuaValue(42))); + } + + [Test] + public async Task InheritedVariadicMethod_IsBoundOnDerivedObject() + { + using var state = LuaState.Create(); + state.Environment["target"] = new VariadicDerived(); + var result = await state.DoStringAsync( + "return target:Inherited(), target:Inherited(1, nil, 3)" + ); + Assert.That(result, Is.EqualTo(new LuaValue[] { 0, 3 })); + } +}