diff --git a/docs/guide/migration-from-projectables.md b/docs/guide/migration-from-projectables.md
index ea277374..cc6c4959 100644
--- a/docs/guide/migration-from-projectables.md
+++ b/docs/guide/migration-from-projectables.md
@@ -161,10 +161,12 @@ public string FullName
See the [Projectable Properties reference](../reference/projectable-properties) and the [Projection Middleware recipe](../recipes/projection-middleware) for the complete feature.
-**Option B -- `[ExpressiveFor]`** (separate stub, supports cross-type mapping):
+**Option B -- `[ExpressiveFor]`** (separate stub, also supports cross-type mapping):
**Scenario 1: Same-type member with an alternative body**
+Use the co-located form: a property stub on the same class combined with the single-argument attribute. `this` is the receiver naturally -- the migration reads almost identically to `UseMemberBody`.
+
```csharp
// Before (Projectables)
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
@@ -178,8 +180,8 @@ using ExpressiveSharp.Mapping;
public string FullName => $"{FirstName} {LastName}".Trim().ToUpper();
-[ExpressiveFor(typeof(MyEntity), nameof(MyEntity.FullName))]
-static string FullNameExpr(MyEntity e) => e.FirstName + " " + e.LastName;
+[ExpressiveFor(nameof(FullName))]
+private string FullNameExpression => FirstName + " " + LastName;
```
**Scenario 2: External/third-party type methods**
@@ -212,9 +214,9 @@ static OrderDto CreateDto(int id, string name)
| | `UseMemberBody` (Projectables) | `[ExpressiveFor]` (ExpressiveSharp) |
|---|---|---|
-| Scope | Same type only | Any type (including external/third-party) |
+| Scope | Same type only | Same type **or** any accessible type (including external/third-party) |
| Syntax | Property on `[Projectable]` | Separate attribute on a stub method |
-| Target member | Must be in the same class | Any accessible type |
+| Target member | Must be in the same class | Co-located (single-arg form, `this` is receiver) or cross-type (two-arg form) |
| Namespace | `EntityFrameworkCore.Projectables` | `ExpressiveSharp.Mapping` |
| Constructors | Not supported | `[ExpressiveForConstructor]` |
diff --git a/docs/recipes/external-member-mapping.md b/docs/recipes/external-member-mapping.md
index 82d14f20..cac46c4a 100644
--- a/docs/recipes/external-member-mapping.md
+++ b/docs/recipes/external-member-mapping.md
@@ -191,7 +191,6 @@ static class DateTimeMappings
|------|-------------|
| EXP0014 | `[ExpressiveFor]` target type not found |
| EXP0015 | `[ExpressiveFor]` target member not found on the specified type |
-| EXP0016 | `[ExpressiveFor]` stub method must be `static` |
| EXP0017 | Return type of stub does not match target member's return type |
| EXP0019 | Target member already has `[Expressive]` -- use `[Expressive]` directly instead |
| EXP0020 | Duplicate `[ExpressiveFor]` mapping for the same target member |
@@ -199,7 +198,32 @@ static class DateTimeMappings
## Tips
::: tip Match the signature exactly
-For static methods, the stub parameters must match the target method signature. For instance members, add the receiver as the first parameter (e.g., `static string FullName(Person p)`).
+For static methods, the stub parameters must match the target method signature. For instance members you have three options: write a `static` method stub whose first parameter is the receiver (e.g. `static string FullName(Person p)`), write an `instance` method stub on the target type itself where the stub's `this` is the receiver, or write a **property stub** on the target type (parameterless; `this` is the receiver) -- property stubs can only target other properties.
+:::
+
+::: tip Co-locate when possible
+When the target is on the same type as the stub, you can drop `typeof(...)` and use the single-argument form -- it targets a member on the stub's containing type. Combined with an **instance property stub**, this is the ergonomic shape for the case where a property has its own backing storage (a plain settable auto-property used for DTO shape, serialization, or in-memory assignment in tests) but you want queries to compute it from other columns:
+
+```csharp
+public class Person
+{
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+
+ // Regular auto-property — can be assigned directly (for DTOs, tests, deserialization).
+ public string FullName { get; set; } = "";
+
+ // When used in a LINQ expression tree, FullName is rewritten to this body,
+ // so EF Core projects it from the underlying columns instead of trying to
+ // map it to a column of its own. `this` is the receiver automatically.
+ [ExpressiveFor(nameof(FullName))]
+ private string FullNameExpression => FirstName + " " + LastName;
+}
+```
+
+A method stub (`string FullNameExpression() => ...`) works the same way and is appropriate when the target is a method or you need a block body.
+
+If the property has no backing storage and the same body works at both runtime and query time, put `[Expressive]` directly on the property and delete the stub.
:::
::: tip Consider [Expressive] first
@@ -207,7 +231,7 @@ Many `[ExpressiveFor]` use cases exist because of syntax limitations in other li
:::
::: warning Placement
-`[ExpressiveFor]` stubs must be in a `static` class. The class can be in any namespace -- it is discovered at compile time by the source generator.
+`[ExpressiveFor]` stubs can live either in a `static` helper class (with a `static` method stub) or on the target type itself as an instance method or property. Either way, they are discovered at compile time by the source generator.
:::
## See Also
diff --git a/docs/reference/diagnostics.md b/docs/reference/diagnostics.md
index 74b1915e..431b0585 100644
--- a/docs/reference/diagnostics.md
+++ b/docs/reference/diagnostics.md
@@ -6,6 +6,10 @@ The ExpressiveSharp source generator and companion analyzers emit diagnostics du
See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find the error message or behavior you see and get step-by-step resolution.
:::
+::: info Retired diagnostics
+`EXP0016` ("`[ExpressiveFor]` stub must be static") has been retired. Instance stubs on the target type are now permitted; constructor-stub and unrelated-type mismatches surface as `EXP0015` (member not found) instead.
+:::
+
## Overview
| ID | Severity | Title | Code Fix |
@@ -25,7 +29,6 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
| [EXP0013](#exp0013) | Warning | Member could benefit from `[Expressive]` | [Add `[Expressive]`](#exp0013-fix) |
| [EXP0014](#exp0014) | Error | `[ExpressiveFor]` target type not found | -- |
| [EXP0015](#exp0015) | Error | `[ExpressiveFor]` target member not found | -- |
-| [EXP0016](#exp0016) | Error | `[ExpressiveFor]` stub must be static | -- |
| [EXP0017](#exp0017) | Error | `[ExpressiveFor]` return type mismatch | -- |
| [EXP0019](#exp0019) | Error | `[ExpressiveFor]` conflicts with `[Expressive]` | -- |
| [EXP0020](#exp0020) | Error | Duplicate `[ExpressiveFor]` mapping | -- |
@@ -417,32 +420,6 @@ static double Clamp(double value, double min, double max)
---
-### EXP0016 -- Stub must be static {#exp0016}
-
-**Severity:** Error
-**Category:** Design
-
-**Message:**
-```
-[ExpressiveFor] stub method '{0}' must be static
-```
-
-**Cause:** The stub method is not declared `static`.
-
-**Fix:** Add the `static` modifier:
-
-```csharp
-// Error: not static
-[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
-double Clamp(double value, double min, double max) => /* ... */;
-
-// Fixed
-[ExpressiveFor(typeof(Math), nameof(Math.Clamp))]
-static double Clamp(double value, double min, double max) => /* ... */;
-```
-
----
-
### EXP0017 -- Return type mismatch {#exp0017}
**Severity:** Error
diff --git a/docs/reference/expressive-for.md b/docs/reference/expressive-for.md
index 41d4b1a0..2c5fa277 100644
--- a/docs/reference/expressive-for.md
+++ b/docs/reference/expressive-for.md
@@ -10,15 +10,21 @@ using ExpressiveSharp.Mapping;
## How It Works
-You write a static stub method whose body defines the expression-tree replacement. The `[ExpressiveFor]` attribute tells the generator which external member this stub maps to. At runtime, the replacer substitutes calls to the target member with the stub's expression tree -- call sites remain unchanged.
+You write a stub member -- a method **or** a property -- whose body defines the expression-tree replacement. The `[ExpressiveFor]` attribute tells the generator which external member this stub maps to. At runtime, the replacer substitutes calls to the target member with the stub's expression tree -- call sites remain unchanged.
## Mapping Rules
-- The stub method **must be `static`** (EXP0016 if not).
-- For **static methods**, the stub's parameters must match the target method's parameters exactly.
-- For **instance methods**, the first parameter of the stub is the receiver (`this`), followed by the target method's parameters.
-- For **instance properties**, the stub takes a single parameter: the receiver.
-- The return type must match (EXP0017 if not).
+- The stub can be a **method** (receiver supplied as the first parameter for instance targets, or `this` for instance stubs on the target type) **or** a **property** (parameterless; `this` is the receiver for instance stubs).
+- The single-argument form `[ExpressiveFor(nameof(X))]` is shorthand for `[ExpressiveFor(typeof(ContainingType), nameof(X))]` -- use it when the target member is on the same type as the stub.
+- For **static methods** (and static stubs over static members), the stub's parameters must match the target method's parameters exactly.
+- For **instance methods** with a `static` stub, the first parameter of the stub is the receiver (`this`), followed by the target method's parameters.
+- For **instance methods** with an `instance` stub on the target type, `this` is the receiver; remaining parameters match the target's exactly.
+- For **instance properties** with a `static` method stub, the stub takes a single parameter: the receiver.
+- For **instance properties** with an `instance` method or property stub on the target type, the stub is parameterless.
+- For **static properties**, the stub is parameterless.
+- Property stubs can only target other properties (no parameters to carry method arguments).
+- The return type / property type must match (EXP0017 if not).
+- Constructor stubs (`[ExpressiveForConstructor]`) must still be `static` methods; instance or property ctor stubs have no coherent meaning.
## Static Method Mapping
@@ -63,10 +69,55 @@ static class EntityMappings
}
```
+## Co-located Form (Instance Stub + Single-argument Attribute)
+
+When the target is on the same type as the stub, the most ergonomic form combines an **instance stub** with the **single-argument** attribute. `this` is the receiver automatically. Use this form when a property has its own backing storage -- e.g. a plain settable auto-property used for DTO shape, serialization, or in-memory assignment in tests -- but queries should still compute it from other columns.
+
+A **property stub** is often the cleanest choice for this (no parentheses, reads like the target it replaces):
+
+```csharp
+public class Person
+{
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+
+ // Regular auto-property — assignable directly (for DTOs, tests, deserialization).
+ public string FullName { get; set; } = "";
+
+ // When FullName appears in a LINQ expression tree, it is rewritten to this body,
+ // so EF Core projects it from FirstName/LastName instead of mapping it to its own column.
+ [ExpressiveFor(nameof(FullName))]
+ private string FullNameExpression => FirstName + " " + LastName;
+}
+```
+
+A **method stub** is equivalent in behaviour and appropriate when the target is a method or when you need a block body:
+
+```csharp
+[ExpressiveFor(nameof(FullName))]
+private string FullNameExpression() => FirstName + " " + LastName;
+```
+
+Both forms are equivalent to the verbose `[ExpressiveFor(typeof(Person), nameof(Person.FullName))] static string FullName(Person obj) => obj.FirstName + " " + obj.LastName;` but reuse `this` instead of threading a receiver parameter. When the EF Core integration is enabled, both the target property **and** the stub property itself are automatically excluded from the model (no `[NotMapped]` needed -- see [Automatic NotMapped for `[ExpressiveFor]` targets](#automatic-notmapped-for-expressivefor-targets)).
+
+::: warning When to prefer `[Expressive]` instead
+If the property has no backing storage and the same body works at both runtime and query time, put `[Expressive]` directly on it (`[Expressive] public string FullName => FirstName + " " + LastName;`) and skip the stub. `[ExpressiveFor]` is for the dual-body case; `[Expressive]` is for the single-body case.
+:::
+
::: tip
The stub can use any C# syntax that `[Expressive]` supports -- switch expressions, pattern matching, null-conditional operators, and more.
:::
+## Automatic NotMapped for `[ExpressiveFor]` targets
+
+When `UseExpressives()` is active, EF Core's model builder automatically ignores properties that are:
+
+1. Decorated with `[Expressive]`,
+2. Decorated with `[ExpressiveFor]` (a property stub itself), **or**
+3. The target of an `[ExpressiveFor]` stub anywhere in the loaded assemblies.
+
+You do not need to add `[NotMapped]` to a property you are expressing externally or using as a property stub -- the `ExpressivePropertiesNotMappedConvention` detects these cases via attribute metadata and the generated registry and calls `Ignore()` for you.
+
## Constructor Mapping with `[ExpressiveForConstructor]`
Use `[ExpressiveForConstructor]` to provide an expression-tree body for a constructor on a type you do not own:
@@ -114,7 +165,6 @@ The following diagnostics are specific to `[ExpressiveFor]` and `[ExpressiveForC
|------|----------|-------------|
| [EXP0014](./diagnostics#exp0014) | Error | Target type specified in `[ExpressiveFor]` could not be resolved |
| [EXP0015](./diagnostics#exp0015) | Error | No member with the given name found on the target type matching the stub's parameter signature |
-| [EXP0016](./diagnostics#exp0016) | Error | The stub method must be `static` |
| [EXP0017](./diagnostics#exp0017) | Error | Return type of the stub does not match the target member's return type |
| [EXP0019](./diagnostics#exp0019) | Error | The target member already has `[Expressive]` -- remove one of the two attributes |
| [EXP0020](./diagnostics#exp0020) | Error | Duplicate mapping -- only one stub per target member is allowed |
diff --git a/docs/reference/projectable-properties.md b/docs/reference/projectable-properties.md
index b6629ac7..45e512a2 100644
--- a/docs/reference/projectable-properties.md
+++ b/docs/reference/projectable-properties.md
@@ -198,7 +198,7 @@ No `[NotMapped]` annotation or manual `modelBuilder.Ignore(...)` call is require
## Comparison with `[ExpressiveFor]`
-`[ExpressiveFor]` is the verbose alternative -- the formula lives in a separate static stub instead of on the property:
+`[ExpressiveFor]` is the alternative -- the formula lives in a separate stub (static or co-located instance method) instead of on the property:
```csharp
public class User
diff --git a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs
index 1522051f..b58ee5f8 100644
--- a/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs
+++ b/src/ExpressiveSharp.Abstractions/Mapping/ExpressiveForAttribute.cs
@@ -12,13 +12,14 @@ namespace ExpressiveSharp.Mapping;
/// For instance properties, the stub takes a single parameter (the receiver) and returns the property type.
/// For static properties, the stub is parameterless and returns the property type.
///
-[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
public sealed class ExpressiveForAttribute : Attribute
{
///
- /// The type that declares the target member.
+ /// The type that declares the target member, or null when the single-argument constructor
+ /// is used (in which case the target defaults to the stub's containing type at generator time).
///
- public Type TargetType { get; }
+ public Type? TargetType { get; }
///
/// The name of the target member on .
@@ -43,4 +44,14 @@ public ExpressiveForAttribute(Type targetType, string memberName)
TargetType = targetType;
MemberName = memberName;
}
+
+ ///
+ /// Shorthand for [ExpressiveFor(typeof(ContainingType), memberName)] —
+ /// use when the target member is on the same type as the stub.
+ ///
+ public ExpressiveForAttribute(string memberName)
+ {
+ TargetType = null;
+ MemberName = memberName;
+ }
}
diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs
index 7121d080..b4fc8840 100644
--- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs
+++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs
@@ -43,6 +43,10 @@ public ExpressiveOptionsExtension(IReadOnlyList plugins, bool
[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to decorate query compiler")]
public void ApplyServices(IServiceCollection services)
{
+ // The expressive resolver is stateless at the instance level (all caches are process-static),
+ // so a singleton lifetime is appropriate and cheap to share across scopes.
+ services.TryAddSingleton();
+
// Register conventions
services.AddScoped();
services.AddScoped();
diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs
index 438b82fa..08057220 100644
--- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs
+++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConvention.cs
@@ -1,15 +1,29 @@
using System.Reflection;
+using ExpressiveSharp.Mapping;
+using ExpressiveSharp.Services;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal;
///
-/// Convention that marks properties decorated with
-/// as unmapped in the EF Core model, since they have no backing database column.
+/// Convention that marks properties as unmapped in the EF Core model when they have no backing
+/// database column:
+///
+/// - decorated with ,
+/// - decorated with (the property is a stub itself), or
+/// - the target of an stub elsewhere in the solution.
+///
///
public class ExpressivePropertiesNotMappedConvention : IEntityTypeAddedConvention
{
+ private readonly IExpressiveResolver _resolver;
+
+ public ExpressivePropertiesNotMappedConvention(IExpressiveResolver resolver)
+ {
+ _resolver = resolver;
+ }
+
public void ProcessEntityTypeAdded(
IConventionEntityTypeBuilder entityTypeBuilder,
IConventionContext context)
@@ -19,7 +33,9 @@ public void ProcessEntityTypeAdded(
foreach (var property in entityTypeBuilder.Metadata.ClrType.GetRuntimeProperties())
{
- if (property.GetCustomAttribute() is not null)
+ if (property.GetCustomAttribute() is not null
+ || property.GetCustomAttribute() is not null
+ || _resolver.FindExternalExpression(property) is not null)
{
entityTypeBuilder.Ignore(property.Name);
}
diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs
index d001ca82..cd3609b3 100644
--- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs
+++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressivePropertiesNotMappedConventionPlugin.cs
@@ -1,3 +1,4 @@
+using ExpressiveSharp.Services;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
@@ -5,9 +6,16 @@ namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal;
public class ExpressivePropertiesNotMappedConventionPlugin : IConventionSetPlugin
{
+ private readonly IExpressiveResolver _resolver;
+
+ public ExpressivePropertiesNotMappedConventionPlugin(IExpressiveResolver resolver)
+ {
+ _resolver = resolver;
+ }
+
public ConventionSet ModifyConventions(ConventionSet conventionSet)
{
- conventionSet.EntityTypeAddedConventions.Add(new ExpressivePropertiesNotMappedConvention());
+ conventionSet.EntityTypeAddedConventions.Add(new ExpressivePropertiesNotMappedConvention(_resolver));
return conventionSet;
}
}
diff --git a/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs b/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs
index 2f2d6467..e00e9e0e 100644
--- a/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs
+++ b/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs
@@ -8,27 +8,28 @@ namespace ExpressiveSharp.Generator.Comparers;
///
/// Equality comparer for [ExpressiveFor] pipeline tuples,
/// mirroring for the standard pipeline.
+/// The Member field is a to cover both method and property stubs.
///
internal class ExpressiveForMemberCompilationEqualityComparer
- : IEqualityComparer<((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)>
+ : IEqualityComparer<((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)>
{
private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new();
public bool Equals(
- ((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x,
- ((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y)
+ ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x,
+ ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y)
{
var (xLeft, xCompilation) = x;
var (yLeft, yCompilation) = y;
- if (ReferenceEquals(xLeft.Method, yLeft.Method) &&
+ if (ReferenceEquals(xLeft.Member, yLeft.Member) &&
ReferenceEquals(xCompilation, yCompilation) &&
xLeft.GlobalOptions == yLeft.GlobalOptions)
{
return true;
}
- if (!ReferenceEquals(xLeft.Method.SyntaxTree, yLeft.Method.SyntaxTree))
+ if (!ReferenceEquals(xLeft.Member.SyntaxTree, yLeft.Member.SyntaxTree))
{
return false;
}
@@ -43,7 +44,7 @@ public bool Equals(
return false;
}
- if (!_memberComparer.Equals(xLeft.Method, yLeft.Method))
+ if (!_memberComparer.Equals(xLeft.Member, yLeft.Member))
{
return false;
}
@@ -51,14 +52,14 @@ public bool Equals(
return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences);
}
- public int GetHashCode(((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj)
+ public int GetHashCode(((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj)
{
var (left, compilation) = obj;
unchecked
{
var hash = 17;
- hash = hash * 31 + _memberComparer.GetHashCode(left.Method);
- hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Method.SyntaxTree);
+ hash = hash * 31 + _memberComparer.GetHashCode(left.Member);
+ hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Member.SyntaxTree);
hash = hash * 31 + left.Attribute.GetHashCode();
hash = hash * 31 + left.GlobalOptions.GetHashCode();
diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs
index bf48a5ac..b061fffa 100644
--- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs
+++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs
@@ -125,7 +125,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
/// Discovers, interprets, emits expression factory source, and returns the pipeline
/// for registry entry extraction.
///
- private static IncrementalValuesProvider<((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)>
+ private static IncrementalValuesProvider<((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)>
CreateExpressiveForPipeline(
IncrementalGeneratorInitializationContext context,
IncrementalValueProvider globalOptions,
@@ -135,16 +135,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
var declarations = context.SyntaxProvider
.ForAttributeWithMetadataName(
attributeFullName,
- predicate: static (s, _) => s is MethodDeclarationSyntax,
+ predicate: static (s, _) => s is MethodDeclarationSyntax or PropertyDeclarationSyntax,
transform: (c, _) => (
- Method: (MethodDeclarationSyntax)c.TargetNode,
+ Member: (MemberDeclarationSyntax)c.TargetNode,
Attribute: new ExpressiveForAttributeData(c.Attributes[0], memberKind)
));
var declarationsWithGlobalOptions = declarations
.Combine(globalOptions)
.Select(static (pair, _) => (
- Method: pair.Left.Method,
+ Member: pair.Left.Member,
Attribute: pair.Left.Attribute,
GlobalOptions: pair.Right
));
@@ -163,14 +163,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
foreach (var source in items)
{
- var ((method, attribute, globalOptions), compilation) = source;
- var semanticModel = compilation.GetSemanticModel(method.SyntaxTree);
- var stubSymbol = semanticModel.GetDeclaredSymbol(method) as IMethodSymbol;
+ var ((member, attribute, globalOptions), compilation) = source;
+ var semanticModel = compilation.GetSemanticModel(member.SyntaxTree);
+ var stubSymbol = semanticModel.GetDeclaredSymbol(member);
- if (stubSymbol is null)
+ if (stubSymbol is not (IMethodSymbol or IPropertySymbol))
continue;
- ExecuteFor(method, semanticModel, stubSymbol, attribute, globalOptions,
+ ExecuteFor(member, semanticModel, stubSymbol, attribute, globalOptions,
compilation, spc, emittedFileNames);
}
});
@@ -411,9 +411,9 @@ private static void EmitExpressionTreeSource(
/// validates the stub, and emits the expression tree factory source file.
///
private static void ExecuteFor(
- MethodDeclarationSyntax stubMethod,
+ MemberDeclarationSyntax stubMember,
SemanticModel semanticModel,
- IMethodSymbol stubSymbol,
+ ISymbol stubSymbol,
ExpressiveForAttributeData attributeData,
ExpressiveGlobalOptions globalOptions,
Compilation compilation,
@@ -421,7 +421,7 @@ private static void ExecuteFor(
HashSet? emittedFileNames = null)
{
var descriptor = ExpressiveForInterpreter.GetDescriptor(
- semanticModel, stubMethod, stubSymbol, attributeData, globalOptions, context, compilation);
+ semanticModel, stubMember, stubSymbol, attributeData, globalOptions, context, compilation);
if (descriptor is null)
return;
@@ -442,7 +442,7 @@ private static void ExecuteFor(
if (descriptor.ExpressionTreeEmission is null)
throw new InvalidOperationException("ExpressionTreeEmission must be set");
- EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, stubMethod, compilation, context);
+ EmitExpressionTreeSource(descriptor, generatedClassName, methodSuffix, generatedFileName, stubMember, compilation, context);
}
///
@@ -450,19 +450,30 @@ private static void ExecuteFor(
/// The entry points to the external target member, not the stub itself.
///
private static ExpressionRegistryEntry? ExtractRegistryEntryForExternal(
- ((MethodDeclarationSyntax Method, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) source)
+ ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) source)
{
- var ((method, attribute, globalOptions), compilation) = source;
- var semanticModel = compilation.GetSemanticModel(method.SyntaxTree);
- var stubSymbol = semanticModel.GetDeclaredSymbol(method) as IMethodSymbol;
+ var ((member, attribute, _), compilation) = source;
+ var semanticModel = compilation.GetSemanticModel(member.SyntaxTree);
+ var rawStubSymbol = semanticModel.GetDeclaredSymbol(member);
- if (stubSymbol is null)
+ if (rawStubSymbol is not (IMethodSymbol or IPropertySymbol))
return null;
- // Resolve target type
+ var stubIsProperty = rawStubSymbol is IPropertySymbol;
+ var stubIsStatic = rawStubSymbol.IsStatic;
+ var stubContainingType = rawStubSymbol.ContainingType;
+ var stubMethodSymbol = rawStubSymbol as IMethodSymbol;
+
+ // Property stubs cannot map to constructors (no parameter list).
+ if (stubIsProperty && attribute.MemberKind == ExpressiveForMemberKind.Constructor)
+ return null;
+
+ // Resolve target type. Two cases (mirrors ExpressiveForInterpreter):
+ // - Two-arg form: resolve from metadata name.
+ // - Single-arg form: default to the stub's containing type.
var targetType = attribute.TargetTypeMetadataName is not null
? compilation.GetTypeByMetadataName(attribute.TargetTypeMetadataName)
- : null;
+ : stubContainingType;
if (targetType is null)
return null;
@@ -482,9 +493,9 @@ private static void ExecuteFor(
memberKind = ExpressionRegistryMemberType.Constructor;
memberLookupName = "_ctor";
- // Constructor params match stub params directly
+ // Constructor params match stub params directly (method stubs only — guarded above).
parameterTypeNames = [
- ..stubSymbol.Parameters.Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
+ ..stubMethodSymbol!.Parameters.Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
];
}
else
@@ -493,8 +504,10 @@ private static void ExecuteFor(
if (memberName is null)
return null;
- // Determine if this maps to a property or method
- var isProperty = targetType.GetMembers(memberName).OfType().Any();
+ // Property stubs can only target properties; method stubs may target either.
+ var isProperty = stubIsProperty
+ || targetType.GetMembers(memberName).OfType().Any();
+
if (isProperty)
{
memberKind = ExpressionRegistryMemberType.Property;
@@ -506,29 +519,13 @@ private static void ExecuteFor(
memberKind = ExpressionRegistryMemberType.Method;
memberLookupName = memberName;
- // Find the matching target method to get its parameter types (not the stub's)
+ // Signature matching via the shared matcher so the registry entry can never
+ // disagree with what ExpressiveForInterpreter accepted.
+ var stubParams = stubMethodSymbol!.Parameters;
var targetMethod = targetType.GetMembers(memberName).OfType()
.Where(m => m.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet))
- .FirstOrDefault(m =>
- {
- var expectedParamCount = m.IsStatic ? m.Parameters.Length : m.Parameters.Length + 1;
- if (stubSymbol.Parameters.Length != expectedParamCount)
- return false;
-
- // For instance methods, validate that the stub's first parameter matches the target type
- if (!m.IsStatic &&
- !SymbolEqualityComparer.Default.Equals(stubSymbol.Parameters[0].Type, targetType))
- return false;
-
- var offset = m.IsStatic ? 0 : 1;
- for (var i = 0; i < m.Parameters.Length; i++)
- {
- if (!SymbolEqualityComparer.Default.Equals(
- m.Parameters[i].Type, stubSymbol.Parameters[i + offset].Type))
- return false;
- }
- return true;
- });
+ .FirstOrDefault(m => Interpretation.ExpressiveForSignatureMatcher
+ .MatchesMethodSignature(m, targetType, stubIsStatic, stubContainingType, stubParams));
if (targetMethod is null)
return null;
@@ -557,7 +554,12 @@ private static void ExecuteFor(
var expressionMethodName = methodSuffix + "_Expression";
// Capture stub location for duplicate detection diagnostics
- var stubLocation = method.Identifier.GetLocation();
+ var stubLocation = member switch
+ {
+ MethodDeclarationSyntax m => m.Identifier.GetLocation(),
+ PropertyDeclarationSyntax p => p.Identifier.GetLocation(),
+ _ => member.GetLocation()
+ };
var stubLineSpan = stubLocation.GetLineSpan();
return new ExpressionRegistryEntry(
diff --git a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs
index 77dad02a..d51ec6d7 100644
--- a/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs
+++ b/src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs
@@ -127,13 +127,9 @@ static internal class Diagnostics
DiagnosticSeverity.Error,
isEnabledByDefault: true);
- public readonly static DiagnosticDescriptor ExpressiveForStubMustBeStatic = new DiagnosticDescriptor(
- id: "EXP0016",
- title: "[ExpressiveFor] stub must be static",
- messageFormat: "[ExpressiveFor] stub method '{0}' must be static",
- category: "Design",
- DiagnosticSeverity.Error,
- isEnabledByDefault: true);
+ // NOTE: EXP0016 (ExpressiveForStubMustBeStatic) is retired. Instance stubs are now permitted
+ // on the target type itself. Constructor stubs remain static-only; signature mismatches fall
+ // back to EXP0015 (ExpressiveForMemberNotFound).
public readonly static DiagnosticDescriptor ExpressiveForReturnTypeMismatch = new DiagnosticDescriptor(
id: "EXP0017",
diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs
index 97ef2b82..e20057ad 100644
--- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs
+++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs
@@ -17,45 +17,66 @@ static internal class ExpressiveForInterpreter
{
public static ExpressiveDescriptor? GetDescriptor(
SemanticModel semanticModel,
- MethodDeclarationSyntax stubMethod,
- IMethodSymbol stubSymbol,
+ MemberDeclarationSyntax stubMember,
+ ISymbol stubSymbol,
ExpressiveForAttributeData attributeData,
ExpressiveGlobalOptions globalOptions,
SourceProductionContext context,
Compilation compilation)
{
- // Validate: stub must be static
- if (!stubSymbol.IsStatic)
+ var stubIdentifierLocation = stubMember switch
{
- context.ReportDiagnostic(Diagnostic.Create(
- Diagnostics.ExpressiveForStubMustBeStatic,
- stubMethod.Identifier.GetLocation(),
- stubSymbol.Name));
- return null;
- }
+ MethodDeclarationSyntax m => m.Identifier.GetLocation(),
+ PropertyDeclarationSyntax p => p.Identifier.GetLocation(),
+ _ => stubMember.GetLocation()
+ };
- // Resolve target type
- var targetType = attributeData.TargetTypeMetadataName is not null
- ? compilation.GetTypeByMetadataName(attributeData.TargetTypeMetadataName)
- : null;
+ // Resolve target type. Two cases:
+ // - Two-arg form: [ExpressiveFor(typeof(T), "Name")] — resolve T from metadata name.
+ // - Single-arg form: [ExpressiveFor("Name")] — default to the stub's containing type.
+ INamedTypeSymbol? targetType;
+ if (attributeData.TargetTypeMetadataName is not null)
+ {
+ targetType = compilation.GetTypeByMetadataName(attributeData.TargetTypeMetadataName);
+ if (targetType is null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Diagnostics.ExpressiveForTargetTypeNotFound,
+ stubIdentifierLocation,
+ attributeData.TargetTypeFullName));
+ return null;
+ }
+ }
+ else
+ {
+ targetType = stubSymbol.ContainingType;
+ }
- if (targetType is null)
+ // Property stubs can only target properties (no parameter list to carry method args).
+ // Only the ExpressiveFor (MethodOrProperty) pipeline reaches this branch; the constructor
+ // attribute is method-target-only at the AttributeUsage level.
+ if (stubMember is PropertyDeclarationSyntax stubProperty && stubSymbol is IPropertySymbol stubPropertySymbol)
{
- context.ReportDiagnostic(Diagnostic.Create(
- Diagnostics.ExpressiveForTargetTypeNotFound,
- stubMethod.Identifier.GetLocation(),
- attributeData.TargetTypeFullName));
- return null;
+ if (attributeData.MemberKind == ExpressiveForMemberKind.Constructor)
+ return null;
+
+ return ResolvePropertyStub(semanticModel, stubProperty, stubPropertySymbol, attributeData,
+ globalOptions, context, compilation, targetType, stubIdentifierLocation);
}
- return attributeData.MemberKind switch
+ if (stubMember is MethodDeclarationSyntax stubMethod && stubSymbol is IMethodSymbol stubMethodSymbol)
{
- ExpressiveForMemberKind.MethodOrProperty =>
- ResolveMethodOrProperty(semanticModel, stubMethod, stubSymbol, attributeData, globalOptions, context, compilation, targetType),
- ExpressiveForMemberKind.Constructor =>
- ResolveConstructor(semanticModel, stubMethod, stubSymbol, attributeData, globalOptions, context, compilation, targetType),
- _ => null
- };
+ return attributeData.MemberKind switch
+ {
+ ExpressiveForMemberKind.MethodOrProperty =>
+ ResolveMethodOrProperty(semanticModel, stubMethod, stubMethodSymbol, attributeData, globalOptions, context, compilation, targetType),
+ ExpressiveForMemberKind.Constructor =>
+ ResolveConstructor(semanticModel, stubMethod, stubMethodSymbol, attributeData, globalOptions, context, compilation, targetType),
+ _ => null
+ };
+ }
+
+ return null;
}
private static ExpressiveDescriptor? ResolveMethodOrProperty(
@@ -158,76 +179,87 @@ static internal class ExpressiveForInterpreter
ctor.Parameters, isInstanceMember: false);
}
- private static IPropertySymbol? FindTargetProperty(
- INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol)
+ private static ExpressiveDescriptor? ResolvePropertyStub(
+ SemanticModel semanticModel,
+ PropertyDeclarationSyntax stubProperty,
+ IPropertySymbol stubSymbol,
+ ExpressiveForAttributeData attributeData,
+ ExpressiveGlobalOptions globalOptions,
+ SourceProductionContext context,
+ Compilation compilation,
+ INamedTypeSymbol targetType,
+ Location stubIdentifierLocation)
{
- var members = targetType.GetMembers(memberName);
- foreach (var member in members)
- {
- if (member is not IPropertySymbol property)
- continue;
-
- // Exclude indexers (they have parameters)
- if (property.Parameters.Length > 0)
- continue;
+ var memberName = attributeData.MemberName;
+ if (memberName is null)
+ return null;
- // Instance property: stub should have 1 param (the instance) whose type matches targetType
- // Static property: stub should have 0 params
- if (property.IsStatic && stubSymbol.Parameters.Length == 0)
- return property;
- if (!property.IsStatic && stubSymbol.Parameters.Length == 1 &&
- SymbolEqualityComparer.Default.Equals(stubSymbol.Parameters[0].Type, targetType))
- return property;
+ var target = FindTargetPropertyForPropertyStub(targetType, memberName, stubSymbol);
+ if (target is null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Diagnostics.ExpressiveForMemberNotFound,
+ stubIdentifierLocation,
+ memberName,
+ targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ return null;
}
- return null;
- }
- private static IMethodSymbol? FindTargetMethod(
- INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol)
- {
- var members = targetType.GetMembers(memberName);
- foreach (var member in members)
+ if (HasExpressiveAttribute(target, compilation))
{
- if (member is not IMethodSymbol method || method.MethodKind is MethodKind.PropertyGet or MethodKind.PropertySet)
- continue;
+ context.ReportDiagnostic(Diagnostic.Create(
+ Diagnostics.ExpressiveForConflictsWithExpressive,
+ stubIdentifierLocation,
+ memberName,
+ targetType.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ return null;
+ }
- // For instance methods: first stub param = this, rest = method params
- // For static methods: all stub params = method params
- var expectedStubParamCount = method.IsStatic
- ? method.Parameters.Length
- : method.Parameters.Length + 1;
+ if (!SymbolEqualityComparer.Default.Equals(stubSymbol.Type, target.Type))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Diagnostics.ExpressiveForReturnTypeMismatch,
+ stubProperty.Type.GetLocation(),
+ target.Name,
+ target.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat),
+ stubSymbol.Type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)));
+ return null;
+ }
- if (stubSymbol.Parameters.Length != expectedStubParamCount)
- continue;
+ return BuildDescriptorFromPropertyStub(semanticModel, stubProperty, stubSymbol, attributeData,
+ globalOptions, context, targetType, target.Name);
+ }
- // For instance methods, validate that the stub's first parameter matches the target type
- if (!method.IsStatic &&
- !SymbolEqualityComparer.Default.Equals(stubSymbol.Parameters[0].Type, targetType))
- continue;
+ private static IPropertySymbol? FindTargetPropertyForPropertyStub(
+ INamedTypeSymbol targetType, string memberName, IPropertySymbol stubSymbol)
+ => targetType.GetMembers(memberName)
+ .OfType()
+ .FirstOrDefault(property => ExpressiveForSignatureMatcher.MatchesPropertyFromPropertyStub(
+ property, targetType, stubSymbol.IsStatic, stubSymbol.ContainingType));
- // Check parameter types match
- var offset = method.IsStatic ? 0 : 1;
- var match = true;
- for (var i = 0; i < method.Parameters.Length; i++)
- {
- var targetParamType = method.Parameters[i].Type;
- var stubParamType = stubSymbol.Parameters[i + offset].Type;
- if (!SymbolEqualityComparer.Default.Equals(targetParamType, stubParamType))
- {
- match = false;
- break;
- }
- }
+ private static IPropertySymbol? FindTargetProperty(
+ INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol)
+ => targetType.GetMembers(memberName)
+ .OfType()
+ .FirstOrDefault(property => ExpressiveForSignatureMatcher.MatchesPropertyFromMethodStub(
+ property, targetType, stubSymbol.IsStatic, stubSymbol.ContainingType, stubSymbol.Parameters));
- if (match)
- return method;
- }
- return null;
- }
+ private static IMethodSymbol? FindTargetMethod(
+ INamedTypeSymbol targetType, string memberName, IMethodSymbol stubSymbol)
+ => targetType.GetMembers(memberName)
+ .OfType()
+ .Where(m => m.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet))
+ .FirstOrDefault(method => ExpressiveForSignatureMatcher.MatchesMethodSignature(
+ method, targetType, stubSymbol.IsStatic, stubSymbol.ContainingType, stubSymbol.Parameters));
private static IMethodSymbol? FindTargetConstructor(
INamedTypeSymbol targetType, IMethodSymbol stubSymbol)
{
+ // Constructor stubs remain static-only: an instance stub producing a new instance of its
+ // own containing type has no natural `this` semantics and would produce incoherent code.
+ if (!stubSymbol.IsStatic)
+ return null;
+
foreach (var ctor in targetType.Constructors)
{
if (ctor.IsStatic)
@@ -310,7 +342,7 @@ static internal class ExpressiveForInterpreter
}
///
- /// Builds the from the stub method's body,
+ /// Builds the from a method stub's body,
/// using the target type's namespace/class path for generated class naming.
///
private static ExpressiveDescriptor? BuildDescriptorFromStub(
@@ -325,49 +357,10 @@ static internal class ExpressiveForInterpreter
System.Collections.Immutable.ImmutableArray targetParameters,
bool isInstanceMember)
{
- var declarationSyntaxRewriter = new DeclarationSyntaxRewriter(semanticModel);
-
- // Use target type's namespace/class path for naming — this is what the registry will use
- var targetClassNamespace = targetType.ContainingNamespace.IsGlobalNamespace
- ? null
- : targetType.ContainingNamespace.ToDisplayString();
-
- var descriptor = new ExpressiveDescriptor
- {
- UsingDirectives = stubMethod.SyntaxTree.GetRoot().DescendantNodes().OfType(),
- ClassName = targetType.Name,
- ClassNamespace = targetClassNamespace,
- MemberName = targetMemberName,
- NestedInClassNames = GetNestedInClassPath(targetType),
- TargetClassNamespace = targetClassNamespace,
- TargetNestedInClassNames = GetNestedInClassPath(targetType),
- ParametersList = SyntaxFactory.ParameterList()
- };
-
- // Populate declared transformers from attribute
- foreach (var typeName in attributeData.TransformerTypeNames)
- descriptor.DeclaredTransformerTypeNames.Add(typeName);
-
- // Collect parameter type names for registry disambiguation
- // Use the TARGET member's parameter types (not the stub's)
- if (!targetParameters.IsEmpty)
- {
- descriptor.ParameterTypeNames = targetParameters
- .Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
- .ToList();
- }
-
- // Build the stub's parameter list on the descriptor
- // (this is what the expression factory method will use)
- var rewrittenParamList = (ParameterListSyntax)declarationSyntaxRewriter.Visit(stubMethod.ParameterList);
- foreach (var p in rewrittenParamList.Parameters)
- {
- descriptor.ParametersList = descriptor.ParametersList.AddParameters(p);
- }
-
- // Extract and emit the body
+ var rewriter = new DeclarationSyntaxRewriter(semanticModel);
var allowBlockBody = attributeData.AllowBlockBody ?? globalOptions.AllowBlockBody;
+ // Extract body from the stub method.
SyntaxNode bodySyntax;
if (stubMethod.ExpressionBody is not null)
{
@@ -394,26 +387,163 @@ static internal class ExpressiveForInterpreter
return null;
}
- var returnTypeSyntax = declarationSyntaxRewriter.Visit(stubMethod.ReturnType);
- descriptor.ReturnTypeName = returnTypeSyntax.ToString();
+ var returnTypeName = rewriter.Visit(stubMethod.ReturnType).ToString();
+
+ // Explicit stub params (after syntax rewriting for default values / modifiers).
+ var rewrittenParamList = (ParameterListSyntax)rewriter.Visit(stubMethod.ParameterList);
+ var explicitSyntax = rewrittenParamList.Parameters.ToList();
+ var explicitEmitterParams = stubSymbol.Parameters
+ .Select(p => new EmitterParameter(
+ p.Name,
+ p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ symbol: p))
+ .ToList();
+
+ return BuildDescriptorCore(semanticModel, context, stubMethod.SyntaxTree, stubSymbol,
+ attributeData, targetType, targetMemberName, targetParameters,
+ explicitSyntax, explicitEmitterParams, returnTypeName, bodySyntax);
+ }
+
+ ///
+ /// Builds the from a property stub's body.
+ /// Property stubs are always parameterless; an instance stub's this becomes a synthetic
+ /// receiver on the generated factory method.
+ ///
+ private static ExpressiveDescriptor? BuildDescriptorFromPropertyStub(
+ SemanticModel semanticModel,
+ PropertyDeclarationSyntax stubProperty,
+ IPropertySymbol stubSymbol,
+ ExpressiveForAttributeData attributeData,
+ ExpressiveGlobalOptions globalOptions,
+ SourceProductionContext context,
+ INamedTypeSymbol targetType,
+ string targetMemberName)
+ {
+ var rewriter = new DeclarationSyntaxRewriter(semanticModel);
+ var allowBlockBody = attributeData.AllowBlockBody ?? globalOptions.AllowBlockBody;
+
+ // Extract body: prefer the expression-bodied form, otherwise fall back to the get accessor.
+ SyntaxNode? bodySyntax = null;
+ if (stubProperty.ExpressionBody is not null)
+ {
+ bodySyntax = stubProperty.ExpressionBody.Expression;
+ }
+ else if (stubProperty.AccessorList is not null)
+ {
+ var getter = stubProperty.AccessorList.Accessors
+ .FirstOrDefault(a => a.Kind() == SyntaxKind.GetAccessorDeclaration);
+
+ if (getter is not null)
+ {
+ if (getter.ExpressionBody is not null)
+ {
+ bodySyntax = getter.ExpressionBody.Expression;
+ }
+ else if (getter.Body is not null)
+ {
+ if (!allowBlockBody)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Diagnostics.BlockBodyRequiresOptIn,
+ stubProperty.Identifier.GetLocation(),
+ stubSymbol.Name));
+ return null;
+ }
+ bodySyntax = getter.Body;
+ }
+ }
+ }
+
+ if (bodySyntax is null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Diagnostics.RequiresBodyDefinition,
+ stubProperty.GetLocation(),
+ stubSymbol.Name));
+ return null;
+ }
+
+ var returnTypeName = rewriter.Visit(stubProperty.Type).ToString();
+
+ return BuildDescriptorCore(semanticModel, context, stubProperty.SyntaxTree, stubSymbol,
+ attributeData, targetType, targetMemberName,
+ targetParameters: System.Collections.Immutable.ImmutableArray.Empty,
+ explicitStubParamSyntax: [],
+ explicitStubEmitterParams: [],
+ returnTypeName, bodySyntax);
+ }
+
+ ///
+ /// Shared scaffolding for both method-stub and property-stub descriptor construction.
+ /// Builds the descriptor shell, synthesizes @this for instance stubs, appends the
+ /// caller-supplied explicit stub parameters, assembles the delegate type, and emits the body.
+ ///
+ private static ExpressiveDescriptor BuildDescriptorCore(
+ SemanticModel semanticModel,
+ SourceProductionContext context,
+ SyntaxTree stubSyntaxTree,
+ ISymbol stubSymbol,
+ ExpressiveForAttributeData attributeData,
+ INamedTypeSymbol targetType,
+ string targetMemberName,
+ System.Collections.Immutable.ImmutableArray targetParameters,
+ IReadOnlyList explicitStubParamSyntax,
+ IReadOnlyList explicitStubEmitterParams,
+ string returnTypeName,
+ SyntaxNode bodySyntax)
+ {
+ var targetClassNamespace = targetType.ContainingNamespace.IsGlobalNamespace
+ ? null
+ : targetType.ContainingNamespace.ToDisplayString();
+
+ var descriptor = new ExpressiveDescriptor
+ {
+ UsingDirectives = stubSyntaxTree.GetRoot().DescendantNodes().OfType(),
+ ClassName = targetType.Name,
+ ClassNamespace = targetClassNamespace,
+ MemberName = targetMemberName,
+ NestedInClassNames = GetNestedInClassPath(targetType),
+ TargetClassNamespace = targetClassNamespace,
+ TargetNestedInClassNames = GetNestedInClassPath(targetType),
+ ParametersList = SyntaxFactory.ParameterList(),
+ ReturnTypeName = returnTypeName,
+ };
+
+ foreach (var typeName in attributeData.TransformerTypeNames)
+ descriptor.DeclaredTransformerTypeNames.Add(typeName);
+
+ // Target member's parameter types disambiguate method overloads in the registry;
+ // properties and parameterless targets keep ParameterTypeNames null.
+ if (!targetParameters.IsEmpty)
+ {
+ descriptor.ParameterTypeNames = targetParameters
+ .Select(p => p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
+ .ToList();
+ }
- // Build emitter parameters from the stub's parameters
- var emitter = new ExpressionTreeEmitter(semanticModel, context);
var emitterParams = new List();
- foreach (var param in stubSymbol.Parameters)
+
+ // For instance stubs, prepend `@this` so IInstanceReferenceOperation in the body binds to it.
+ if (!stubSymbol.IsStatic)
{
- emitterParams.Add(new EmitterParameter(
- param.Name,
- param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
- symbol: param));
+ var thisTypeFqn = stubSymbol.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ descriptor.ParametersList = descriptor.ParametersList.AddParameters(
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("@this"))
+ .WithType(SyntaxFactory.ParseTypeName(thisTypeFqn)));
+ emitterParams.Add(new EmitterParameter("@this", thisTypeFqn, isThis: true));
}
+ foreach (var p in explicitStubParamSyntax)
+ descriptor.ParametersList = descriptor.ParametersList.AddParameters(p);
+ emitterParams.AddRange(explicitStubEmitterParams);
+
var allTypeArgs = emitterParams.Select(p => p.TypeFqn).ToList();
- allTypeArgs.Add(descriptor.ReturnTypeName);
+ allTypeArgs.Add(returnTypeName);
var delegateTypeFqn = $"global::System.Func<{string.Join(", ", allTypeArgs)}>";
+ var emitter = new ExpressionTreeEmitter(semanticModel, context);
descriptor.ExpressionTreeEmission = emitter.Emit(bodySyntax, emitterParams,
- descriptor.ReturnTypeName, delegateTypeFqn);
+ returnTypeName, delegateTypeFqn);
return descriptor;
}
diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForSignatureMatcher.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForSignatureMatcher.cs
new file mode 100644
index 00000000..66189b28
--- /dev/null
+++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForSignatureMatcher.cs
@@ -0,0 +1,123 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace ExpressiveSharp.Generator.Interpretation;
+
+///
+/// Shared signature-matching rules for [ExpressiveFor] stubs.
+/// Kept central so the interpreter (which validates + emits) and the registry extractor
+/// (which builds the runtime lookup entry) can never disagree about which target member
+/// a stub maps to — a divergence here produces either silently-missing bodies or
+/// orphaned registry entries.
+///
+static internal class ExpressiveForSignatureMatcher
+{
+ ///
+ /// Four-quadrant matrix for method-target ↔ method-stub matching:
+ ///
+ /// - static target + static stub: stub params = target params
+ /// - instance target + static stub: stub params = [receiver] + target params (receiver type = targetType)
+ /// - instance target + instance stub on targetType: stub params = target params (this is receiver)
+ /// - static target + instance stub: never matches (no way to receive a non-null instance)
+ ///
+ ///
+ public static bool MatchesMethodSignature(
+ IMethodSymbol target,
+ INamedTypeSymbol targetType,
+ bool stubIsStatic,
+ INamedTypeSymbol stubContainingType,
+ ImmutableArray stubParameters)
+ {
+ if (target.IsStatic && !stubIsStatic)
+ return false;
+
+ int expectedStubParamCount;
+ int offset;
+ if (!target.IsStatic && stubIsStatic)
+ {
+ // Static stub over instance target: first stub param is the explicit receiver.
+ expectedStubParamCount = target.Parameters.Length + 1;
+ offset = 1;
+ }
+ else
+ {
+ // Either both static, or both instance (receiver implicit via `this`).
+ expectedStubParamCount = target.Parameters.Length;
+ offset = 0;
+ }
+
+ if (stubParameters.Length != expectedStubParamCount)
+ return false;
+
+ if (!target.IsStatic && stubIsStatic &&
+ !SymbolEqualityComparer.Default.Equals(stubParameters[0].Type, targetType))
+ return false;
+
+ if (!target.IsStatic && !stubIsStatic &&
+ !SymbolEqualityComparer.Default.Equals(stubContainingType, targetType))
+ return false;
+
+ for (var i = 0; i < target.Parameters.Length; i++)
+ {
+ if (!SymbolEqualityComparer.Default.Equals(
+ target.Parameters[i].Type, stubParameters[i + offset].Type))
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Matching rules for property-target ↔ method-stub (the parameter-count encoding is the
+ /// same matrix but restricted to zero target parameters):
+ ///
+ /// - static property + static stub (0 params): match.
+ /// - instance property + static stub (1 param of targetType): match.
+ /// - instance property + instance stub on targetType (0 params): match (this is receiver).
+ /// - static property + instance stub: never matches.
+ ///
+ ///
+ public static bool MatchesPropertyFromMethodStub(
+ IPropertySymbol target,
+ INamedTypeSymbol targetType,
+ bool stubIsStatic,
+ INamedTypeSymbol stubContainingType,
+ ImmutableArray stubParameters)
+ {
+ // Indexers have parameters and cannot be expressive targets.
+ if (target.Parameters.Length > 0)
+ return false;
+
+ if (stubIsStatic)
+ {
+ if (target.IsStatic && stubParameters.Length == 0)
+ return true;
+ if (!target.IsStatic && stubParameters.Length == 1 &&
+ SymbolEqualityComparer.Default.Equals(stubParameters[0].Type, targetType))
+ return true;
+ return false;
+ }
+
+ return !target.IsStatic && stubParameters.Length == 0 &&
+ SymbolEqualityComparer.Default.Equals(stubContainingType, targetType);
+ }
+
+ ///
+ /// Matching rules for property-target ↔ property-stub (parameterless stub, this as receiver).
+ ///
+ public static bool MatchesPropertyFromPropertyStub(
+ IPropertySymbol target,
+ INamedTypeSymbol targetType,
+ bool stubIsStatic,
+ INamedTypeSymbol stubContainingType)
+ {
+ if (target.Parameters.Length > 0)
+ return false;
+
+ if (stubIsStatic && target.IsStatic)
+ return true;
+
+ return !stubIsStatic && !target.IsStatic &&
+ SymbolEqualityComparer.Default.Equals(stubContainingType, targetType);
+ }
+}
diff --git a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs
index 15002ab4..20202e47 100644
--- a/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs
+++ b/src/ExpressiveSharp.Generator/Models/ExpressiveForAttributeData.cs
@@ -38,7 +38,9 @@ public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKi
bool? allowBlockBody = null;
var transformerTypeNames = new List();
- // Extract target type from first constructor argument
+ // Extract target type from first constructor argument.
+ // ExpressiveFor has two constructors: (Type, string) and (string).
+ // ExpressiveForConstructor has a single (Type) constructor.
if (attribute.ConstructorArguments.Length > 0 &&
attribute.ConstructorArguments[0].Value is INamedTypeSymbol targetTypeSymbol)
{
@@ -51,12 +53,20 @@ public ExpressiveForAttributeData(AttributeData attribute, ExpressiveForMemberKi
TargetTypeMetadataName = null;
}
- // Extract member name from second constructor argument (only for ExpressiveFor, not ExpressiveForConstructor)
- if (memberKind != ExpressiveForMemberKind.Constructor &&
- attribute.ConstructorArguments.Length > 1 &&
- attribute.ConstructorArguments[1].Value is string memberName)
+ if (memberKind != ExpressiveForMemberKind.Constructor)
{
- MemberName = memberName;
+ if (attribute.ConstructorArguments.Length > 1 &&
+ attribute.ConstructorArguments[1].Value is string memberNameTwoArg)
+ {
+ // Two-argument form: [ExpressiveFor(typeof(T), "Name")]
+ MemberName = memberNameTwoArg;
+ }
+ else if (attribute.ConstructorArguments.Length == 1 &&
+ attribute.ConstructorArguments[0].Value is string memberNameOneArg)
+ {
+ // Single-argument form: [ExpressiveFor("Name")] — target defaults to stub's containing type
+ MemberName = memberNameOneArg;
+ }
}
// Extract named arguments
diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ExpressivePropertiesNotMappedConventionTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ExpressivePropertiesNotMappedConventionTests.cs
new file mode 100644
index 00000000..3b6bf272
--- /dev/null
+++ b/tests/ExpressiveSharp.EntityFrameworkCore.IntegrationTests/Tests/Sqlite/ExpressivePropertiesNotMappedConventionTests.cs
@@ -0,0 +1,151 @@
+using ExpressiveSharp.Mapping;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ExpressiveSharp.EntityFrameworkCore.IntegrationTests.Tests.Sqlite;
+
+///
+/// Verifies the ignores properties that
+/// would otherwise be mapped to columns when they have no backing database value —
+/// either via [Expressive] directly, or as the target of an [ExpressiveFor] stub.
+///
+[TestClass]
+public class ExpressivePropertiesNotMappedConventionTests
+{
+ private SqliteConnection _connection = null!;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+ }
+
+ [TestCleanup]
+ public void Cleanup() => _connection.Dispose();
+
+ private DbContextOptions CreateOptions() where TContext : DbContext
+ => new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .UseExpressives()
+ .Options;
+
+ [TestMethod]
+ public void PropertyWithExpressiveAttribute_IsIgnored()
+ {
+ using var ctx = new NotMappedTestContext(CreateOptions());
+
+ var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!;
+
+ // The [Expressive] property must not appear as a mapped property.
+ Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.DoubledValue)),
+ "[Expressive] properties should be ignored by the convention.");
+
+ // Baseline: regular scalar properties should still be mapped.
+ Assert.IsNotNull(entity.FindProperty(nameof(NotMappedItem.Value)));
+ }
+
+ [TestMethod]
+ public void PropertyTargetedByExpressiveFor_IsIgnored()
+ {
+ using var ctx = new NotMappedTestContext(CreateOptions());
+
+ var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!;
+
+ // The property `DescribedValue` is targeted by an [ExpressiveFor] stub
+ // declared on the same class (single-arg + instance-stub form).
+ Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.DescribedValue)),
+ "Properties targeted by [ExpressiveFor] should be ignored by the convention.");
+ }
+
+ [TestMethod]
+ public void PropertyStubIsIgnored()
+ {
+ using var ctx = new NotMappedTestContext(CreateOptions());
+
+ var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!;
+
+ // A property decorated with [ExpressiveFor] is itself a stub — it must not be mapped
+ // as a column (otherwise EF would try to materialize/persist its value).
+ Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.DescribedValueExpression)),
+ "Property stubs carrying [ExpressiveFor] should be ignored by the convention.");
+ }
+
+ [TestMethod]
+ public void PropertyTargetedByExternalExpressiveFor_IsIgnored()
+ {
+ using var ctx = new NotMappedTestContext(CreateOptions());
+
+ var entity = ctx.Model.FindEntityType(typeof(NotMappedItem))!;
+
+ // `ExternalDescribedValue` is targeted by a stub in an unrelated static class —
+ // the cross-assembly-style mapping that was already supported for [Expressive].
+ Assert.IsNull(entity.FindProperty(nameof(NotMappedItem.ExternalDescribedValue)),
+ "Properties targeted by [ExpressiveFor] stubs in other classes should also be ignored.");
+ }
+
+ [TestMethod]
+ public async Task QueryReferencingIgnoredProperty_UsesExpressiveFormulaNotColumn()
+ {
+ using var ctx = new NotMappedTestContext(CreateOptions());
+ ctx.Database.EnsureCreated();
+
+ ctx.Items.Add(new NotMappedItem { Id = 1, Value = 5 });
+ ctx.Items.Add(new NotMappedItem { Id = 2, Value = 7 });
+ await ctx.SaveChangesAsync();
+
+ // The formula for DoubledValue comes from the [Expressive] getter; no DB column.
+ var results = await ctx.Items
+ .OrderBy(x => x.Id)
+ .Select(x => new { x.Id, x.DoubledValue })
+ .ToListAsync();
+
+ Assert.AreEqual(2, results.Count);
+ Assert.AreEqual(10, results[0].DoubledValue);
+ Assert.AreEqual(14, results[1].DoubledValue);
+ }
+
+ // ── Test-local models ────────────────────────────────────────────────
+
+ public class NotMappedItem
+ {
+ public int Id { get; set; }
+ public int Value { get; set; }
+
+ /// Computed by [Expressive] — existing convention path.
+ [Expressive]
+ public int DoubledValue => Value * 2;
+
+ /// Targeted by a co-located property stub (new single-arg form).
+ public string DescribedValue { get; set; } = "";
+
+ [ExpressiveFor(nameof(DescribedValue))]
+ public string DescribedValueExpression => "value=" + Value;
+
+ /// Targeted by a stub declared in an external static class.
+ public string ExternalDescribedValue { get; set; } = "";
+ }
+
+ private static class NotMappedItemMappings
+ {
+ [ExpressiveFor(typeof(NotMappedItem), nameof(NotMappedItem.ExternalDescribedValue))]
+ static string ExternalDescribedValue(NotMappedItem item) => "ext=" + item.Value;
+ }
+
+ public class NotMappedTestContext : DbContext
+ {
+ public DbSet Items => Set();
+
+ public NotMappedTestContext(DbContextOptions options) : base(options) { }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Id).ValueGeneratedNever();
+ });
+ }
+ }
+}
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.InstanceStub_OnInstanceProperty_SameType.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.InstanceStub_OnInstanceProperty_SameType.verified.txt
new file mode 100644
index 00000000..acdb1770
--- /dev/null
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.InstanceStub_OnInstanceProperty_SameType.verified.txt
@@ -0,0 +1,24 @@
+//
+#nullable disable
+
+using ExpressiveSharp.Mapping;
+using Foo;
+
+namespace ExpressiveSharp.Generated
+{
+ static partial class Foo_MyType
+ {
+ // [ExpressiveFor(typeof(MyType), "FullName")]
+ // string FullNameExpr() => FirstName + " " + LastName;
+ static global::System.Linq.Expressions.Expression> FullName_Expression()
+ {
+ var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this");
+ var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("FirstName")); // FirstName
+ var expr_3 = global::System.Linq.Expressions.Expression.Constant(" ", typeof(string)); // " "
+ var expr_1 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_2, expr_3);
+ var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("LastName")); // LastName
+ var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_1, expr_4);
+ return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this);
+ }
+ }
+}
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.PropertyStub_InstanceProperty_SameType.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.PropertyStub_InstanceProperty_SameType.verified.txt
new file mode 100644
index 00000000..41835078
--- /dev/null
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.PropertyStub_InstanceProperty_SameType.verified.txt
@@ -0,0 +1,24 @@
+//
+#nullable disable
+
+using ExpressiveSharp.Mapping;
+using Foo;
+
+namespace ExpressiveSharp.Generated
+{
+ static partial class Foo_MyType
+ {
+ // [ExpressiveFor(nameof(FullName))]
+ // private string FullNameExpression => FirstName + " " + LastName;
+ static global::System.Linq.Expressions.Expression> FullName_Expression()
+ {
+ var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this");
+ var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("FirstName")); // FirstName
+ var expr_3 = global::System.Linq.Expressions.Expression.Constant(" ", typeof(string)); // " "
+ var expr_1 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_2, expr_3);
+ var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("LastName")); // LastName
+ var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_1, expr_4);
+ return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this);
+ }
+ }
+}
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_DefaultsToContainingType.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_DefaultsToContainingType.verified.txt
new file mode 100644
index 00000000..bcb109f1
--- /dev/null
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_DefaultsToContainingType.verified.txt
@@ -0,0 +1,24 @@
+//
+#nullable disable
+
+using ExpressiveSharp.Mapping;
+using Foo;
+
+namespace ExpressiveSharp.Generated
+{
+ static partial class Foo_MyType
+ {
+ // [ExpressiveFor(nameof(FullName))]
+ // string FullNameExpr() => FirstName + " " + LastName;
+ static global::System.Linq.Expressions.Expression> FullName_Expression()
+ {
+ var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this");
+ var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("FirstName")); // FirstName
+ var expr_3 = global::System.Linq.Expressions.Expression.Constant(" ", typeof(string)); // " "
+ var expr_1 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_2, expr_3);
+ var expr_4 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("LastName")); // LastName
+ var expr_0 = global::System.Linq.Expressions.Expression.Call(typeof(string).GetMethod("Concat", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Static, null, new global::System.Type[] { typeof(string), typeof(string) }, null), expr_1, expr_4);
+ return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this);
+ }
+ }
+}
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_InstanceMethodTarget.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_InstanceMethodTarget.verified.txt
new file mode 100644
index 00000000..9bfaefcd
--- /dev/null
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.SingleArgForm_InstanceMethodTarget.verified.txt
@@ -0,0 +1,24 @@
+//
+#nullable disable
+
+using ExpressiveSharp.Mapping;
+using Foo;
+
+namespace ExpressiveSharp.Generated
+{
+ static partial class Foo_MyType
+ {
+ // [ExpressiveFor(nameof(AddAndDouble))]
+ // int AddAndDoubleExpr(int x) => (Base + x) * 2;
+ static global::System.Linq.Expressions.Expression> AddAndDouble_P0_int_Expression()
+ {
+ var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.MyType), "@this");
+ var p_x = global::System.Linq.Expressions.Expression.Parameter(typeof(int), "x");
+ var expr_2 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.MyType).GetProperty("Base")); // Base
+ var expr_1 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Add, expr_2, p_x);
+ var expr_3 = global::System.Linq.Expressions.Expression.Constant(2, typeof(int)); // 2
+ var expr_0 = global::System.Linq.Expressions.Expression.MakeBinary(global::System.Linq.Expressions.ExpressionType.Multiply, expr_1, expr_3);
+ return global::System.Linq.Expressions.Expression.Lambda>(expr_0, p__this, p_x);
+ }
+ }
+}
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.StaticStub_StaticPropertyTarget_Match.verified.txt b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.StaticStub_StaticPropertyTarget_Match.verified.txt
new file mode 100644
index 00000000..9a8cb77a
--- /dev/null
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.StaticStub_StaticPropertyTarget_Match.verified.txt
@@ -0,0 +1,19 @@
+//
+#nullable disable
+
+using ExpressiveSharp.Mapping;
+using Foo;
+
+namespace ExpressiveSharp.Generated
+{
+ static partial class Foo_MyType
+ {
+ // [ExpressiveFor(typeof(MyType), nameof(MyType.DefaultValue))]
+ // static int DefaultValue() => 99;
+ static global::System.Linq.Expressions.Expression> DefaultValue_Expression()
+ {
+ var expr_0 = global::System.Linq.Expressions.Expression.Constant(99, typeof(int)); // 99
+ return global::System.Linq.Expressions.Expression.Lambda>(expr_0, global::System.Array.Empty());
+ }
+ }
+}
diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs
index 26081b24..0d3f936e 100644
--- a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs
+++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressiveForTests.cs
@@ -195,8 +195,10 @@ static class Mappings {
}
[TestMethod]
- public void StubNotStatic_EXP0016()
+ public void InstanceStubOnUnrelatedType_Rejected_EXP0015()
{
+ // Instance stub targeting System.Math.Abs — stub's containing type is `Mappings`,
+ // which does not match `System.Math`, so no member should be found.
var compilation = CreateCompilation(
"""
using ExpressiveSharp.Mapping;
@@ -211,7 +213,113 @@ class Mappings {
var result = RunExpressiveGenerator(compilation);
Assert.AreEqual(1, result.Diagnostics.Length);
- Assert.AreEqual("EXP0016", result.Diagnostics[0].Id);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public Task InstanceStub_OnInstanceProperty_SameType()
+ {
+ // Instance stub on the target type — `this` is the receiver.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string FullName => FirstName + " " + LastName;
+
+ [ExpressiveFor(typeof(MyType), "FullName")]
+ string FullNameExpr() => FirstName + " " + LastName;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(0, result.Diagnostics.Length);
+ Assert.AreEqual(1, result.GeneratedTrees.Length);
+
+ return Verifier.Verify(result.GeneratedTrees[0].ToString());
+ }
+
+ [TestMethod]
+ public Task SingleArgForm_DefaultsToContainingType()
+ {
+ // [ExpressiveFor(nameof(X))] without typeof — target defaults to the stub's containing type.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string FullName => FirstName + " " + LastName;
+
+ [ExpressiveFor(nameof(FullName))]
+ string FullNameExpr() => FirstName + " " + LastName;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(0, result.Diagnostics.Length);
+ Assert.AreEqual(1, result.GeneratedTrees.Length);
+
+ return Verifier.Verify(result.GeneratedTrees[0].ToString());
+ }
+
+ [TestMethod]
+ public Task PropertyStub_InstanceProperty_SameType()
+ {
+ // [ExpressiveFor] on an expression-bodied PROPERTY — cleanest same-type form.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string FullName { get; set; }
+
+ [ExpressiveFor(nameof(FullName))]
+ private string FullNameExpression => FirstName + " " + LastName;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(0, result.Diagnostics.Length);
+ Assert.AreEqual(1, result.GeneratedTrees.Length);
+
+ return Verifier.Verify(result.GeneratedTrees[0].ToString());
+ }
+
+ [TestMethod]
+ public Task SingleArgForm_InstanceMethodTarget()
+ {
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public int Base { get; set; }
+ public int AddAndDouble(int x) => (Base + x) * 2;
+
+ [ExpressiveFor(nameof(AddAndDouble))]
+ int AddAndDoubleExpr(int x) => (Base + x) * 2;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(0, result.Diagnostics.Length);
+ Assert.AreEqual(1, result.GeneratedTrees.Length);
+
+ return Verifier.Verify(result.GeneratedTrees[0].ToString());
}
[TestMethod]
@@ -287,4 +395,233 @@ static class Mappings2 {
var exp0020 = result.Diagnostics.Where(d => d.Id == "EXP0020").ToArray();
Assert.AreEqual(2, exp0020.Length);
}
+
+ // ── Signature-matrix coverage ──────────────────────────────────────────
+ //
+ // Each test below exercises one specific branch of
+ // ExpressiveForSignatureMatcher (method/property, stub kind, static/instance).
+ // Happy-path acceptance is covered by the snapshot tests above; this block
+ // focuses on the rejection branches (param-count, receiver-type, containing-type,
+ // param-type mismatches) so every matrix cell has a test.
+
+ [TestMethod]
+ public Task StaticStub_StaticPropertyTarget_Match()
+ {
+ // Method stub → static property, both parameterless. Exercises
+ // MatchesPropertyFromMethodStub static/static branch.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public static int DefaultValue => 42;
+ }
+
+ static class Mappings {
+ [ExpressiveFor(typeof(MyType), nameof(MyType.DefaultValue))]
+ static int DefaultValue() => 99;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(0, result.Diagnostics.Length);
+ Assert.AreEqual(1, result.GeneratedTrees.Length);
+
+ return Verifier.Verify(result.GeneratedTrees[0].ToString());
+ }
+
+ [TestMethod]
+ public void InstanceStub_StaticPropertyTarget_Rejected_EXP0015()
+ {
+ // Static property + instance property stub → never matches (no way to supply receiver).
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public static int DefaultValue => 42;
+
+ [ExpressiveFor(nameof(DefaultValue))]
+ int DefaultValueExpression => 99;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void PropertyStub_TargetingMethod_Rejected_EXP0015()
+ {
+ // Property stubs can only target properties — even if a matching method exists.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public int Value { get; set; }
+ public int Compute() => Value * 2;
+
+ [ExpressiveFor(nameof(Compute))]
+ int ComputeExpression => Value * 2;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void StaticStub_InstanceMethod_WrongReceiverType_Rejected_EXP0015()
+ {
+ // Static stub over instance method, but the explicit receiver param is the wrong type.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public int Value { get; set; }
+ public int Add(int x) => Value + x;
+ }
+
+ static class Mappings {
+ [ExpressiveFor(typeof(MyType), nameof(MyType.Add))]
+ static int Add(string wrongType, int x) => x;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void StaticStub_MethodTarget_WrongParamType_Rejected_EXP0015()
+ {
+ // Param count matches but a param type differs — hits the matcher's per-param loop.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ static class Mappings {
+ [ExpressiveFor(typeof(System.Math), nameof(System.Math.Abs))]
+ static int Abs(string s) => 0;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void StaticStub_InstanceMethod_ParamCountMismatch_Rejected_EXP0015()
+ {
+ // Instance method has 1 param; static stub provides [receiver] only (missing the arg).
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public int Value { get; set; }
+ public int Add(int x) => Value + x;
+ }
+
+ static class Mappings {
+ [ExpressiveFor(typeof(MyType), nameof(MyType.Add))]
+ static int Add(MyType receiver) => receiver.Value;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void PropertyStub_WithExplicitTargetType_WrongContainingType_Rejected_EXP0015()
+ {
+ // Property stub must be on the target type; [ExpressiveFor(typeof(Other))] on a stub
+ // whose containing type is not Other cannot supply a receiver from `this`.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class Other {
+ public string Name { get; set; }
+ }
+
+ class MyType {
+ [ExpressiveFor(typeof(Other), nameof(Other.Name))]
+ string NameExpression => "hardcoded";
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void InstanceStub_InstanceMethod_ParamCountMismatch_Rejected_EXP0015()
+ {
+ // Instance stub on target type, but arg count doesn't match the target method.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public int Value { get; set; }
+ public int Add(int x, int y) => Value + x + y;
+
+ [ExpressiveFor(nameof(Add))]
+ int AddExpression(int x) => Value + x;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
+
+ [TestMethod]
+ public void SingleArgForm_UnknownMember_Rejected_EXP0015()
+ {
+ // Single-arg form with a name that doesn't exist on the stub's containing type.
+ var compilation = CreateCompilation(
+ """
+ using ExpressiveSharp.Mapping;
+
+ namespace Foo {
+ class MyType {
+ public int Value { get; set; }
+
+ [ExpressiveFor("NoSuchMember")]
+ int NoSuchMemberExpression => Value;
+ }
+ }
+ """);
+ var result = RunExpressiveGenerator(compilation);
+
+ Assert.AreEqual(1, result.Diagnostics.Length);
+ Assert.AreEqual("EXP0015", result.Diagnostics[0].Id);
+ }
}
diff --git a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs
index 24a13df7..27e3fd09 100644
--- a/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs
+++ b/tests/ExpressiveSharp.IntegrationTests/Scenarios/Store/Models/DisplayFormatter.cs
@@ -4,10 +4,15 @@ namespace ExpressiveSharp.IntegrationTests.Scenarios.Store.Models;
///
/// Represents an external (e.g. third-party) class with instance
-/// methods and properties that test code cannot mark with
-/// [Expressive] directly.
-/// provides [ExpressiveFor] bodies so its members can be used inside
-/// LINQ expression trees.
+/// methods and properties. Demonstrates two [ExpressiveFor] styles in one model:
+///
+/// - The Wrap mapping uses the ergonomic co-located form — an instance stub
+/// with the single-argument attribute, where the stub's this is the receiver.
+/// - The Label mapping uses the original external form — a static stub in
+/// with the explicit receiver parameter.
+///
+/// Integration tests assert both paths produce identical runtime behavior, so regressions
+/// in either the new or the legacy form are caught end-to-end.
///
public class DisplayFormatter
{
@@ -25,18 +30,20 @@ public DisplayFormatter(string prefix, string suffix)
/// Label that combines the prefix/suffix — instance property.
public string Label => "[" + Prefix + "/" + Suffix + "]";
+
+ // Single-arg + instance-stub form: the target is Wrap on this type, and the stub's `this`
+ // is the receiver. Equivalent to [ExpressiveFor(typeof(DisplayFormatter), nameof(Wrap))]
+ // with a static stub taking `(DisplayFormatter, string)`.
+ [ExpressiveFor(nameof(Wrap))]
+ string WrapExpr(string value) => Prefix + value + Suffix;
}
///
-/// [ExpressiveFor] mappings for .
-/// Each mapping takes the target instance as its first parameter.
+/// [ExpressiveFor] mapping for using the original
+/// external-class form (static stub with explicit receiver parameter).
///
static class DisplayFormatterMappings
{
- [ExpressiveFor(typeof(DisplayFormatter), nameof(DisplayFormatter.Wrap))]
- static string Wrap(DisplayFormatter formatter, string value)
- => formatter.Prefix + value + formatter.Suffix;
-
[ExpressiveFor(typeof(DisplayFormatter), nameof(DisplayFormatter.Label))]
static string Label(DisplayFormatter formatter)
=> "[" + formatter.Prefix + "/" + formatter.Suffix + "]";