Skip to content

Commit 245ce8b

Browse files
authored
Merge pull request #36 from EFNext/fix/issue-35
Support nullable and value type projectables
2 parents 5066cea + 1f47402 commit 245ce8b

11 files changed

Lines changed: 1569 additions & 112 deletions
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
using System.Collections.Generic;
2+
using System.Collections.Immutable;
3+
using System.Composition;
4+
using System.Linq;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CodeActions;
9+
using Microsoft.CodeAnalysis.CodeFixes;
10+
using Microsoft.CodeAnalysis.CSharp;
11+
using Microsoft.CodeAnalysis.CSharp.Syntax;
12+
using Microsoft.CodeAnalysis.Formatting;
13+
14+
namespace ExpressiveSharp.CodeFixers;
15+
16+
/// <summary>
17+
/// Code fix for EXP0024: the <c>??</c> projectable pattern cannot be applied to a nullable
18+
/// property type. Rewrites the getter and setter to the ternary <c>_hasFoo ? field : formula</c>
19+
/// pattern and inserts a private <c>bool _hasFoo</c> flag field into the containing type.
20+
/// </summary>
21+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ConvertProjectableToTernaryCodeFixProvider))]
22+
[Shared]
23+
public sealed class ConvertProjectableToTernaryCodeFixProvider : CodeFixProvider
24+
{
25+
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create("EXP0024");
26+
27+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
28+
29+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
30+
{
31+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
32+
if (root is null)
33+
return;
34+
35+
foreach (var diagnostic in context.Diagnostics)
36+
{
37+
var node = root.FindNode(diagnostic.Location.SourceSpan);
38+
var property = node.FirstAncestorOrSelf<PropertyDeclarationSyntax>();
39+
if (property is null)
40+
continue;
41+
42+
if (!TryExtractCoalesceParts(property, out _, out _, out _, out _))
43+
continue;
44+
45+
context.RegisterCodeFix(
46+
CodeAction.Create(
47+
title: "Convert to ternary 'has-value flag' pattern",
48+
createChangedDocument: ct => ApplyFixAsync(context.Document, property, ct),
49+
equivalenceKey: "EXP0024_ConvertToTernary"),
50+
diagnostic);
51+
}
52+
}
53+
54+
private static async Task<Document> ApplyFixAsync(
55+
Document document,
56+
PropertyDeclarationSyntax property,
57+
CancellationToken cancellationToken)
58+
{
59+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
60+
if (root is null)
61+
return document;
62+
63+
if (!TryExtractCoalesceParts(property, out var getAccessor, out var setAccessor, out var backingFieldRef, out var formula))
64+
return document;
65+
66+
var containingType = property.FirstAncestorOrSelf<TypeDeclarationSyntax>();
67+
if (containingType is null)
68+
return document;
69+
70+
var flagName = await ResolveUniqueFlagNameAsync(document, containingType, property, cancellationToken).ConfigureAwait(false);
71+
72+
var flagIdent = SyntaxFactory.IdentifierName(flagName);
73+
74+
// Build the new get accessor: `flagName ? <backingFieldRef> : (<formula>)`.
75+
var ternary = SyntaxFactory.ConditionalExpression(
76+
flagIdent,
77+
backingFieldRef!,
78+
SyntaxFactory.ParenthesizedExpression(formula!));
79+
80+
var newGetAccessor = getAccessor!
81+
.WithBody(null)
82+
.WithExpressionBody(SyntaxFactory.ArrowExpressionClause(ternary))
83+
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));
84+
85+
// Build the new set/init accessor body: `{ _hasFoo = true; <backingFieldRef> = value; }`.
86+
var flagAssignment = SyntaxFactory.ExpressionStatement(
87+
SyntaxFactory.AssignmentExpression(
88+
SyntaxKind.SimpleAssignmentExpression,
89+
flagIdent,
90+
SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)));
91+
92+
var valueAssignment = SyntaxFactory.ExpressionStatement(
93+
SyntaxFactory.AssignmentExpression(
94+
SyntaxKind.SimpleAssignmentExpression,
95+
backingFieldRef!,
96+
SyntaxFactory.IdentifierName("value")));
97+
98+
var newSetAccessor = setAccessor!
99+
.WithExpressionBody(null)
100+
.WithSemicolonToken(default)
101+
.WithBody(SyntaxFactory.Block(flagAssignment, valueAssignment));
102+
103+
// Replace accessors inside the property's accessor list.
104+
var accessorList = property.AccessorList!;
105+
var newAccessorList = accessorList.ReplaceNodes(
106+
new AccessorDeclarationSyntax[] { getAccessor, setAccessor },
107+
(original, _) => ReferenceEquals(original, getAccessor) ? (SyntaxNode)newGetAccessor : newSetAccessor);
108+
109+
var newProperty = property.WithAccessorList((AccessorListSyntax)newAccessorList);
110+
111+
// Build the flag field declaration: `private bool _hasFoo;`.
112+
var flagField = SyntaxFactory.FieldDeclaration(
113+
SyntaxFactory.VariableDeclaration(
114+
SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)),
115+
SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator(flagName))))
116+
.WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PrivateKeyword)))
117+
.WithAdditionalAnnotations(Formatter.Annotation);
118+
119+
// Insert the flag field immediately before the property declaration so related state
120+
// stays visually grouped.
121+
var members = containingType.Members;
122+
var propertyIndex = members.IndexOf(property);
123+
var newMembers = members
124+
.RemoveAt(propertyIndex)
125+
.Insert(propertyIndex, newProperty)
126+
.Insert(propertyIndex, flagField);
127+
128+
var newContainingType = containingType.WithMembers(newMembers);
129+
130+
var newRoot = root.ReplaceNode(containingType, newContainingType);
131+
return document.WithSyntaxRoot(newRoot);
132+
}
133+
134+
/// <summary>
135+
/// Picks a flag-field name that doesn't collide with an existing member on the containing
136+
/// type (including members declared in other <c>partial</c> declarations). The happy path
137+
/// returns <c>_has&lt;PropertyName&gt;</c>; on collision a numeric suffix is appended.
138+
/// </summary>
139+
private static async Task<string> ResolveUniqueFlagNameAsync(
140+
Document document,
141+
TypeDeclarationSyntax containingType,
142+
PropertyDeclarationSyntax property,
143+
CancellationToken cancellationToken)
144+
{
145+
var baseName = $"_has{property.Identifier.Text}";
146+
147+
var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
148+
var typeSymbol = semanticModel?.GetDeclaredSymbol(containingType, cancellationToken);
149+
if (typeSymbol is null)
150+
return baseName;
151+
152+
var existing = new HashSet<string>(typeSymbol.GetMembers().Select(m => m.Name), System.StringComparer.Ordinal);
153+
if (!existing.Contains(baseName))
154+
return baseName;
155+
156+
var suffix = 1;
157+
string candidate;
158+
do
159+
{
160+
candidate = $"{baseName}{suffix++}";
161+
}
162+
while (existing.Contains(candidate));
163+
return candidate;
164+
}
165+
166+
/// <summary>
167+
/// Inspects the property syntax for the Coalesce-shape Projectable pattern and extracts the
168+
/// get accessor, set/init accessor, the backing field reference (the left operand of
169+
/// <c>??</c>), and the formula (the right operand). Returns <c>false</c> if the pattern
170+
/// doesn't match — in which case the fix is not offered.
171+
/// </summary>
172+
private static bool TryExtractCoalesceParts(
173+
PropertyDeclarationSyntax property,
174+
out AccessorDeclarationSyntax? getAccessor,
175+
out AccessorDeclarationSyntax? setAccessor,
176+
out ExpressionSyntax? backingFieldRef,
177+
out ExpressionSyntax? formula)
178+
{
179+
getAccessor = null;
180+
setAccessor = null;
181+
backingFieldRef = null;
182+
formula = null;
183+
184+
if (property.AccessorList is null)
185+
return false;
186+
187+
foreach (var accessor in property.AccessorList.Accessors)
188+
{
189+
if (accessor.IsKind(SyntaxKind.GetAccessorDeclaration))
190+
getAccessor = accessor;
191+
else if (accessor.IsKind(SyntaxKind.InitAccessorDeclaration) || accessor.IsKind(SyntaxKind.SetAccessorDeclaration))
192+
setAccessor = accessor;
193+
}
194+
195+
if (getAccessor is null || setAccessor is null)
196+
return false;
197+
198+
if (!TryGetSingleExpression(getAccessor, out var getBody))
199+
return false;
200+
201+
// getBody must be a coalesce `left ?? right`.
202+
if (getBody is not BinaryExpressionSyntax { RawKind: (int)SyntaxKind.CoalesceExpression } coalesce)
203+
return false;
204+
205+
// Left must be either the C# 14 `field` keyword (parsed as FieldExpressionSyntax) or a
206+
// manually-declared backing field identifier. The generator's recognizer already
207+
// validated the symbol, so we only need to match the syntax shape here.
208+
if (!IsBackingFieldReference(coalesce.Left))
209+
return false;
210+
211+
if (!TryGetSingleAssignmentValue(setAccessor, out var setAssignment))
212+
return false;
213+
214+
if (!IsBackingFieldReference(setAssignment.Left)
215+
|| !BackingFieldReferencesMatch(coalesce.Left, setAssignment.Left))
216+
return false;
217+
218+
backingFieldRef = coalesce.Left;
219+
formula = UnwrapParentheses(coalesce.Right);
220+
return true;
221+
}
222+
223+
private static bool IsBackingFieldReference(ExpressionSyntax expression) =>
224+
expression is IdentifierNameSyntax
225+
|| expression.IsKind(SyntaxKind.FieldExpression);
226+
227+
private static bool BackingFieldReferencesMatch(ExpressionSyntax a, ExpressionSyntax b)
228+
{
229+
// Both `field` keyword references are always equivalent; for identifiers compare text.
230+
if (a.IsKind(SyntaxKind.FieldExpression) && b.IsKind(SyntaxKind.FieldExpression))
231+
return true;
232+
if (a is IdentifierNameSyntax ai && b is IdentifierNameSyntax bi)
233+
return ai.Identifier.Text == bi.Identifier.Text;
234+
return false;
235+
}
236+
237+
private static bool TryGetSingleExpression(AccessorDeclarationSyntax accessor, out ExpressionSyntax expression)
238+
{
239+
if (accessor.ExpressionBody is not null)
240+
{
241+
expression = accessor.ExpressionBody.Expression;
242+
return true;
243+
}
244+
245+
if (accessor.Body is { Statements: { Count: 1 } stmts }
246+
&& stmts[0] is ReturnStatementSyntax { Expression: { } ret })
247+
{
248+
expression = ret;
249+
return true;
250+
}
251+
252+
expression = null!;
253+
return false;
254+
}
255+
256+
private static bool TryGetSingleAssignmentValue(AccessorDeclarationSyntax accessor, out AssignmentExpressionSyntax assignment)
257+
{
258+
if (accessor.ExpressionBody is { Expression: AssignmentExpressionSyntax a1 })
259+
{
260+
assignment = a1;
261+
return true;
262+
}
263+
264+
if (accessor.Body is { Statements: { Count: 1 } stmts }
265+
&& stmts[0] is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax a2 })
266+
{
267+
assignment = a2;
268+
return true;
269+
}
270+
271+
assignment = null!;
272+
return false;
273+
}
274+
275+
private static ExpressionSyntax UnwrapParentheses(ExpressionSyntax expression)
276+
{
277+
while (expression is ParenthesizedExpressionSyntax paren)
278+
expression = paren.Expression;
279+
return expression;
280+
}
281+
}

