Skip to content

Commit 6d62c4c

Browse files
committed
feat(generator): enhance synthesized property handling for nullable types and backing field naming
1 parent 6d23d8f commit 6d62c4c

7 files changed

Lines changed: 187 additions & 27 deletions

‎docs/guide/migration-from-projectables.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ Pick based on whether you want the generator to declare the target property for
143143
```csharp
144144
// Before (Projectables)
145145
[Projectable(UseMemberBody = nameof(FullNameProjection))]
146-
public partial string FullName { get; init; }
146+
public string FullName { get; init; }
147147
private string FullNameProjection => LastName + ", " + FirstName;
148148

149149
// After (ExpressiveSharp) -- partial class, stub only; FullName is generated

‎src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs‎

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ public static string BuildSource(SynthesizedPropertySpec spec)
4040
var baseIndent = hasNamespace ? " " : "";
4141
var nestingDepth = spec.ContainingTypePath.Count;
4242

43-
// Emit partial-class wrappers for any enclosing (nested) types, from outermost in.
44-
// The innermost is the type we attach the synthesized member to.
43+
// Emit partial wrappers for any enclosing (nested) types, from outermost in.
44+
// Each level uses its actual type keyword so a struct/record container doesn't get
45+
// emitted as `partial class`.
4546
for (var i = 0; i < nestingDepth; i++)
4647
{
4748
var indent = baseIndent + new string(' ', i * 4);
4849
var name = spec.ContainingTypePath[i];
49-
// Only the innermost knows its keyword; enclosing nests are "partial class" as a safe default.
50-
var keyword = i == nestingDepth - 1 ? spec.ContainingTypeKeyword : "class";
50+
var keyword = spec.ContainingTypeKeywords[i];
5151
sb.AppendLine($"{indent}partial {keyword} {name}");
5252
sb.AppendLine($"{indent}{{");
5353
}
@@ -72,43 +72,34 @@ public static string BuildSource(SynthesizedPropertySpec spec)
7272

7373
private static string BuildCoalesceBody(SynthesizedPropertySpec spec, string indent)
7474
{
75-
var fieldName = MakeBackingFieldName(spec.PropertyName);
7675
var stubCall = spec.StubIsMethod ? $"{spec.StubMemberName}()" : spec.StubMemberName;
7776

7877
var sb = new StringBuilder();
79-
sb.AppendLine($"{indent}private {spec.BackingFieldTypeFqn} {fieldName};");
78+
sb.AppendLine($"{indent}private {spec.BackingFieldTypeFqn} {spec.BackingFieldName};");
8079
sb.AppendLine($"{indent}public {spec.PropertyTypeFqn} {spec.PropertyName}");
8180
sb.AppendLine($"{indent}{{");
82-
sb.AppendLine($"{indent} get => {fieldName} ?? {stubCall};");
83-
sb.AppendLine($"{indent} init => {fieldName} = value;");
81+
sb.AppendLine($"{indent} get => {spec.BackingFieldName} ?? {stubCall};");
82+
sb.AppendLine($"{indent} init => {spec.BackingFieldName} = value;");
8483
sb.AppendLine($"{indent}}}");
8584
return sb.ToString();
8685
}
8786

8887
private static string BuildTernaryBody(SynthesizedPropertySpec spec, string indent)
8988
{
90-
var fieldName = MakeBackingFieldName(spec.PropertyName);
91-
var flagName = MakeHasValueFlagName(spec.PropertyName);
9289
var stubCall = spec.StubIsMethod ? $"{spec.StubMemberName}()" : spec.StubMemberName;
9390

9491
var sb = new StringBuilder();
95-
sb.AppendLine($"{indent}private {spec.BackingFieldTypeFqn} {fieldName};");
96-
sb.AppendLine($"{indent}private bool {flagName};");
92+
sb.AppendLine($"{indent}private {spec.BackingFieldTypeFqn} {spec.BackingFieldName};");
93+
sb.AppendLine($"{indent}private bool {spec.HasValueFlagName};");
9794
sb.AppendLine($"{indent}public {spec.PropertyTypeFqn} {spec.PropertyName}");
9895
sb.AppendLine($"{indent}{{");
99-
sb.AppendLine($"{indent} get => {flagName} ? {fieldName} : {stubCall};");
96+
sb.AppendLine($"{indent} get => {spec.HasValueFlagName} ? {spec.BackingFieldName} : {stubCall};");
10097
sb.AppendLine($"{indent} init");
10198
sb.AppendLine($"{indent} {{");
102-
sb.AppendLine($"{indent} {flagName} = true;");
103-
sb.AppendLine($"{indent} {fieldName} = value;");
99+
sb.AppendLine($"{indent} {spec.HasValueFlagName} = true;");
100+
sb.AppendLine($"{indent} {spec.BackingFieldName} = value;");
104101
sb.AppendLine($"{indent} }}");
105102
sb.AppendLine($"{indent}}}");
106103
return sb.ToString();
107104
}
108-
109-
private static string MakeBackingFieldName(string propertyName) =>
110-
"_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1);
111-
112-
private static string MakeHasValueFlagName(string propertyName) =>
113-
"_" + char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1) + "HasValue";
114105
}

‎src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ namespace ExpressiveSharp.Generator.Interpretation;
2323
/// </remarks>
2424
static internal class ExpressivePropertyInterpreter
2525
{
26+
private static readonly SymbolDisplayFormat FullyQualifiedNullableFormat =
27+
SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(
28+
SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions
29+
| SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
30+
2631
public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? GetDescriptor(
2732
SemanticModel semanticModel,
2833
PropertyDeclarationSyntax stubProperty,
@@ -107,8 +112,9 @@ public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? G
107112
// Synthesis spec for the partial-class emitter.
108113
var returnType = stubSymbol.Type;
109114
var useTernary = IsNullablePropertyType(returnType);
110-
var propertyTypeFqn = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
115+
var propertyTypeFqn = returnType.ToDisplayString(FullyQualifiedNullableFormat);
111116
var backingFieldTypeFqn = useTernary ? propertyTypeFqn : MakeNullableTypeFqn(returnType);
117+
var (backingFieldName, hasValueFlagName) = ChooseBackingNames(containingType, targetName!, useTernary);
112118

113119
var spec = new SynthesizedPropertySpec
114120
{
@@ -118,11 +124,14 @@ public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? G
118124
StubIsMethod = false, // rule 1 enforces property stub
119125
UseTernaryShape = useTernary,
120126
BackingFieldTypeFqn = backingFieldTypeFqn,
127+
BackingFieldName = backingFieldName,
128+
HasValueFlagName = hasValueFlagName,
121129
ContainingTypeName = containingType.Name,
122130
ContainingTypeNamespace = containingType.ContainingNamespace.IsGlobalNamespace
123131
? null
124132
: containingType.ContainingNamespace.ToDisplayString(),
125133
ContainingTypePath = GetNestedInClassPath(containingType).ToList(),
134+
ContainingTypeKeywords = GetNestedInClassKeywords(containingType).ToList(),
126135
ContainingTypeKeyword = GetTypeKeyword(containingType),
127136
};
128137

@@ -158,8 +167,9 @@ public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? G
158167

159168
var returnType = stubSymbol.Type;
160169
var useTernary = IsNullablePropertyType(returnType);
161-
var propertyTypeFqn = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
170+
var propertyTypeFqn = returnType.ToDisplayString(FullyQualifiedNullableFormat);
162171
var backingFieldTypeFqn = useTernary ? propertyTypeFqn : MakeNullableTypeFqn(returnType);
172+
var (backingFieldName, hasValueFlagName) = ChooseBackingNames(containingType, targetName!, useTernary);
163173

164174
return new SynthesizedPropertySpec
165175
{
@@ -169,11 +179,14 @@ public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? G
169179
StubIsMethod = false,
170180
UseTernaryShape = useTernary,
171181
BackingFieldTypeFqn = backingFieldTypeFqn,
182+
BackingFieldName = backingFieldName,
183+
HasValueFlagName = hasValueFlagName,
172184
ContainingTypeName = containingType.Name,
173185
ContainingTypeNamespace = containingType.ContainingNamespace.IsGlobalNamespace
174186
? null
175187
: containingType.ContainingNamespace.ToDisplayString(),
176188
ContainingTypePath = GetNestedInClassPath(containingType).ToList(),
189+
ContainingTypeKeywords = GetNestedInClassKeywords(containingType).ToList(),
177190
ContainingTypeKeyword = GetTypeKeyword(containingType),
178191
};
179192
}
@@ -299,6 +312,40 @@ private static IEnumerable<string> GetNestedInClassPath(ITypeSymbol typeSymbol)
299312
yield return typeSymbol.Name;
300313
}
301314

315+
private static IEnumerable<string> GetNestedInClassKeywords(INamedTypeSymbol typeSymbol)
316+
{
317+
if (typeSymbol.ContainingType is not null)
318+
{
319+
foreach (var keyword in GetNestedInClassKeywords(typeSymbol.ContainingType))
320+
yield return keyword;
321+
}
322+
yield return GetTypeKeyword(typeSymbol);
323+
}
324+
325+
private static (string BackingFieldName, string HasValueFlagName) ChooseBackingNames(
326+
INamedTypeSymbol containingType, string propertyName, bool useTernary)
327+
{
328+
var existing = new HashSet<string>(containingType.GetMembers().Select(m => m.Name));
329+
var camel = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1);
330+
var basePrefix = "_" + camel;
331+
332+
// Single suffix shared across the pair so collision-resolved names stay related
333+
// (`_foo2` / `_foo2HasValue`, never `_foo2` / `_foo3HasValue`).
334+
var suffix = 0;
335+
while (true)
336+
{
337+
var suffixStr = suffix == 0 ? "" : suffix.ToString(System.Globalization.CultureInfo.InvariantCulture);
338+
var fieldName = basePrefix + suffixStr;
339+
var flagName = basePrefix + suffixStr + "HasValue";
340+
var fieldOk = !existing.Contains(fieldName);
341+
var flagOk = !useTernary || !existing.Contains(flagName);
342+
if (fieldOk && flagOk)
343+
return (fieldName, useTernary ? flagName : "");
344+
// First retry uses suffix 2 (skip 1 — `_foo1` reads worse than `_foo2`).
345+
suffix = suffix == 0 ? 2 : suffix + 1;
346+
}
347+
}
348+
302349
private static INamedTypeSymbol? FindInheritedMember(INamedTypeSymbol type, string memberName)
303350
{
304351
// Walk the base chain — System.Object itself is not interesting for user-defined collisions

‎src/ExpressiveSharp.Generator/Models/SynthesizedPropertySpec.cs‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ internal sealed class SynthesizedPropertySpec
3737
/// <summary>Containing type path from outermost to target (for nested types).</summary>
3838
public IReadOnlyList<string> ContainingTypePath { get; set; } = System.Array.Empty<string>();
3939

40-
/// <summary>Keyword for the containing type declaration (<c>class</c>, <c>record</c>, <c>struct</c>, etc.).</summary>
40+
/// <summary>
41+
/// Type keyword (<c>class</c>, <c>struct</c>, <c>record</c>, <c>record struct</c>) for each entry in
42+
/// <see cref="ContainingTypePath"/>. Same length and ordering as <see cref="ContainingTypePath"/>.
43+
/// The last element equals <see cref="ContainingTypeKeyword"/>.
44+
/// </summary>
45+
public IReadOnlyList<string> ContainingTypeKeywords { get; set; } = System.Array.Empty<string>();
46+
47+
/// <summary>Keyword for the innermost containing type declaration (<c>class</c>, <c>record</c>, <c>struct</c>, etc.).</summary>
4148
public string ContainingTypeKeyword { get; set; } = "class";
49+
50+
/// <summary>
51+
/// Name of the backing field for the materialized value. Chosen at interpret time to avoid
52+
/// collisions with user-declared members on the containing type.
53+
/// </summary>
54+
public string BackingFieldName { get; set; } = "";
55+
56+
/// <summary>
57+
/// Name of the "has value" flag field (ternary shape only). Chosen at interpret time to
58+
/// avoid collisions with user-declared members on the containing type.
59+
/// </summary>
60+
public string HasValueFlagName { get; set; } = "";
4261
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// <auto-generated/>
2+
#nullable disable
3+
4+
using ExpressiveSharp.Mapping;
5+
using Foo;
6+
7+
namespace ExpressiveSharp.Generated
8+
{
9+
static partial class Foo_Outer_Inner
10+
{
11+
// [ExpressiveProperty("Name")]
12+
// private string NameExpression => FirstName;
13+
static global::System.Linq.Expressions.Expression<global::System.Func<global::Foo.Outer.Inner, string>> Name_Expression()
14+
{
15+
var p__this = global::System.Linq.Expressions.Expression.Parameter(typeof(global::Foo.Outer.Inner), "@this");
16+
var expr_0 = global::System.Linq.Expressions.Expression.Property(p__this, typeof(global::Foo.Outer.Inner).GetProperty("FirstName")); // FirstName
17+
return global::System.Linq.Expressions.Expression.Lambda<global::System.Func<global::Foo.Outer.Inner, string>>(expr_0, p__this);
18+
}
19+
}
20+
}
21+
22+
23+
// ===
24+
25+
// <auto-generated/>
26+
#nullable enable
27+
28+
namespace Foo
29+
{
30+
partial struct Outer
31+
{
32+
partial class Inner
33+
{
34+
private string? _name;
35+
public string Name
36+
{
37+
get => _name ?? NameExpression;
38+
init => _name = value;
39+
}
40+
}
41+
}
42+
}

‎tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.NullableReferenceTypeTarget_EmitsTernaryForm.verified.txt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ namespace Foo
4646
{
4747
partial class Account
4848
{
49-
private string _fullName;
49+
private string? _fullName;
5050
private bool _fullNameHasValue;
51-
public string FullName
51+
public string? FullName
5252
{
5353
get => _fullNameHasValue ? _fullName : FullNameExpression;
5454
init

‎tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExpressivePropertyTests.cs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,67 @@ partial struct Point {
184184
result.GeneratedTrees.Select(t => t.ToString())));
185185
}
186186

187+
[TestMethod]
188+
public Task BackingFieldNameCollision_AppendsSuffix()
189+
{
190+
// User type already declares `_fullName` — synthesized backing field must avoid the
191+
// collision (verified by a string contains assertion rather than full snapshot since
192+
// only the field name choice is the contract being tested).
193+
var compilation = CreateCompilation(
194+
"""
195+
using ExpressiveSharp.Mapping;
196+
197+
namespace Foo {
198+
partial class Account {
199+
private string? _fullName;
200+
public string FirstName { get; set; } = "";
201+
public string LastName { get; set; } = "";
202+
203+
[ExpressiveProperty("FullName")]
204+
private string FullNameExpression => LastName + ", " + FirstName;
205+
}
206+
}
207+
""");
208+
var result = RunExpressiveGenerator(compilation);
209+
210+
Assert.AreEqual(0, result.Diagnostics.Length);
211+
var generated = string.Join("\n", result.GeneratedTrees.Select(t => t.ToString()));
212+
StringAssert.Contains(generated, "private string? _fullName2;",
213+
"Backing field should be renamed to avoid collision with the user-declared _fullName field.");
214+
Assert.IsFalse(generated.Contains("private string? _fullName;\n public string FullName"),
215+
"Synthesized backing field must not be the colliding name `_fullName`.");
216+
return Task.CompletedTask;
217+
}
218+
219+
[TestMethod]
220+
public Task NestedInsideNonClassContainer_EmitsCorrectOuterKeyword()
221+
{
222+
// Partial class nested inside a partial struct — outer wrapper must be `partial struct`,
223+
// not the previous hard-coded `partial class` (which produced uncompilable output).
224+
var compilation = CreateCompilation(
225+
"""
226+
using ExpressiveSharp.Mapping;
227+
228+
namespace Foo {
229+
public partial struct Outer {
230+
public partial class Inner {
231+
public string FirstName { get; set; } = "";
232+
233+
[ExpressiveProperty("Name")]
234+
private string NameExpression => FirstName;
235+
}
236+
}
237+
}
238+
""");
239+
var result = RunExpressiveGenerator(compilation);
240+
241+
Assert.AreEqual(0, result.Diagnostics.Length);
242+
Assert.AreEqual(2, result.GeneratedTrees.Length);
243+
244+
return Verifier.Verify(string.Join("\n\n// ===\n\n",
245+
result.GeneratedTrees.Select(t => t.ToString())));
246+
}
247+
187248
// ── Diagnostic tests ────────────────────────────────────────────────────
188249

189250
[TestMethod]

0 commit comments

Comments
 (0)