diff --git a/Jint.Tests/Runtime/DecoratorTests.cs b/Jint.Tests/Runtime/DecoratorTests.cs new file mode 100644 index 0000000000..d88eec4b3e --- /dev/null +++ b/Jint.Tests/Runtime/DecoratorTests.cs @@ -0,0 +1,419 @@ +namespace Jint.Tests.Runtime; + +public class DecoratorTests +{ + private readonly Engine _engine; + + public DecoratorTests() + { + _engine = new Engine(); + } + + [Fact] + public void MethodDecoratorCanWrapMethod() + { + var result = _engine.Evaluate(""" + function double(target, context) { + return function(...args) { + return target.call(this, ...args) * 2; + }; + } + + class C { + @double + foo() { return 21; } + } + + new C().foo(); + """); + + Assert.Equal(42, result.AsInteger()); + } + + [Fact] + public void ClassDecoratorCanAddProperty() + { + var result = _engine.Evaluate(""" + function addProp(cls, context) { + cls.prototype.added = true; + return cls; + } + + @addProp + class C {} + + new C().added; + """); + + Assert.True(result.AsBoolean()); + } + + [Fact] + public void FieldDecoratorCanTransformInitialValue() + { + var result = _engine.Evaluate(""" + function multiplyBy10(value, context) { + return function(initialValue) { + return initialValue * 10; + }; + } + + class C { + @multiplyBy10 + x = 5; + } + + new C().x; + """); + + Assert.Equal(50, result.AsInteger()); + } + + [Fact] + public void AutoAccessorDecoratorCanAddLogging() + { + var result = _engine.Evaluate(""" + var logs = []; + function logged(value, context) { + return { + get() { + logs.push("get " + context.name); + return value.get.call(this); + }, + set(v) { + logs.push("set " + context.name); + return value.set.call(this, v); + }, + init(v) { + logs.push("init " + context.name); + return v; + } + }; + } + + class C { + @logged + accessor x = 1; + } + + var c = new C(); + var val = c.x; + c.x = 2; + JSON.stringify(logs); + """); + + Assert.Equal("[\"init x\",\"get x\",\"set x\"]", result.AsString()); + } + + [Fact] + public void AddInitializerCallbackRunsOnInstantiation() + { + var result = _engine.Evaluate(""" + function withInit(target, context) { + context.addInitializer(function() { + this.fromInit = true; + }); + } + + class C { + @withInit + foo() {} + } + + new C().fromInit; + """); + + Assert.True(result.AsBoolean()); + } + + [Fact] + public void MultipleDecoratorsAppliedInCorrectOrder() + { + var result = _engine.Evaluate(""" + var evalOrder = []; + function makeD(name) { + evalOrder.push("eval_" + name); + return function(target, context) { + evalOrder.push("apply_" + name); + return function(...args) { + return target.call(this, ...args) + "_" + name; + }; + }; + } + + class C { + @makeD("first") + @makeD("second") + foo() { return "base"; } + } + + JSON.stringify({ + result: new C().foo(), + order: evalOrder + }); + """); + + var json = result.AsString(); + // Evaluation order: top-to-bottom (first, second) + // Application order: bottom-to-top (second applied first, then first) + // Result: first(second(base)) = first("base_second") = "base_second_first" + Assert.Contains("\"result\":\"base_second_first\"", json); + Assert.Contains("\"order\":[\"eval_first\",\"eval_second\",\"apply_second\",\"apply_first\"]", json); + } + + [Fact] + public void DecoratorContextHasCorrectProperties() + { + var result = _engine.Evaluate(""" + var ctx; + function capture(target, context) { + ctx = context; + } + + class C { + @capture + myMethod() {} + } + + JSON.stringify({ + kind: ctx.kind, + name: ctx.name, + isStatic: ctx.static, + isPrivate: ctx.private, + hasAddInitializer: typeof ctx.addInitializer === 'function' + }); + """); + + var json = result.AsString(); + Assert.Contains("\"kind\":\"method\"", json); + Assert.Contains("\"name\":\"myMethod\"", json); + Assert.Contains("\"isStatic\":false", json); + Assert.Contains("\"isPrivate\":false", json); + Assert.Contains("\"hasAddInitializer\":true", json); + } + + [Fact] + public void StaticMethodDecoratorContextHasStaticTrue() + { + var result = _engine.Evaluate(""" + var ctx; + function capture(target, context) { + ctx = context; + } + + class C { + @capture + static bar() {} + } + + ctx.static; + """); + + Assert.True(result.AsBoolean()); + } + + [Fact] + public void ClassDecoratorCanReplaceClass() + { + var result = _engine.Evaluate(""" + function addExtra(cls, context) { + return class extends cls { + get extra() { return 99; } + }; + } + + @addExtra + class C { + foo() { return 1; } + } + + var c = new C(); + JSON.stringify({ foo: c.foo(), extra: c.extra }); + """); + + Assert.Contains("\"foo\":1", result.AsString()); + Assert.Contains("\"extra\":99", result.AsString()); + } + + [Fact] + public void GetterDecoratorCanWrapGetter() + { + var result = _engine.Evaluate(""" + function doubleGetter(target, context) { + return function() { + return target.call(this) * 2; + }; + } + + class C { + #val = 21; + @doubleGetter + get value() { return this.#val; } + } + + new C().value; + """); + + Assert.Equal(42, result.AsInteger()); + } + + [Fact] + public void SetterDecoratorCanWrapSetter() + { + var result = _engine.Evaluate(""" + function doubleSetter(target, context) { + return function(v) { + return target.call(this, v * 2); + }; + } + + class C { + #val = 0; + @doubleSetter + set value(v) { this.#val = v; } + get value() { return this.#val; } + } + + var c = new C(); + c.value = 5; + c.value; + """); + + Assert.Equal(10, result.AsInteger()); + } + + [Fact] + public void DecoratorReturningUndefinedKeepsOriginal() + { + var result = _engine.Evaluate(""" + function noop(target, context) { + // returns undefined + } + + class C { + @noop + foo() { return 42; } + } + + new C().foo(); + """); + + Assert.Equal(42, result.AsInteger()); + } + + [Fact] + public void PublicAutoAccessorBasic() + { + _engine.Execute(""" + class C { + accessor x = 1; + accessor y; + } + var c = new C(); + """); + + Assert.Equal(1, _engine.Evaluate("c.x").AsInteger()); + Assert.True(_engine.Evaluate("c.y").IsUndefined()); + + _engine.Execute("c.x = 42;"); + Assert.Equal(42, _engine.Evaluate("c.x").AsInteger()); + } + + [Fact] + public void StaticAutoAccessor() + { + var result = _engine.Evaluate(""" + class C { + static accessor x = 2; + } + var before = C.x; + C.x = 99; + JSON.stringify({ before: before, after: C.x }); + """); + + Assert.Contains("\"before\":2", result.AsString()); + Assert.Contains("\"after\":99", result.AsString()); + } + + [Fact] + public void PrivateAutoAccessor() + { + var result = _engine.Evaluate(""" + class C { + accessor #x = 5; + getX() { return this.#x; } + setX(v) { this.#x = v; } + } + var c = new C(); + var before = c.getX(); + c.setX(42); + JSON.stringify({ before: before, after: c.getX() }); + """); + + Assert.Contains("\"before\":5", result.AsString()); + Assert.Contains("\"after\":42", result.AsString()); + } + + [Fact] + public void StaticAutoAccessorDerivedClassThrows() + { + var result = _engine.Evaluate(""" + class C { + static accessor x = 1; + } + class D extends C {} + + var threw = false; + try { D.x; } catch(e) { threw = true; } + threw; + """); + + Assert.True(result.AsBoolean()); + } + + [Fact] + public void FieldDecoratorWithMultipleDecorators() + { + var result = _engine.Evaluate(""" + function add1(value, context) { + return function(v) { return v + 1; }; + } + function times2(value, context) { + return function(v) { return v * 2; }; + } + + class C { + @add1 + @times2 + x = 3; + } + + // times2 applied first (inner): 3 * 2 = 6 + // add1 applied second (outer): 6 + 1 = 7 + new C().x; + """); + + Assert.Equal(7, result.AsInteger()); + } + + [Fact] + public void StaticAddInitializerRunsOnClassConstruction() + { + var result = _engine.Evaluate(""" + function addStaticInit(target, context) { + context.addInitializer(function() { + this.initialized = true; + }); + } + + class C { + @addStaticInit + static foo() {} + } + + C.initialized; + """); + + Assert.True(result.AsBoolean()); + } +} diff --git a/Jint/Native/Function/ClassDefinition.cs b/Jint/Native/Function/ClassDefinition.cs index d171f9d56b..145054aacf 100644 --- a/Jint/Native/Function/ClassDefinition.cs +++ b/Jint/Native/Function/ClassDefinition.cs @@ -2,6 +2,7 @@ using Jint.Runtime; using Jint.Runtime.Descriptors; using Jint.Runtime.Environments; +using Jint.Runtime.Interop; using Jint.Runtime.Interpreter; using Jint.Runtime.Interpreter.Expressions; using Environment = Jint.Runtime.Environments.Environment; @@ -36,14 +37,18 @@ static MethodDefinition CreateConstructorMethodDefinition(Parser parser, string _emptyConstructor = CreateConstructorMethodDefinition(parser, "class temp { constructor() {} }"); } + private readonly NodeList _classDecorators; + public ClassDefinition( string? className, Expression? superClass, - ClassBody body) + ClassBody body, + in NodeList classDecorators = default) { _className = className; _superClass = superClass; _body = body; + _classDecorators = classDecorators; } /// @@ -65,6 +70,44 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri var outerPrivateEnvironment = engine.ExecutionContext.PrivateEnvironment; var classPrivateEnvironment = JintEnvironment.NewPrivateEnvironment(engine, outerPrivateEnvironment); + // Step: Evaluate all decorator expressions in the current environment (before entering class scope) + // Decorator expressions are evaluated left-to-right, top-to-bottom in source order. + var hasDecorators = HasDecorators(); + JsValue[]? classDecoratorValues = null; + List? elementDecorators = null; + + if (hasDecorators) + { + // Evaluate class-level decorators + classDecoratorValues = EvaluateDecorators(context, _classDecorators); + + // Evaluate element-level decorators + ref readonly var bodyElements = ref _body.Body; + elementDecorators = new List(bodyElements.Count); + for (var i = 0; i < bodyElements.Count; i++) + { + var elem = (IClassElement) bodyElements[i]; + JsValue[] decs; + if (elem is MethodDefinition md && md.Decorators.Count > 0) + { + decs = EvaluateDecorators(context, md.Decorators); + } + else if (elem is PropertyDefinition pd && pd.Decorators.Count > 0) + { + decs = EvaluateDecorators(context, pd.Decorators); + } + else if (elem is AccessorProperty ap2 && ap2.Decorators.Count > 0) + { + decs = EvaluateDecorators(context, ap2.Decorators); + } + else + { + decs = System.Array.Empty(); + } + elementDecorators.Add(new ElementDecoratorPair(elem, decs)); + } + } + ObjectInstance? protoParent = null; ObjectInstance? constructorParent = null; if (_superClass is null) @@ -155,16 +198,120 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri var instanceFields = new List(); var staticElements = new List(); + // Collect extra initializers from decorators + List? instanceExtraInitializers = hasDecorators ? new List() : null; + List? staticExtraInitializers = hasDecorators ? new List() : null; + + var elementIndex = 0; foreach (IClassElement e in elements) { if (e is MethodDefinition { Kind: PropertyKind.Constructor }) { + elementIndex++; continue; } var isStatic = e.Static; - var target = !isStatic ? proto : F; + var currentDecorators = elementDecorators is not null && elementIndex < elementDecorators.Count + ? elementDecorators[elementIndex].Decorators + : []; + + // Handle auto-accessors specially since they produce both a field definition and optionally a private element + if (e is AccessorProperty ap) + { + var result = AutoAccessorDefinitionEvaluation(engine, target, ap, isStatic); + + // Check for generator suspension after evaluating computed property key + if (engine.ExecutionContext.Suspended) + { + return JsValue.Undefined; + } + + // Apply accessor decorators if present + if (currentDecorators.Length > 0) + { + var propName = ap.GetKey(engine); + var isPrivateAccessor = ap.Key is PrivateIdentifier; + var extraInits = isStatic ? staticExtraInitializers! : instanceExtraInitializers!; + + JsValue currentGetter; + JsValue currentSetter; + + if (result.PrivateElement is not null) + { + currentGetter = result.PrivateElement.Get ?? JsValue.Undefined; + currentSetter = result.PrivateElement.Set ?? JsValue.Undefined; + } + else + { + // For public auto-accessors, retrieve getter/setter from the property we just defined + var desc = target.GetOwnProperty(propName); + currentGetter = desc?.Get ?? JsValue.Undefined; + currentSetter = desc?.Set ?? JsValue.Undefined; + } + + var accessorResult = ApplyAccessorDecorators(engine, currentDecorators, currentGetter, currentSetter, propName, isStatic, isPrivateAccessor, extraInits); + + // Update getter/setter if decorators returned replacements + if (result.PrivateElement is not null) + { + result.PrivateElement.Get = accessorResult.Getter; + result.PrivateElement.Set = accessorResult.Setter; + } + else + { + target.DefinePropertyOrThrow(propName, new GetSetPropertyDescriptor( + accessorResult.Getter is ICallable ? accessorResult.Getter : null, + accessorResult.Setter is ICallable ? accessorResult.Setter : null, + PropertyFlag.Configurable)); + } + + // If decorators returned init functions, wrap the field initializer + if (accessorResult.InitFunctions is { Count: > 0 }) + { + var fieldDef = result.FieldDefinition; + var originalInit = fieldDef.Initializer; + var capturedInits = accessorResult.InitFunctions.ToArray(); + + var wrappedInit = new ClrFunction(engine, "", (thisObj, _) => + { + var initValue = originalInit is not null + ? engine.Call(originalInit, thisObj, Arguments.Empty) + : JsValue.Undefined; + + for (var j = 0; j < capturedInits.Length; j++) + { + initValue = engine.Call((JsValue) capturedInits[j], thisObj, new JsValue[] { initValue }); + } + return initValue; + }, 0, PropertyFlag.Configurable); + + fieldDef.Initializer = wrappedInit; + } + } + + // Add the backing field initializer + if (!isStatic) + { + instanceFields.Add(result.FieldDefinition); + } + else + { + staticElements.Add(result.FieldDefinition); + } + + // Add private element if present (for private auto-accessors) + if (result.PrivateElement is not null) + { + var container = !isStatic ? instancePrivateMethods : staticPrivateMethods; + container.Add(result.PrivateElement); + } + + elementIndex++; + continue; + } + var element = ClassElementEvaluation(engine, target, e); // Check for generator suspension after evaluating class element @@ -173,6 +320,77 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri return JsValue.Undefined; } + // Apply decorators to methods, getters, setters + if (currentDecorators.Length > 0 && e is MethodDefinition md) + { + var propName = md.GetKey(engine); + var isPrivateMethod = md.Key is PrivateIdentifier; + var extraInits = isStatic ? staticExtraInitializers! : instanceExtraInitializers!; + + if (md.Kind == PropertyKind.Get || md.Kind == PropertyKind.Set) + { + var kind = md.Kind == PropertyKind.Get ? "getter" : "setter"; + if (element is PrivateElement pe && pe.Kind == PrivateElementKind.Accessor) + { + var decoratedValue = md.Kind == PropertyKind.Get ? pe.Get! : pe.Set!; + var replacement = ApplyMethodDecorators(engine, currentDecorators, decoratedValue, kind, propName, isStatic, isPrivateMethod, extraInits); + if (md.Kind == PropertyKind.Get) + { + pe.Get = replacement; + } + else + { + pe.Set = replacement; + } + } + else + { + // Public getter/setter: get from the property descriptor + var desc = target.GetOwnProperty(propName); + if (desc is not null) + { + var decoratedValue = md.Kind == PropertyKind.Get ? desc.Get ?? JsValue.Undefined : desc.Set ?? JsValue.Undefined; + var replacement = ApplyMethodDecorators(engine, currentDecorators, decoratedValue, kind, propName, isStatic, isPrivateMethod, extraInits); + target.DefinePropertyOrThrow(propName, new GetSetPropertyDescriptor( + md.Kind == PropertyKind.Get ? replacement : desc.Get, + md.Kind == PropertyKind.Set ? replacement : desc.Set, + PropertyFlag.Configurable)); + } + } + } + else + { + // Regular method + if (element is PrivateElement pe) + { + var replacement = ApplyMethodDecorators(engine, currentDecorators, pe.Value!, "method", propName, isStatic, isPrivateMethod, extraInits); + pe.Value = replacement; + } + else + { + // Public method: get from the property descriptor + var desc = target.GetOwnProperty(propName); + if (desc?.Value is not null) + { + var replacement = ApplyMethodDecorators(engine, currentDecorators, desc.Value, "method", propName, isStatic, isPrivateMethod, extraInits); + target.DefinePropertyOrThrow(propName, new PropertyDescriptor(replacement, PropertyFlag.NonEnumerable)); + } + } + } + } + + // Apply decorators to fields + if (currentDecorators.Length > 0 && e is PropertyDefinition pd) + { + if (element is ClassFieldDefinition fieldDef) + { + var propName = pd.GetKey(engine); + var isPrivateField = pd.Key is PrivateIdentifier; + var extraInits = isStatic ? staticExtraInitializers! : instanceExtraInitializers!; + ApplyFieldDecorators(engine, currentDecorators, fieldDef, propName, isStatic, isPrivateField, extraInits); + } + } + if (element is PrivateElement privateElement) { var container = !isStatic ? instancePrivateMethods : staticPrivateMethods; @@ -206,6 +424,8 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri { staticElements.Add(element); } + + elementIndex++; } if (_className is not null) @@ -213,6 +433,26 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri classEnv.InitializeBinding(_className, F, DisposeHint.Normal); } + // Store extra initializers so they run during instance/static initialization + if (instanceExtraInitializers is { Count: > 0 }) + { + // Add a field definition that runs the extra initializers + var capturedInits = instanceExtraInitializers.ToArray(); + var initRunner = new ClrFunction(engine, "", (thisObj, _) => + { + for (var j = 0; j < capturedInits.Length; j++) + { + engine.Call((JsValue) capturedInits[j], thisObj, Arguments.Empty); + } + return JsValue.Undefined; + }, 0, PropertyFlag.Configurable); + instanceFields.Add(new ClassFieldDefinition + { + Name = JsValue.Undefined, + Initializer = initRunner + }); + } + F._privateMethods = instancePrivateMethods; F._fields = instanceFields; @@ -233,6 +473,9 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri engine.Call(((ClassStaticBlockDefinition) elementRecord).BodyFunction, F); } } + + // Run static extra initializers + RunExtraInitializers(engine, F, staticExtraInitializers); } finally { @@ -240,6 +483,41 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri engine.UpdatePrivateEnvironment(outerPrivateEnvironment); } + // Apply class decorators (after class is fully constructed) + if (classDecoratorValues is { Length: > 0 }) + { + var classExtraInitializers = new List(); + // Apply class decorators in reverse order + JsValue classValue = F; + for (var i = classDecoratorValues.Length - 1; i >= 0; i--) + { + var decorator = classDecoratorValues[i]; + if (decorator is not ICallable callable) + { + Throw.TypeError(engine.Realm, "A decorator must be a function"); + return F; + } + + var decoratorContext = CreateDecoratorContext(engine, "class", new JsString(_className ?? classBinding ?? ""), isStatic: false, isPrivate: false, classExtraInitializers); + var result = engine.Call((JsValue) callable, JsValue.Undefined, new JsValue[] { classValue, decoratorContext }); + + if (!result.IsUndefined()) + { + if (result is not ICallable) + { + Throw.TypeError(engine.Realm, "A class decorator must return either undefined or a function"); + return F; + } + classValue = result; + } + } + + // Run class extra initializers on the (possibly replaced) class + RunExtraInitializers(engine, classValue, classExtraInitializers); + + return classValue; + } + return F; } @@ -253,11 +531,126 @@ public JsValue BuildConstructor(EvaluationContext context, Environment env, stri PropertyDefinition p => ClassFieldDefinitionEvaluation(engine, target, p), MethodDefinition m => MethodDefinitionEvaluation(engine, target, m, enumerable: false), StaticBlock s => ClassStaticBlockDefinitionEvaluation(engine, target, s), - // AccessorProperty ap => throw new NotImplementedException(), // not implemented yet + AccessorProperty ap => null, // handled directly in BuildConstructor _ => null }; } + /// + /// Result of auto-accessor definition evaluation. + /// Contains the field initializer and an optional private element (for private auto-accessors). + /// + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] + private readonly record struct AutoAccessorResult( + ClassFieldDefinition FieldDefinition, + PrivateElement? PrivateElement); + + /// + /// https://tc39.es/proposal-decorators/#sec-autoaccessordefinitionevaluation + /// Evaluates an auto-accessor property and returns a field initializer plus optional private element. + /// + private static AutoAccessorResult AutoAccessorDefinitionEvaluation( + Engine engine, + ObjectInstance target, + AccessorProperty accessorProperty, + bool isStatic) + { + var name = accessorProperty.GetKey(engine); + + // Check for generator suspension after evaluating computed property key + if (engine.ExecutionContext.Suspended) + { + return new AutoAccessorResult( + new ClassFieldDefinition { Name = JsValue.Undefined, Initializer = null }, + null); + } + + var isPrivate = accessorProperty.Key is PrivateIdentifier; + + // Create a unique backing storage private name + var backingName = new PrivateName(isPrivate ? $"{name}_backing" : $"accessor_backing"); + + if (isPrivate) + { + // For private auto-accessors, use PrivateGet/PrivateSet for the backing field + var privateEnv = engine.ExecutionContext.PrivateEnvironment; + var privateName = privateEnv!.Names[(PrivateIdentifier) accessorProperty.Key]; + + var getter = new ClrFunction(engine, "get", (thisObj, _) => + { + return thisObj.AsObject().PrivateGet(backingName); + }, 0, PropertyFlag.Configurable); + + var setter = new ClrFunction(engine, "set", (thisObj, args) => + { + thisObj.AsObject().PrivateSet(backingName, args.At(0)); + return JsValue.Undefined; + }, 1, PropertyFlag.Configurable); + + // Create field initializer that sets the backing private field + ScriptFunction? initializer = null; + if (accessorProperty.Value is not null) + { + var intrinsics = engine.Realm.Intrinsics; + var env = engine.ExecutionContext.LexicalEnvironment; + var privEnv = engine.ExecutionContext.PrivateEnvironment; + + var definition = new JintFunctionDefinition(new ClassFieldFunction(accessorProperty.Value)); + initializer = intrinsics.Function.OrdinaryFunctionCreate(intrinsics.Function.PrototypeObject, definition, FunctionThisMode.Global, env, privEnv); + initializer.MakeMethod(target); + initializer._classFieldInitializerName = name; + } + + var fieldDef = new ClassFieldDefinition { Name = backingName, Initializer = initializer }; + var pe = new PrivateElement + { + Key = privateName, + Kind = PrivateElementKind.Accessor, + Get = getter, + Set = setter + }; + + return new AutoAccessorResult(fieldDef, pe); + } + else + { + // For public auto-accessors, use a private backing name for the storage + // This ensures that static auto-accessors are per-class (derived classes + // calling inherited getter/setter will throw TypeError because they don't + // have the backing private element). + var getter = new ClrFunction(engine, "get", (thisObj, _) => + { + return thisObj.AsObject().PrivateGet(backingName); + }, 0, PropertyFlag.Configurable); + + var setter = new ClrFunction(engine, "set", (thisObj, args) => + { + thisObj.AsObject().PrivateSet(backingName, args.At(0)); + return JsValue.Undefined; + }, 1, PropertyFlag.Configurable); + + // Define the accessor property (getter/setter) on the target + target.DefinePropertyOrThrow(name, new GetSetPropertyDescriptor(getter, setter, PropertyFlag.Configurable)); + + // Create field initializer for the backing storage + ScriptFunction? initializer = null; + if (accessorProperty.Value is not null) + { + var intrinsics = engine.Realm.Intrinsics; + var env = engine.ExecutionContext.LexicalEnvironment; + var privEnv = engine.ExecutionContext.PrivateEnvironment; + + var definition = new JintFunctionDefinition(new ClassFieldFunction(accessorProperty.Value)); + initializer = intrinsics.Function.OrdinaryFunctionCreate(intrinsics.Function.PrototypeObject, definition, FunctionThisMode.Global, env, privEnv); + initializer.MakeMethod(target); + initializer._classFieldInitializerName = name; + } + + var fieldDef = new ClassFieldDefinition { Name = backingName, Initializer = initializer }; + return new AutoAccessorResult(fieldDef, null); + } + } + /// /// /https://tc39.es/ecma262/#sec-runtime-semantics-classfielddefinitionevaluation /// @@ -450,4 +843,277 @@ public ClassStaticBlockFunction(StaticBlock staticBlock) : base(NodeType.StaticB homeObject.DefinePropertyOrThrow(key, desc); return null; } + + /// + /// Pairs a class element with its evaluated decorator values. + /// + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] + private readonly record struct ElementDecoratorPair(IClassElement Element, JsValue[] Decorators); + + /// + /// Evaluates all decorator expressions in source order (left-to-right). + /// Returns array of evaluated decorator functions. + /// + private static JsValue[] EvaluateDecorators(EvaluationContext context, in NodeList decorators) + { + if (decorators.Count == 0) + { + return []; + } + + var result = new JsValue[decorators.Count]; + for (var i = 0; i < decorators.Count; i++) + { + result[i] = JintExpression.Build(decorators[i].Expression).GetValue(context); + } + return result; + } + + /// + /// Creates the decorator context object passed as the second argument to a decorator. + /// https://tc39.es/proposal-decorators/#sec-createdecoratorcontextobject + /// + private static JsObject CreateDecoratorContext( + Engine engine, + string kind, + JsValue name, + bool isStatic, + bool isPrivate, + List extraInitializers) + { + var context = new JsObject(engine); + context.FastSetDataProperty("kind", new JsString(kind)); + context.FastSetDataProperty("name", isPrivate && name is PrivateName pn ? (JsValue) new JsString(pn.Description) : name); + context.FastSetDataProperty("static", isStatic ? JsBoolean.True : JsBoolean.False); + context.FastSetDataProperty("private", isPrivate ? JsBoolean.True : JsBoolean.False); + + var addInitializer = new ClrFunction(engine, "addInitializer", (_, args) => + { + var init = args.At(0); + if (init is not ICallable initCallable) + { + Throw.TypeError(engine.Realm, "An initializer must be a function"); + return JsValue.Undefined; + } + extraInitializers.Add(initCallable); + return JsValue.Undefined; + }, 1, PropertyFlag.Configurable); + + context.FastSetDataProperty("addInitializer", addInitializer); + + return context; + } + + /// + /// Applies decorators to a method, getter, or setter. + /// Returns the potentially replaced function value. + /// + private static JsValue ApplyMethodDecorators( + Engine engine, + JsValue[] decorators, + JsValue value, + string kind, + JsValue name, + bool isStatic, + bool isPrivate, + List extraInitializers) + { + // Apply decorators in reverse order (last decorator first) + for (var i = decorators.Length - 1; i >= 0; i--) + { + var decorator = decorators[i]; + if (decorator is not ICallable callable) + { + Throw.TypeError(engine.Realm, "A decorator must be a function"); + return value; + } + + var context = CreateDecoratorContext(engine, kind, name, isStatic, isPrivate, extraInitializers); + var result = engine.Call((JsValue) callable, JsValue.Undefined, new JsValue[] { value, context }); + + if (!result.IsUndefined()) + { + if (result is not ICallable) + { + Throw.TypeError(engine.Realm, "A decorator must return either undefined or a function"); + return value; + } + value = result; + } + } + + return value; + } + + /// + /// Applies decorators to a field definition. + /// Returns the potentially wrapped initializer. + /// + private static void ApplyFieldDecorators( + Engine engine, + JsValue[] decorators, + ClassFieldDefinition fieldDefinition, + JsValue name, + bool isStatic, + bool isPrivate, + List extraInitializers) + { + var initFunctions = new List(); + + // Apply decorators in reverse order + for (var i = decorators.Length - 1; i >= 0; i--) + { + var decorator = decorators[i]; + if (decorator is not ICallable callable) + { + Throw.TypeError(engine.Realm, "A decorator must be a function"); + return; + } + + var context = CreateDecoratorContext(engine, "field", name, isStatic, isPrivate, extraInitializers); + var result = engine.Call((JsValue) callable, JsValue.Undefined, new JsValue[] { JsValue.Undefined, context }); + + if (!result.IsUndefined()) + { + if (result is not ICallable initCallable) + { + Throw.TypeError(engine.Realm, "A decorator must return either undefined or a function"); + return; + } + initFunctions.Add(initCallable); + } + } + + if (initFunctions.Count > 0) + { + var originalInitializer = fieldDefinition.Initializer; + var capturedInits = initFunctions.ToArray(); + + // Wrap the initializer to chain through decorator init functions + // Init functions are applied in the order they were collected + // (which is reverse decorator order = inner decorator first) + var wrappedInitializer = new ClrFunction(engine, "", (thisObj, _) => + { + var initValue = originalInitializer is not null + ? engine.Call(originalInitializer, thisObj, Arguments.Empty) + : JsValue.Undefined; + + for (var j = 0; j < capturedInits.Length; j++) + { + initValue = engine.Call((JsValue) capturedInits[j], thisObj, new JsValue[] { initValue }); + } + return initValue; + }, 0, PropertyFlag.Configurable); + + fieldDefinition.Initializer = wrappedInitializer; + } + } + + /// + /// Applies decorators to an auto-accessor. + /// Returns potentially replaced getter/setter and optional init function. + /// + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] + private readonly record struct AccessorDecoratorResult( + JsValue Getter, + JsValue Setter, + List? InitFunctions); + + private static AccessorDecoratorResult ApplyAccessorDecorators( + Engine engine, + JsValue[] decorators, + JsValue getter, + JsValue setter, + JsValue name, + bool isStatic, + bool isPrivate, + List extraInitializers) + { + var currentGetter = getter; + var currentSetter = setter; + List? initFunctions = null; + + // Apply decorators in reverse order + for (var i = decorators.Length - 1; i >= 0; i--) + { + var decorator = decorators[i]; + if (decorator is not ICallable callable) + { + Throw.TypeError(engine.Realm, "A decorator must be a function"); + return new AccessorDecoratorResult(currentGetter, currentSetter, initFunctions); + } + + var context = CreateDecoratorContext(engine, "accessor", name, isStatic, isPrivate, extraInitializers); + var valueObj = ObjectInstance.OrdinaryObjectCreate(engine, engine.Realm.Intrinsics.Object.PrototypeObject); + valueObj.FastSetDataProperty("get", currentGetter); + valueObj.FastSetDataProperty("set", currentSetter); + + var result = engine.Call((JsValue) callable, JsValue.Undefined, new JsValue[] { valueObj, context }); + + if (!result.IsUndefined()) + { + if (result is not ObjectInstance resultObj) + { + Throw.TypeError(engine.Realm, "A decorator must return either undefined or an object"); + return new AccessorDecoratorResult(currentGetter, currentSetter, initFunctions); + } + + var newGet = resultObj.Get("get"); + if (!newGet.IsUndefined()) + { + currentGetter = newGet; + } + + var newSet = resultObj.Get("set"); + if (!newSet.IsUndefined()) + { + currentSetter = newSet; + } + + var initValue = resultObj.Get("init"); + if (!initValue.IsUndefined()) + { + if (initValue is not ICallable initCallable) + { + Throw.TypeError(engine.Realm, "An accessor decorator's init must be a function"); + return new AccessorDecoratorResult(currentGetter, currentSetter, initFunctions); + } + initFunctions ??= []; + initFunctions.Add(initCallable); + } + } + } + + return new AccessorDecoratorResult(currentGetter, currentSetter, initFunctions); + } + + /// + /// Checks if any class element or the class itself has decorators. + /// + private bool HasDecorators() + { + if (_classDecorators.Count > 0) return true; + + ref readonly var elements = ref _body.Body; + for (var i = 0; i < elements.Count; i++) + { + var element = elements[i]; + if (element is MethodDefinition md && md.Decorators.Count > 0) return true; + if (element is PropertyDefinition pd && pd.Decorators.Count > 0) return true; + if (element is AccessorProperty ap && ap.Decorators.Count > 0) return true; + } + return false; + } + + /// + /// Runs extra initializers registered via addInitializer. + /// + private static void RunExtraInitializers(Engine engine, JsValue target, List? initializers) + { + if (initializers is null) return; + for (var i = 0; i < initializers.Count; i++) + { + engine.Call((JsValue) initializers[i], target, Arguments.Empty); + } + } } diff --git a/Jint/Native/Object/ObjectInstance.Private.cs b/Jint/Native/Object/ObjectInstance.Private.cs index 734e097ac9..c90db83189 100644 --- a/Jint/Native/Object/ObjectInstance.Private.cs +++ b/Jint/Native/Object/ObjectInstance.Private.cs @@ -148,7 +148,7 @@ internal void PrivateSet(PrivateName property, JsValue value) internal sealed class ClassFieldDefinition { public required JsValue Name { get; set; } - public ScriptFunction? Initializer { get; set; } + public Function.Function? Initializer { get; set; } } internal sealed class ClassStaticBlockDefinition diff --git a/Jint/Native/PrivateName.cs b/Jint/Native/PrivateName.cs index 55f4cba75a..95fdf3a0b1 100644 --- a/Jint/Native/PrivateName.cs +++ b/Jint/Native/PrivateName.cs @@ -8,7 +8,7 @@ namespace Jint.Native; /// internal sealed class PrivateName : JsValue, IEquatable { - private readonly PrivateIdentifier _identifier; + private readonly PrivateIdentifier? _identifier; public PrivateName(PrivateIdentifier identifier) : base(InternalTypes.PrivateName) { @@ -16,9 +16,18 @@ public PrivateName(PrivateIdentifier identifier) : base(InternalTypes.PrivateNam Description = identifier.Name; } + /// + /// Creates a synthetic PrivateName not tied to a source-level PrivateIdentifier. + /// Used for auto-accessor backing storage. + /// + internal PrivateName(string description) : base(InternalTypes.PrivateName) + { + Description = description; + } + public string Description { get; } - public override string ToString() => _identifier.Name; + public override string ToString() => Description; public override object ToObject() => throw new NotImplementedException(); @@ -49,7 +58,7 @@ public bool Equals(PrivateName? other) public override int GetHashCode() { - return StringComparer.Ordinal.GetHashCode(_identifier.Name); + return StringComparer.Ordinal.GetHashCode(Description); } } diff --git a/Jint/Runtime/Interpreter/Expressions/JintClassExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintClassExpression.cs index 7b27082597..72d163e5c6 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintClassExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintClassExpression.cs @@ -9,7 +9,7 @@ internal sealed class JintClassExpression : JintExpression public JintClassExpression(ClassExpression expression) : base(expression) { - _classDefinition = new ClassDefinition(expression.Id?.Name, expression.SuperClass, expression.Body); + _classDefinition = new ClassDefinition(expression.Id?.Name, expression.SuperClass, expression.Body, expression.Decorators); } protected override object EvaluateInternal(EvaluationContext context) diff --git a/Jint/Runtime/Interpreter/Statements/JintClassDeclarationStatement.cs b/Jint/Runtime/Interpreter/Statements/JintClassDeclarationStatement.cs index 0ea02bbd46..9452703e01 100644 --- a/Jint/Runtime/Interpreter/Statements/JintClassDeclarationStatement.cs +++ b/Jint/Runtime/Interpreter/Statements/JintClassDeclarationStatement.cs @@ -9,7 +9,7 @@ internal sealed class JintClassDeclarationStatement : JintStatement