src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,23 +180,23 @@ static internal class Diagnostics
180180
public readonly static DiagnosticDescriptor ProjectableGetAccessorPattern = new DiagnosticDescriptor(
181181
id: "EXP0022",
182182
title: "Projectable get accessor pattern",
183-
messageFormat: "The get accessor of a Projectable property must be of the form '=> field ?? (<formula>)' or '=> _backingField ?? (<formula>)' where _backingField is a private nullable field on the same type. Found: {0}.",
183+
messageFormat: "The get accessor of a Projectable property must be of the form '=> field ?? (<formula>)', '=> _backingField ?? (<formula>)', or '=> _hasValueFlag ? field : (<formula>)' where _backingField is a private non-static instance field on the same type and _hasValueFlag is a private non-static non-readonly instance bool field on the same type. Found: {0}.",
184184
category: "Design",
185185
DiagnosticSeverity.Error,
186186
isEnabledByDefault: true);
187187

188188
public readonly static DiagnosticDescriptor ProjectableSetterMustStoreToBackingField = new DiagnosticDescriptor(
189189
id: "EXP0023",
190190
title: "Projectable setter must store to backing field",
191-
messageFormat: "The init/set accessor of a Projectable property must store the incoming value into the same backing field referenced by the get accessor. Found: {0}.",
191+
messageFormat: "The init/set accessor of a Projectable property must store the incoming value into the same backing field referenced by the get accessor (and, for the ternary form, also set the has-value flag to true). Found: {0}.",
192192
category: "Design",
193193
DiagnosticSeverity.Error,
194194
isEnabledByDefault: true);
195195

