diff --git a/README.md b/README.md index 32b1297b..aadbbb1b 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,13 @@ return add(1, 2) > [!TIP] > Defining functions with `LuaFunction` can be somewhat verbose. When adding multiple functions, it is recommended to use the Source Generator with the `[LuaObject]` attribute. For more details, see the [LuaObject](#luaobject) section. +### Binding a C# Target + +`Lua.Runtime.BoundClosure` retains a typed target for custom bindings and generators. +Inside a static callback, cast `context.State.GetCurrentFrame().Function` to +`BoundClosure` and read its `Target`. The target is separate from Lua arguments +and upvalues. + ## Low-Level API In addition to normal function calls, it is possible to directly call Lua's low-level API. @@ -453,6 +460,22 @@ 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. +### Bound instance methods + +Set `[LuaObject(BindInstanceMethods = true)]` to bind instance methods to their +object and call them with dot notation. For the `LuaVector3` example above: + +```lua +local v2 = v1.normalized() +local normalize = v1.normalized +local v3 = normalize() +``` + +Bound methods are cached per object and read all supplied arguments as method +parameters. The option defaults to `false`, which uses explicit receivers +(`obj:Method(...)` or `obj.Method(obj, ...)`). Static methods and metamethods keep +their existing calling conventions. + ### 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. diff --git a/README_JA.md b/README_JA.md index 28515e03..18322f95 100644 --- a/README_JA.md +++ b/README_JA.md @@ -451,6 +451,15 @@ print(v2.x, v2.y, v2.z) 利用可能でない型に対してはSource Generatorがコンパイルエラーを出力します。 +### インスタンスメソッドのバインド + +`[LuaObject(BindInstanceMethods = true)]`を指定すると、インスタンスメソッドを +`obj.Method(...)`で呼び出せます。`local method = obj.Method; method(...)`のように +取り出した関数も元のオブジェクトを保持し、オブジェクトごとにキャッシュされます。 +このモードでは、渡した引数はすべてメソッドの引数として扱われます。 +staticメソッドとメタメソッドの呼び出し規約は変わりません。 +既定値は`false`で、従来の`obj:Method(...)`または`obj.Method(obj, ...)`を使用します。 + ### LuaMetamethod `[LuaMetamethod]`属性を追加することで、C#のメソッドをLua側で使用されるメタメソッドとして設定することが可能です。 diff --git a/src/Lua.Annotations/Attributes.cs b/src/Lua.Annotations/Attributes.cs index 46d68939..352ef484 100644 --- a/src/Lua.Annotations/Attributes.cs +++ b/src/Lua.Annotations/Attributes.cs @@ -11,6 +11,10 @@ public LuaObjectAttribute(string name) } public string? Name { get; } + + /// Gets or sets whether instance methods are bound to their owning object. + /// When true, instance methods use dot notation without an explicit receiver. + public bool BindInstanceMethods { get; set; } } [AttributeUsage(AttributeTargets.Method | AttributeTargets.Field | AttributeTargets.Property)] diff --git a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs index fc3fc078..c1ce34fc 100644 --- a/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs +++ b/src/Lua.SourceGenerator/LuaObjectGenerator.Emit.cs @@ -639,12 +639,17 @@ TempCollections tempCollections ); } + var boundMethodIndex = 0; foreach ( var methodMetadata in typeMetadata.Methods.Where(x => x.HasMemberAttribute) ) { + var function = + methodMetadata.IsStatic || !typeMetadata.BindInstanceMethods + ? $"__function_{methodMetadata.LuaMemberName}" + : $"userData.__GetBoundMethod({boundMethodIndex++}, \"{methodMetadata.LuaMemberName}\", __function_{methodMetadata.LuaMemberName})"; builder.AppendLine( - @$"if (stringKey == ""{methodMetadata.LuaMemberName}"") return new global::System.Threading.Tasks.ValueTask(context.Return(new global::Lua.LuaValue(__function_{methodMetadata.LuaMemberName})));" + @$"if (stringKey == ""{methodMetadata.LuaMemberName}"") return new global::System.Threading.Tasks.ValueTask(context.Return(new global::Lua.LuaValue({function})));" ); } @@ -976,6 +981,13 @@ in SourceProductionContext context ) { builder.AppendLine(); + var boundMethodCount = typeMetadata.BindInstanceMethods + ? typeMetadata.Methods.Count(x => x.HasMemberAttribute && !x.IsStatic) + : 0; + if (boundMethodCount > 0) + { + EmitBoundMethodCache(typeMetadata, boundMethodCount, builder); + } foreach (var methodMetadata in typeMetadata.Methods) { @@ -991,7 +1003,8 @@ in SourceProductionContext context methodMetadata, builder, references, - compilation + compilation, + bindTarget: typeMetadata.BindInstanceMethods && !methodMetadata.IsStatic ); } @@ -1012,7 +1025,10 @@ in SourceProductionContext context continue; } - if (functionName == null) + if ( + functionName == null + || (typeMetadata.BindInstanceMethods && !methodMetadata.IsStatic) + ) { if ( methodMetadata.Metamethod @@ -1047,6 +1063,60 @@ and not LuaObjectMetamethod.NewIndex return true; } + static void EmitBoundMethodCache( + TypeMetadata typeMetadata, + int methodCount, + CodeBuilder builder + ) + { + var closureType = $"global::Lua.Runtime.BoundClosure<{typeMetadata.FullTypeName}>"; + if (typeMetadata.Symbol.IsRecord) + { + builder.AppendLine( + $"static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable<{typeMetadata.FullTypeName}, {closureType}?[]> __boundMethods = new();" + ); + } + else + { + builder.AppendLine($"{closureType}?[]? __boundMethods;"); + } + + builder.AppendLine( + $"{closureType} __GetBoundMethod(int index, string name, global::System.Func> func)" + ); + using (builder.BeginBlockScope()) + { + if (typeMetadata.Symbol.IsRecord) + { + builder.AppendLine( + $"var methods = __boundMethods.GetValue(this, static _ => new {closureType}?[{methodCount}]);" + ); + } + else + { + builder.AppendLine( + "var methods = global::System.Threading.Volatile.Read(ref __boundMethods);" + ); + builder.AppendLine("if (methods == null)"); + using (builder.BeginBlockScope()) + { + builder.AppendLine($"methods = new {closureType}?[{methodCount}];"); + builder.AppendLine( + "methods = global::System.Threading.Interlocked.CompareExchange(ref __boundMethods, methods, null) ?? methods;" + ); + } + } + builder.AppendLine( + "var function = global::System.Threading.Volatile.Read(ref methods[index]);" + ); + builder.AppendLine("if (function != null) return function;"); + builder.AppendLine($"function = new {closureType}(name, this, func);"); + builder.AppendLine( + "return global::System.Threading.Interlocked.CompareExchange(ref methods[index], function, null) ?? function;" + ); + } + } + static void EmitMethodFunction( string functionName, string chunkName, @@ -1054,19 +1124,43 @@ static void EmitMethodFunction( MethodMetadata methodMetadata, CodeBuilder builder, SymbolReferences references, - Compilation compilation + Compilation compilation, + bool bindTarget = false ) { - builder.AppendLine( - $@"static readonly global::Lua.LuaFunction {functionName} = new global::Lua.LuaFunction(""{chunkName}"", {(methodMetadata.IsAsync ? "async" : "")} (context, ct) =>" - ); + if (bindTarget) + { + builder.AppendLine( + $"static readonly global::System.Func> {functionName} = static {(methodMetadata.IsAsync ? "async" : "")} (context, ct) =>" + ); + } + else + { + builder.AppendLine( + $@"static readonly global::Lua.LuaFunction {functionName} = new global::Lua.LuaFunction(""{chunkName}"", {(methodMetadata.IsAsync ? "async" : "")} (context, ct) =>" + ); + } using (builder.BeginBlockScope()) { - EmitMethodFunctionBody(typeMetadata, methodMetadata, builder, references, compilation); + if (bindTarget) + { + builder.AppendLine( + $"var userData = ((global::Lua.Runtime.BoundClosure<{typeMetadata.FullTypeName}>)context.State.GetCurrentFrame().Function).Target;" + ); + } + EmitMethodFunctionBody( + typeMetadata, + methodMetadata, + builder, + references, + compilation, + existingUserDataVariable: bindTarget ? "userData" : null, + boundTarget: bindTarget + ); } - builder.AppendLine(");"); + builder.AppendLine(bindTarget ? ";" : ");"); builder.AppendLine(); } @@ -1077,7 +1171,8 @@ static void EmitMethodFunctionBody( SymbolReferences references, Compilation compilation, IReadOnlyDictionary? preloadedArguments = null, - string? existingUserDataVariable = null + string? existingUserDataVariable = null, + bool boundTarget = false ) { var index = 0; @@ -1086,7 +1181,7 @@ static void EmitMethodFunctionBody( var extraReturnValues = new List(); var userDataVariable = existingUserDataVariable ?? "userData"; - if (!methodMetadata.IsStatic) + if (!methodMetadata.IsStatic && !boundTarget) { if (existingUserDataVariable == null) { diff --git a/src/Lua.SourceGenerator/TypeMetadata.cs b/src/Lua.SourceGenerator/TypeMetadata.cs index d529a655..1e225d64 100644 --- a/src/Lua.SourceGenerator/TypeMetadata.cs +++ b/src/Lua.SourceGenerator/TypeMetadata.cs @@ -10,6 +10,7 @@ class TypeMetadata public INamedTypeSymbol Symbol { get; } public string TypeName { get; } public string FullTypeName { get; } + public bool BindInstanceMethods { get; } public PropertyMetadata[] Properties { get; } public MethodMetadata[] Methods { get; } @@ -24,6 +25,11 @@ SymbolReferences references TypeName = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); FullTypeName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + BindInstanceMethods = + symbol + .GetAttribute(references.LuaObjectAttribute) + ?.NamedArguments.Any(x => x.Key == "BindInstanceMethods" && x.Value.Value is true) + == true; var properties = new List(); var methods = new List(); diff --git a/src/Lua/Runtime/BoundClosure.cs b/src/Lua/Runtime/BoundClosure.cs new file mode 100644 index 00000000..5f535f54 --- /dev/null +++ b/src/Lua/Runtime/BoundClosure.cs @@ -0,0 +1,21 @@ +namespace Lua.Runtime; + +/// A managed Lua function with a bound target. +/// The type of the bound target. +/// The function name used in diagnostics. +/// The target retained by this closure. +/// The managed callback, following the result contract of . +/// +/// Retrieve this closure through context.State.GetCurrentFrame().Function +/// inside the callback to access its target. The target is separate from Lua +/// arguments and upvalues; this type does not insert or remove a self argument. +/// +public sealed class BoundClosure( + string name, + T target, + Func> func +) : LuaFunction(name, func) +{ + /// Gets the target retained by this closure. + public T Target { get; } = target; +} diff --git a/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs b/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs index c65ef88d..afdb8117 100644 --- a/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs +++ b/tests/Lua.SourceGenerator.Tests/LuaObjectGeneratorTests.cs @@ -7,6 +7,33 @@ namespace Lua.SourceGenerator.Tests; public class LuaObjectGeneratorTests { + [TestCase("class BoundObject")] + [TestCase("class BoundObject")] + [TestCase("record BoundObject")] + [TestCase("record BoundObject")] + public void BoundMethods_GenerateWithoutDiagnostics(string declaration) + { + var result = CompileGeneratedSource( + $$""" + using Lua; + [LuaObject(BindInstanceMethods = true)] + internal partial {{declaration}} + { + [LuaMember] + public int Read(int value = 1) => value; + + [LuaMember, LuaMetamethod(LuaObjectMetamethod.Len)] + public int Length() => 2; + + [LuaMember] + public static int StaticRead() => 3; + } + """ + ); + Assert.That(result.Success, Is.True, string.Join("\n", result.Diagnostics)); + Assert.That(result.Diagnostics, Is.Empty); + } + [TestCase("public static int Read(System.ReadOnlySpan values) => values.Length;")] [TestCase("public int Read(System.ReadOnlySpan values) => values.Length;")] [TestCase( diff --git a/tests/Lua.Tests/BoundClosureTests.cs b/tests/Lua.Tests/BoundClosureTests.cs new file mode 100644 index 00000000..e0f2557b --- /dev/null +++ b/tests/Lua.Tests/BoundClosureTests.cs @@ -0,0 +1,91 @@ +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +public class BoundClosureTests +{ + [Test] + public async Task SharedCallbackUsesItsOwnTarget() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var first = new List(); + var second = new List(); + state.Environment["first"] = new LuaTable + { + ["add"] = new BoundClosure>("add", first, Add), + }; + state.Environment["second"] = new LuaTable + { + ["add"] = new BoundClosure>("add", second, Add), + }; + + await state.DoStringAsync( + """ + local saved = first.add + assert(first.add(10) == 1) + assert(second.add(20) == 1) + local function tail() return saved(30) end + assert(tail() == 2) + assert(second.add(40) == 2) + """ + ); + + Assert.That(first, Is.EqualTo(new[] { 10, 30 })); + Assert.That(second, Is.EqualTo(new[] { 20, 40 })); + Assert.That(state.CallStackFrameCount, Is.Zero); + Assert.That(state.Stack.Count, Is.Zero); + + static ValueTask Add(LuaFunctionExecutionContext context, CancellationToken ct) + { + var closure = (BoundClosure>)context.State.GetCurrentFrame().Function; + Assert.That(context.ArgumentCount, Is.EqualTo(1)); + closure.Target.Add(context.GetArgument(0)); + return new(context.Return(closure.Target.Count)); + } + } + + [Test] + public async Task ValueTypeTargetDoesNotChangeArguments() + { + using var state = LuaState.Create(); + state.Environment["bound"] = new BoundClosure( + "bound", + 42, + static (context, ct) => + { + var closure = (BoundClosure)context.State.GetCurrentFrame().Function; + return new(context.Return([closure.Target, .. context.Arguments])); + } + ); + + var results = await state.DoStringAsync("return bound('hello', nil, 123)"); + + Assert.That(results, Is.EqualTo(new LuaValue[] { 42, "hello", LuaValue.Nil, 123 })); + } + + [Test] + public async Task TargetIsSeparateFromLuaUpvalues() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + state.Environment["bound"] = new BoundClosure( + "bound", + "original", + static (context, ct) => + { + var closure = (BoundClosure)context.State.GetCurrentFrame().Function; + return new(context.Return(closure.Target)); + } + ); + + await state.DoStringAsync( + """ + assert(debug.getupvalue(bound, 1) == nil) + assert(debug.setupvalue(bound, 1, 'replacement') == nil) + assert(bound() == 'original') + """ + ); + } +} diff --git a/tests/Lua.Tests/LuaObjectBindingTests.cs b/tests/Lua.Tests/LuaObjectBindingTests.cs new file mode 100644 index 00000000..bf510bb8 --- /dev/null +++ b/tests/Lua.Tests/LuaObjectBindingTests.cs @@ -0,0 +1,228 @@ +using Lua.Runtime; +using Lua.Standard; + +namespace Lua.Tests; + +[LuaObject] +public partial class BindingBaseObject +{ + [LuaMember] + public int Value { get; set; } + + [LuaMember("read")] + public virtual int Read() => Value; +} + +[LuaObject(BindInstanceMethods = true)] +public partial class BindingObject : BindingBaseObject +{ + public CancellationToken ObservedToken { get; private set; } + + public override int Read() => Value * 2; + + [LuaMember("add")] + public int Add(int amount = 1) => Value += amount; + + [LuaMember("readOther")] + public int ReadOther(BindingObject other) => other.Value; + + [LuaMember] + public static int StaticValue() => 7; + + [LuaMember] + public int CountAll(ReadOnlySpan values) => values.Length; + + [LuaMember] + public int Count(int prefix = 10, ReadOnlySpan values = default) => + prefix + values.Length; + + [LuaMember] + public int RefOut(out int doubled, ref int first, ReadOnlySpan values) + { + doubled = first * 2; + first++; + return values.Length; + } + + [LuaMember] + public int Required(int value) => value; + + [LuaMember] + public async ValueTask Capture( + int first, + ReadOnlyMemory values, + CancellationToken ct + ) + { + ObservedToken = ct; + await Task.Yield(); + ct.ThrowIfCancellationRequested(); + var result = new LuaTable { ["first"] = first, ["count"] = values.Length }; + for (var i = 0; i < values.Length; i++) + result[i + 1] = values.Span[i]; + return result; + } + + [LuaMember("length"), LuaMetamethod(LuaObjectMetamethod.Len)] + public int Length() => Value; + + [LuaMember("multiply"), LuaMetamethod(LuaObjectMetamethod.Mul)] + public int Multiply(BindingObject other) => Value * other.Value; +} + +[LuaObject(BindInstanceMethods = true)] +public partial record BindingRecord(int Value) +{ + [LuaMember] + public int Read() => Value; +} + +public class LuaObjectBindingTests +{ + [Test] + public async Task DotCallsRetainTheirReceiverAndFunctionIdentity() + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var first = new BindingObject(); + var second = new BindingObject { Value = 10 }; + state.Environment["first"] = first; + state.Environment["second"] = second; + + var results = await state.DoStringAsync( + """ + assert(first.add == first.add) + assert(first.add ~= second.add) + assert(first.StaticValue == second.StaticValue) + local saved = first.add + local function tail() return saved(3) end + return first.add(2), tail(), first.readOther(first), first.readOther(second), + first.read(), saved() + """ + ); + + Assert.That(results, Is.EqualTo(new LuaValue[] { 2, 5, 5, 10, 10, 6 })); + Assert.That(first.Value, Is.EqualTo(6)); + Assert.That(second.Value, Is.EqualTo(10)); + } + + [TestCase("CountAll()", 0)] + [TestCase("CountAll(1, nil, 3)", 3)] + [TestCase("Count()", 10)] + [TestCase("Count(nil)", 10)] + [TestCase("Count(7, nil, 3)", 9)] + public async Task DotCallsPreserveVariadicAndOptionalArguments(string call, int expected) + { + using var state = LuaState.Create(); + state.Environment["target"] = new BindingObject(); + var results = await state.DoStringAsync($"return target.{call}"); + Assert.That(results, Is.EqualTo(new LuaValue[] { expected })); + } + + [Test] + public async Task DotCallsPreserveRefOutArgumentPositions() + { + using var state = LuaState.Create(); + state.Environment["target"] = new BindingObject(); + var results = await state.DoStringAsync("return target.RefOut(5, 100, 200)"); + Assert.That(results, Is.EqualTo(new LuaValue[] { 2, 10, 6 })); + } + + [TestCase("Required()")] + [TestCase("Required('invalid')")] + public void InvalidArgumentsReportTheirLuaPosition(string call) + { + using var state = LuaState.Create(); + state.Environment["target"] = new BindingObject(); + var exception = Assert.ThrowsAsync(async () => + await state.DoStringAsync($"return target.{call}") + ); + Assert.That(exception!.Message, Does.Contain("bad argument #1")); + } + + [Test] + public async Task AsyncDotCallsPreserveArgumentsAndCancellation([Values] bool hooks) + { + using var state = LuaState.Create(); + state.OpenStandardLibraries(); + var target = new BindingObject(); + state.Environment["target"] = target; + using var cancellation = new CancellationTokenSource(); + if (hooks) + await state.DoStringAsync("debug.sethook(function() end, 'cr')"); + + var results = await state.DoStringAsync( + "local values = target.Capture(7, nil, 3, nil); return values.first, values.count, values[1], values[2], values[3]", + cancellationToken: cancellation.Token + ); + + Assert.That(results, Is.EqualTo(new LuaValue[] { 7, 3, LuaValue.Nil, 3, LuaValue.Nil })); + Assert.That(target.ObservedToken, Is.EqualTo(cancellation.Token)); + } + + [Test] + public async Task MetamethodsKeepExplicitReceivers() + { + using var state = LuaState.Create(); + state.Environment["first"] = new BindingObject { Value = 3 }; + state.Environment["second"] = new BindingObject { Value = 7 }; + var results = await state.DoStringAsync( + "return first.length(), #first, first.multiply(second), first * second" + ); + Assert.That(results, Is.EqualTo(new LuaValue[] { 3, 3, 21, 21 })); + } + + [Test] + public async Task DefaultBindingsKeepExplicitReceivers() + { + using var state = LuaState.Create(); + state.Environment["first"] = new BindingBaseObject { Value = 7 }; + state.Environment["second"] = new BindingBaseObject { Value = 9 }; + var results = await state.DoStringAsync("return first:read(), first.read(second)"); + Assert.That(results, Is.EqualTo(new LuaValue[] { 7, 9 })); + } + + [Test] + public async Task RecordCachesPreserveEqualityAndCloneReceivers() + { + using var state = LuaState.Create(); + var first = new BindingRecord(7); + var equal = new BindingRecord(7); + state.Environment["first"] = first; + var saved = (await state.DoStringAsync("return first.Read"))[0].Read(); + state.Environment["equal"] = equal; + state.Environment["clone"] = first with { Value = 9 }; + + var results = await state.DoStringAsync( + "return first.Read(), equal.Read(), clone.Read(), first.Read, equal.Read" + ); + + Assert.That(results.Take(3), Is.EqualTo(new LuaValue[] { 7, 7, 9 })); + Assert.That(results[3].Read(), Is.SameAs(saved)); + Assert.That(results[4].Read(), Is.Not.SameAs(saved)); + Assert.That(first, Is.EqualTo(equal)); + Assert.That(first.GetHashCode(), Is.EqualTo(equal.GetHashCode())); + } + + [Test] + public void ConcurrentLookupsPublishTheSameBoundMethod() + { + var target = new BindingObject(); + var functions = new LuaFunction[32]; + Parallel.For( + 0, + functions.Length, + i => + { + using var state = LuaState.Create(); + state.Environment["target"] = target; + functions[i] = state + .DoStringAsync("return target.add") + .GetAwaiter() + .GetResult()[0] + .Read(); + } + ); + Assert.That(functions.All(x => ReferenceEquals(x, functions[0])), Is.True); + } +}