Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,29 @@ Main entry point API is `Stunt.Of<T>`, which creates an object that implements `
```csharp
ICalculator calculator = Stunt.Of<ICalculator>();

// Extension methods to easily add behaviors without having
// to cast to IStunt
calculator.AddBehavior((invocation, next) => ...);
Console.WriteLine(calculator.Add(2, 5));
```

Behaviors are configured through `IStunt`, which every stunt implements. `Stunt.For<T>`
creates the stunt and returns a `StuntReference<T>` that exposes both the behaviors and
the stunt itself (via `ToObject()`, or an implicit conversion for non-interface types):

```csharp
var stunt = Stunt.For<ICalculator>();
ICalculator calculator = stunt.ToObject();

stunt.AddBehavior((invocation, next) => ...);

Console.WriteLine(calculator.Add(2, 5));
```

`Stunt.Get(calculator)` returns the same `StuntReference<T>` for an already created stunt,
so behaviors can be added after the fact.

There are overloads for implementing additional types, as well as passing constructor
arguments if the base type `T` (which must be the first in the list, like in regular
C# type declarations) is a class that provides a constructor with matching parameters:
`Stunt.Of<T, T1...Tn>(arg1, ... argn)`
`Stunt.Of<T, T1...Tn>(arg1, ... argn)` and `Stunt.For<T, T1...Tn>(arg1, ... argn)`

For anonymous behaviors, the delegate/lambda based overloads are typically sufficient.
For more advanced or reusable behaviors, you can implement `IStuntBehavior` instead.
7 changes: 4 additions & 3 deletions src/Acceptance/Dynamic.Basic/Test.vb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ Namespace Sample

<Fact>
Public Sub CanConfigureDefaultValues()
Dim calculator = Stunt.[Of](Of ICalculator, IDisposable)()
Dim reference = Stunt.[For](Of ICalculator, IDisposable)()
Dim calculator As ICalculator = reference.ToObject()

Assert.IsNotType(Of CompiledStuntFactory)(StuntFactory.[Default])

Dim recorder = New RecordingBehavior()
calculator.AddBehavior(recorder)
calculator.AddBehavior(New DefaultValueBehavior())
reference.AddBehavior(recorder)
reference.AddBehavior(New DefaultValueBehavior())

Assert.IsAssignableFrom(Of IDisposable)(calculator)

Expand Down
7 changes: 4 additions & 3 deletions src/Acceptance/Dynamic/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ public class Tests
[Fact]
public void CanConfigureDefaultValues()
{
var calculator = Stunt.Of<ICalculator, IDisposable>();
var stunt = Stunt.For<ICalculator, IDisposable>();
ICalculator calculator = stunt.ToObject();

Assert.IsNotType<CompiledStuntFactory>(StuntFactory.Default);

var recorder = new RecordingBehavior();
calculator.AddBehavior(recorder);
calculator.AddBehavior(new DefaultValueBehavior());
stunt.AddBehavior(recorder);
stunt.AddBehavior(new DefaultValueBehavior());

Assert.IsAssignableFrom<IDisposable>(calculator);

Expand Down
7 changes: 4 additions & 3 deletions src/Acceptance/Static/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ public class Tests
[Fact]
public void CanConfigureDefaultValues()
{
var calculator = Stunt.Of<ICalculator, IDisposable>();
var stunt = Stunt.For<ICalculator, IDisposable>();
ICalculator calculator = stunt.ToObject();

var recorder = new RecordingBehavior();

calculator.AddBehavior(recorder);
calculator.AddBehavior(new DefaultValueBehavior());
stunt.AddBehavior(recorder);
stunt.AddBehavior(new DefaultValueBehavior());

Assert.IsAssignableFrom<IDisposable>(calculator);

Expand Down
2 changes: 1 addition & 1 deletion src/Stunts.CompiledProxy/Stunts.CompiledProxy.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
Expand Down
1 change: 0 additions & 1 deletion src/Stunts.CompiledProxy/Stunts.CompiledProxy.targets
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
<ItemGroup>
<PackageReference Include="NuGetizer" Version="1.5.1" PrivateAssets="all" />
<PackageReference Include="PolySharp" Version="1.16.0" PrivateAssets="all" />
<PackageReference Include="ThisAssembly" Version="2.1.6" PrivateAssets="all" />

<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="$(RoslynVersion)$(RoslynLabel)" Pack="false" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(RoslynVersion)$(RoslynLabel)" Pack="false" />
Expand Down
6 changes: 4 additions & 2 deletions src/Stunts.Package/Stunts.Package.msbuildproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ and runs everywhere, even where run-time code generation (Reflection.Emit) is fo
limitted, like physical iOS devices and game consoles, through compile-time code generation.
The proxy behavior is configured in code using what we call a behavior pipeline.

IMyService service = Stunt.Of&lt;IMyService&gt;();
service.AddBehavior(...);
ICalculator calculator = Stunt
.For&lt;ICalculator&gt;()
.AddBehavior((invocation, next) => ..., appliesTo: invocation => ...)
.ToObject();
</Description>
<PackageTags>dotnet proxy runtime dynamic</PackageTags>
</PropertyGroup>
Expand Down
28 changes: 14 additions & 14 deletions src/Stunts.UnitTests/Castle/DelegateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ public void Run()
public void BehaviorHandlesACallWithNoTarget()
{
var seen = false;
var action = Stunt.Of<Action>().AddBehavior((invocation, next) =>
Action action = Stunt.For<Action>().AddBehavior((invocation, next) =>
{
seen = invocation.MethodBase.Name == "Invoke";
return invocation.CreateReturn();
});
}).ToObject();

action();

Expand All @@ -42,11 +42,11 @@ public void BehaviorHandlesACallWithNoTarget()
public void NoTargetLeavesTheTailEmpty()
{
bool? hasImplementation = null;
var action = Stunt.Of<Action>().AddBehavior((invocation, next) =>
Action action = Stunt.For<Action>().AddBehavior((invocation, next) =>
{
hasImplementation = invocation.HasImplementation;
return next(invocation, next);
});
}).ToObject();

Assert.Throws<NotImplementedException>(() => action());
Assert.False(hasImplementation);
Expand All @@ -56,7 +56,7 @@ public void ImplementationIsTheTail()
{
var called = false;
Action implementation = () => called = true;
var action = Stunt.Of<Action>(implementation);
Action action = Stunt.Of<Action>(implementation);

action();

Expand All @@ -68,13 +68,13 @@ public void BehaviorWrapsTheImplementation()
{
var order = new List<string>();
Action implementation = () => order.Add("impl");
var action = Stunt.Of<Action>(implementation).AddBehavior((invocation, next) =>
Action action = Stunt.For<Action>(implementation).AddBehavior((invocation, next) =>
{
order.Add("before");
var result = next(invocation, next);
order.Add("after");
return result;
});
}).ToObject();

action();

Expand All @@ -85,11 +85,11 @@ public void BehaviorCanReplaceAnArgument()
{
var seen = 0;
Action<int> implementation = value => seen = value;
var action = Stunt.Of<Action<int>>(implementation).AddBehavior((invocation, next) =>
Action<int> action = Stunt.For<Action<int>>(implementation).AddBehavior((invocation, next) =>
{
invocation.Arguments.SetValue("obj", 41);
return next(invocation, next);
});
}).ToObject();

action(1);

Expand All @@ -104,11 +104,11 @@ public void RefAndReturnAreWrittenBack()
value = result;
return result;
};
var action = Stunt.Of<RefImpl>(implementation).AddBehavior((invocation, next) =>
RefImpl action = Stunt.For<RefImpl>(implementation).AddBehavior((invocation, next) =>
{
invocation.Arguments.SetValue("value", 4);
return next(invocation, next);
});
}).ToObject();

var value = 1;
var returned = action(ref value);
Expand All @@ -130,7 +130,7 @@ public void ParameterNamedMethodForwards()
{
var seen = 0;
Named implementation = method => seen = method;
var action = Stunt.Of<Named>(implementation);
Named action = Stunt.Of<Named>(implementation);

action(7);

Expand All @@ -141,7 +141,7 @@ public void ParametersThatHideFieldsForward()
{
var seen = "";
HidesFields target = (left, right) => seen = left + ":" + right;
var action = Stunt.Of<HidesFields>(target);
HidesFields action = Stunt.Of<HidesFields>(target);

action(2, 9);

Expand All @@ -160,7 +160,7 @@ public void Run()
{
var called = false;
Hidden implementation = () => called = true;
var action = Stunt.Of<Hidden>(implementation);
Hidden action = Stunt.Of<Hidden>(implementation);

action();

Expand Down
26 changes: 14 additions & 12 deletions src/Stunts.UnitTests/Castle/LanguageFeaturesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,49 +18,51 @@ public void Run()

public void CovariantReturnUsesTheDerivedSignature()
{
var stunt = Stunt.Of<StringReturn>();
var stunt = Stunt.For<StringReturn>();
StringReturn value = stunt.ToObject();

Assert.Equal("derived", stunt.Value());
Assert.Equal("derived", value.Value());

stunt.AddBehavior((invocation, next) => invocation.CreateValueReturn("proxy"));

Assert.Equal("proxy", stunt.Value());
Assert.Equal("proxy", value.Value());
}

public void PositionalRecordConstructorAndVirtualMethod()
{
var stunt = Stunt.Of<Person>("Ada");
var stunt = Stunt.For<Person>("Ada");
Person person = stunt.ToObject();

Assert.Equal("Ada", stunt.Name);
Assert.Equal("Hello Ada", stunt.Greet());
Assert.Equal("Ada", person.Name);
Assert.Equal("Hello Ada", person.Greet());

stunt.AddBehavior((invocation, next) =>
invocation.MethodBase.Name == nameof(Person.Greet)
? invocation.CreateValueReturn("hi")
: next(invocation, next));

Assert.Equal("hi", stunt.Greet());
Assert.Equal("Ada", stunt.Name);
Assert.Equal("hi", person.Greet());
Assert.Equal("Ada", person.Name);

var copy = stunt with { Name = "Bea" };
var copy = person with { Name = "Bea" };

Assert.Equal("Bea", copy.Name);
Assert.IsType(stunt.GetType(), copy);
Assert.IsType(person.GetType(), copy);
// The synthesized record copy copies the pipeline field.
Assert.Equal("hi", copy.Greet());
}

public void EmptyRecord()
{
var stunt = Stunt.Of<Empty>();
Empty stunt = Stunt.Of<Empty>();

Assert.IsAssignableFrom<Empty>(stunt);
Assert.IsAssignableFrom<IStunt>(stunt);
}

public void RecordCanTakeAnAdditionalInterface()
{
var stunt = Stunt.Of<Person, IDisposable>("Ada");
Person stunt = Stunt.Of<Person, IDisposable>("Ada");

Assert.IsAssignableFrom<Person>(stunt);
Assert.IsAssignableFrom<IDisposable>(stunt);
Expand Down
19 changes: 12 additions & 7 deletions src/Stunts.UnitTests/Castle/Unsupported.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The tests next to this file are the Castle scenarios the generator can carry. Th
| Covariant returns | `LanguageFeaturesTests` |
| Records (positional, empty, record plus an extra interface; `with` keeps the stunt type) | `LanguageFeaturesTests` |
| `ref readonly`, `Span<T>` and other `ref struct` arguments and returns, pointer parameters | `Scenarios/SignatureRefs` |
| Custom attributes on generated types, members, parameters, and returns; optional parameter defaults (including reflection invocation) | `AttributeTests` |

Behaviors added with `AddBehavior` after `Stunt.Of` do not see virtual calls made from a base constructor. Castle passes interceptors into proxy creation, so they do. `BehaviorPipelineFactory` is the matching extension point, and `ClassProxyTests` uses it.

Expand All @@ -46,24 +47,28 @@ Castle's `IProxyGenerationHook` omits members from the proxy type. Stunts overri

### Metadata Castle copies onto the proxy

The scaffold copies signatures, names, and generic constraints. It does not copy:
The generator copies C#-expressible metadata, including custom attributes and optional
parameter default values (covered by `AttributeTests`). Remaining metadata gaps are:

- custom attributes on methods, properties, parameters, or the type (`[Serializable]`, `[Guid]`, parameter attributes)
- metadata-only custom modifiers (`modopt` / `modreq`) that have no C# spelling. `in`, `ref readonly`, and function-pointer calling conventions are emitted as those keywords, and the compiler writes the matching modifier
- optional-parameter default values (callers compiled against the original type still get the compiler default; reflection on the proxy method does not)
- metadata-only custom modifiers (`modopt` / `modreq`) with no C# spelling
- extra attributes supplied at generation time (`ProxyGenerationOptions.AdditionalAttributes`)

### Serialization

Castle's proxy serialization (BinaryFormatter, `ISerializable`, restoring interceptors, mixins, selector, and generation options, including graphs with delegates back to the proxy) is a generator feature. Compile-time stunts are ordinary classes with none of that infrastructure. XML serialization of a proxy is the same kind of gap: the generated type does not replicate the attributes and constructor shape `XmlSerializer` expects.
Castle-specific proxy serialization (`ISerializable`, restoring interceptors, mixins,
selector, and generation options, including graphs with delegates back to the proxy) is
not implemented by compile-time stunts. Copying `[Serializable]` and other attributes
does not provide that proxy-serialization infrastructure.

### Delegates as additional type arguments

`Stunt.Of<SomeDelegate>()` returns a delegate bound to a generated `Invoke`, and `Stunt.Of<SomeDelegate>(implementation)` forwards `next` to that delegate. A delegate combined with a class or another interface is `ST010`. Castle can also mix a delegate `Invoke` onto some other proxy (`AsDelegate` is the missing cast).

### Same signature on two interfaces
### Cannot distinguish identical interface slots

One public method implements both. Both calls are intercepted, and a behavior cannot tell which interface was used. Castle emits explicit implementations so `IInvocation.Method` differs.
Identical signatures from two interfaces share one generated implementation. Both calls
are intercepted, but a behavior cannot tell which interface slot was called. Castle can
emit explicit implementations so `IInvocation.Method` differs.

### Fixed additional-interface arity

Expand Down
7 changes: 4 additions & 3 deletions src/Stunts.UnitTests/RefReturns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ public class RefReturns
[Fact]
public void CanReturnRef()
{
INumbers stunt = new NumbersStunt();

var stunt = new NumbersStunt();

Ref<int> value = 25;

stunt.AddBehavior((invocation, next) => invocation.CreateValueReturn(value, invocation.Arguments));

INumbers numbers = stunt;

var index = 0;
ref int v = ref stunt.NumberAt(ref index, out var count);
ref int v = ref numbers.NumberAt(ref index, out var count);

v = 42;

Expand Down
1 change: 1 addition & 0 deletions src/Stunts.UnitTests/Sample.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using Sample;
using Stunts.Sample;
using Xunit;

Expand Down
7 changes: 4 additions & 3 deletions src/Stunts.UnitTests/Scenarios/ClassBaseAbstractType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ public class Test : IRunnable
public void Run()
{
var stunt = Stunt.Of<CalculatorBase>();
CalculatorBase calculator = stunt;

Assert.Throws<NotImplementedException>(() => stunt.Mode = CalculatorMode.Scientific);
Assert.Throws<NotImplementedException>(() => stunt.Mode);
Assert.Throws<NotImplementedException>(() => stunt.TurnOn());
Assert.Throws<NotImplementedException>(() => calculator.Mode = CalculatorMode.Scientific);
Assert.Throws<NotImplementedException>(() => calculator.Mode);
Assert.Throws<NotImplementedException>(() => calculator.TurnOn());
}
}
}
Loading
Loading