196196
public readonly static DiagnosticDescriptor ProjectableRequiresNonNullablePropertyType = new DiagnosticDescriptor(
197197
id: "EXP0024",
198-
title: "Projectable requires non-nullable property type",
199-
messageFormat: "[Expressive(Projectable = true)] cannot be applied to a property with a nullable type ('{0}'). Nullable types prevent distinguishing 'not materialized' from 'materialized to null'.",
198+
title: "Projectable coalesce pattern requires non-nullable property type",
199+
messageFormat: "The '??' projectable pattern cannot be applied to a property with a nullable type ('{0}') because null cannot distinguish 'not materialized' from 'materialized to null'. Use the ternary form '=> _hasValue ? field : (<formula>)' (or '=> _hasValue ? _backingField : (<formula>)' with a manual backing field) with a private bool flag instead.",
200200
category: "Design",
201201
DiagnosticSeverity.Error,
202202
isEnabledByDefault: true);
@@ -234,4 +234,12 @@ static internal class Diagnostics
234234
category: "Design",
235235
DiagnosticSeverity.Error,
236236
isEnabledByDefault: true);
237+
238+
public readonly static DiagnosticDescriptor ProjectableInconsistentGetSetBacking = new DiagnosticDescriptor(
239+
id: "EXP0030",
240+
title: "Projectable getter and setter reference inconsistent backing storage",
241+
messageFormat: "The init/set accessor of a Projectable property must reference the same backing field (and has-value flag, for the ternary form) as the get accessor: {0}",
242+
category: "Design",
243+
DiagnosticSeverity.Error,
244+
isEnabledByDefault: true);
237245
}

src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,9 @@ private static bool TryApplyProjectablePropertyBody(
189189
return false;
190190
}
191191

192-
// EXP0024: must not be nullable. Rejects both `string?` and `int?`/`Nullable<int>`.
193-
if (IsNullablePropertyType(propertySymbol.Type))
194-
{
195-
context.ReportDiagnostic(Diagnostic.Create(
196-
Diagnostics.ProjectableRequiresNonNullablePropertyType, propertyLocation,
197-
propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
198-
return false;
199-
}
192+
// EXP0024 is now deferred until after pattern recognition: it applies only to the
193+
// Coalesce shape, because the Ternary shape's has-value flag independently distinguishes
194+
// "not materialized" from "materialized to null" and thus supports nullable property types.
200195

201196
// EXP0026: incompatible with the `required` modifier.
202197
if (propertySymbol.IsRequired)
@@ -255,13 +250,23 @@ private static bool TryApplyProjectablePropertyBody(
255250

256251
if (!ProjectablePatternRecognizer.TryRecognizeGetPattern(
257252
propertySymbol, getOperation, context, getAccessor.GetLocation(),
258-
out var backingField, out var formulaOperation)
259-
|| backingField is null
260-
|| formulaOperation is null)
253+
out var getResult))
261254
{
262255
return false;
263256
}
264257

258+
// EXP0024: the Coalesce shape requires a non-nullable property type, because `null` in the
259+
// backing field is the "not materialized" sentinel. The Ternary shape does not have this
260+
// constraint — its has-value flag carries the sentinel independently.
261+
if (getResult.Shape == ProjectablePatternRecognizer.ProjectableGetShape.Coalesce
262+
&& IsNullablePropertyType(propertySymbol.Type))
263+
{
264+
context.ReportDiagnostic(Diagnostic.Create(
265+
Diagnostics.ProjectableRequiresNonNullablePropertyType, propertyLocation,
266+
propertySymbol.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
267+
return false;
268+
}
269+
265270
// ── Step 4: Validate the set/init accessor pattern ──────────────────
266271

267272
var setOperation = semanticModel.GetOperation(setAccessor);
@@ -274,18 +279,19 @@ private static bool TryApplyProjectablePropertyBody(
274279
}
275280

276281
if (!ProjectablePatternRecognizer.ValidateSetterPattern(
277-
setOperation, backingField, context, setAccessor.GetLocation()))
282+
setOperation, getResult, context, setAccessor.GetLocation()))
278283
{
279284
return false;
280285
}
281286

282287
// ── Step 5: Feed the formula through the regular emission pipeline ──
283288

284-
// The formula's Syntax node is the right operand of the `??` coalesce. Passing it to
285-
// EmitExpressionTreeForProperty produces a LambdaExpression factory whose registry key
286-
// is the property's getter handle — exactly what ExpressiveReplacer.VisitMember looks up
287-
// at runtime.
288-
var formulaSyntax = formulaOperation.Syntax;
289+
// The formula's Syntax node is the "not materialized" branch of the get accessor (the
290+
// right operand of `??` for Coalesce, or the else-branch of the ternary for Ternary).
291+
// Passing it to EmitExpressionTreeForProperty produces a LambdaExpression factory whose
292+
// registry key is the property's getter handle — exactly what ExpressiveReplacer.VisitMember
293+
// looks up at runtime.
294+
var formulaSyntax = getResult.Formula.Syntax;
289295
if (formulaSyntax is null)
290296
{
291297
context.ReportDiagnostic(Diagnostic.Create(

0 commit comments

Comments
 (0)