Skip to content

Commit 3d75888

Browse files
C#: Support open generic type parameters in typed captures (#7124)
Allow typed captures to match any instantiation of a generic type by declaring type parameters on the capture. The type parameters are added to the scaffold class declaration so Roslyn produces proper GenericTypeVariable-based type attribution, and the comparator resolves unsubstituted type parameters in the type hierarchy at match time (mirroring Java's maybeResolveParameters approach).
1 parent c4747d7 commit 3d75888

File tree

6 files changed

+914
-46
lines changed

6 files changed

+914
-46
lines changed

rewrite-csharp/csharp/OpenRewrite/CSharp/Template/Capture.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public interface ICapture
4242
bool IsVariadic { get; }
4343
int? MinCount { get; }
4444
int? MaxCount { get; }
45+
IReadOnlyList<string>? TypeParameters { get; }
4546

4647
/// <summary>
4748
/// Evaluate the single-node constraint (if any) against a candidate.
@@ -81,12 +82,14 @@ public sealed class Capture<T> : ICapture where T : J
8182
public int? MinCount => Variadic?.Min;
8283
public int? MaxCount => Variadic?.Max;
8384
public string? Type { get; }
85+
public IReadOnlyList<string>? TypeParameters { get; }
8486
internal CaptureKind Kind { get; }
8587
public Func<T, CaptureConstraintContext, bool>? Constraint { get; }
8688
public VariadicOptions<T>? Variadic { get; }
8789

8890
internal Capture(string name,
8991
string? type = null,
92+
IReadOnlyList<string>? typeParameters = null,
9093
CaptureKind kind = CaptureKind.Expression,
9194
Func<T, CaptureConstraintContext, bool>? constraint = null,
9295
VariadicOptions<T>? variadic = null)
@@ -98,6 +101,7 @@ internal Capture(string name,
98101

99102
Name = name;
100103
Type = type;
104+
TypeParameters = typeParameters;
101105
Kind = kind;
102106
Constraint = constraint;
103107
Variadic = variadic;
@@ -120,7 +124,7 @@ public bool EvaluateConstraint(object candidate, CaptureConstraintContext contex
120124
if (Type != null && candidate is Expression { Type: not null } expr)
121125
{
122126
// Prefer the Roslyn-resolved type from the pattern scaffold when available.
123-
// This handles generics (IDictionary<object, object> resolves to its FQN)
127+
// This handles generics (IDictionary<TKey, TValue> with GenericTypeVariable entries)
124128
// and primitives (int resolves to System.Int32) correctly without string parsing.
125129
var matched = context.PatternType != null
126130
? TypeUtils.IsAssignableTo(expr.Type, context.PatternType)
@@ -188,21 +192,29 @@ public static class Capture
188192
/// </para>
189193
/// </summary>
190194
public static Capture<T> Of<T>(string? name = null, string? type = null,
195+
IReadOnlyList<string>? typeParameters = null,
191196
Func<T, CaptureConstraintContext, bool>? constraint = null,
192197
VariadicOptions<T>? variadic = null) where T : J
193198
=> new(name ?? $"_capture_{Interlocked.Increment(ref _counter)}",
194-
type: type, constraint: constraint, variadic: variadic);
199+
type: type, typeParameters: typeParameters,
200+
constraint: constraint, variadic: variadic);
195201

196202
/// <summary>
197203
/// Create a capture for an expression-position node.
198204
/// When <paramref name="type"/> is specified, the template engine generates a typed
199205
/// field declaration in the scaffold preamble for type attribution.
206+
/// When <paramref name="typeParameters"/> is specified, the listed names are treated
207+
/// as generic type parameters on the scaffold class, allowing the capture to match
208+
/// any instantiation of the generic type. Each entry is either a bare name (unbounded)
209+
/// or <c>"Name : Bound1, Bound2"</c> (with constraints), following C# where-clause syntax.
200210
/// </summary>
201211
public static Capture<Expression> Expression(string? name = null, string? type = null,
212+
IReadOnlyList<string>? typeParameters = null,
202213
Func<Expression, CaptureConstraintContext, bool>? constraint = null,
203214
VariadicOptions<Expression>? variadic = null)
204215
=> new(name ?? $"_capture_{Interlocked.Increment(ref _counter)}",
205-
type: type, kind: CaptureKind.Expression, constraint: constraint,
216+
type: type, typeParameters: typeParameters,
217+
kind: CaptureKind.Expression, constraint: constraint,
206218
variadic: variadic);
207219

208220
/// <summary>

rewrite-csharp/csharp/OpenRewrite/CSharp/Template/TemplateEngine.cs

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ internal static J Parse(string code, IReadOnlyDictionary<string, object> capture
8383
IReadOnlyDictionary<string, string> dependencies, ScaffoldKind? scaffoldKind)
8484
{
8585
// Compute preamble first — it affects the scaffold shape and must be part of the cache key
86-
var preamble = BuildTypePreamble(captures);
86+
var preamble = BuildScaffoldPreamble(captures);
8787
var cacheKey = BuildCacheKey(code, preamble, usings, context, dependencies, scaffoldKind);
8888
if (GlobalCache.TryGetValue(cacheKey, out var cached))
8989
return cached;
@@ -93,7 +93,7 @@ internal static J Parse(string code, IReadOnlyDictionary<string, object> capture
9393
return result;
9494
}
9595

96-
private static J ParseInternal(string code, IReadOnlyList<string> preamble,
96+
private static J ParseInternal(string code, ScaffoldPreamble preamble,
9797
IReadOnlyList<string> usings, IReadOnlyList<string> context,
9898
IReadOnlyDictionary<string, string> dependencies, ScaffoldKind? scaffoldKind)
9999
{
@@ -128,25 +128,74 @@ private static J ParseInternal(string code, IReadOnlyList<string> preamble,
128128
}
129129

130130
/// <summary>
131-
/// Build typed field declarations for captures that have a Type.
132-
/// These are emitted as class fields on the scaffold class so they are in scope
133-
/// inside the method body. This avoids mixing preamble statements with the template
134-
/// code, so <see cref="ExtractTemplateNode"/> doesn't need to skip anything.
135-
/// Dispatches on <see cref="CaptureKind"/> to generate the right scaffold form.
131+
/// Collects field declarations, type parameters, and where clauses from captures
132+
/// for scaffold generation.
136133
/// </summary>
137-
private static List<string> BuildTypePreamble(IReadOnlyDictionary<string, object> captures)
134+
private sealed record ScaffoldPreamble(
135+
IReadOnlyList<string> Fields,
136+
IReadOnlyList<string> TypeParameterNames,
137+
IReadOnlyList<string> WhereClauses);
138+
139+
/// <summary>
140+
/// Build the scaffold preamble from captures: field declarations for expression captures,
141+
/// type parameter names and where clauses from captures with <see cref="ICapture.TypeParameters"/>.
142+
/// </summary>
143+
private static ScaffoldPreamble BuildScaffoldPreamble(IReadOnlyDictionary<string, object> captures)
138144
{
139145
const System.Reflection.BindingFlags bindingFlags =
140146
System.Reflection.BindingFlags.Instance |
141147
System.Reflection.BindingFlags.Public |
142148
System.Reflection.BindingFlags.NonPublic;
143149

144-
var preamble = new List<string>();
150+
var fields = new List<string>();
151+
var typeParamNames = new List<string>();
152+
var whereClauses = new List<string>();
153+
// Track bounds per type parameter name for conflict detection
154+
var typeParamBounds = new Dictionary<string, string?>();
155+
145156
foreach (var kvp in captures)
146157
{
147158
var kind = kvp.Value.GetType().GetProperty("Kind", bindingFlags)?.GetValue(kvp.Value);
148159
var placeholder = Placeholder.ToPlaceholder(kvp.Key);
149160

161+
// Collect type parameters from captures that declare them
162+
if (kvp.Value is ICapture { TypeParameters: { } typeParams })
163+
{
164+
foreach (var tp in typeParams)
165+
{
166+
// Each entry is either "TName" (unbounded) or "TName : Bound1, Bound2"
167+
var colonIdx = tp.IndexOf(':');
168+
string name;
169+
string? bounds;
170+
if (colonIdx >= 0)
171+
{
172+
name = tp[..colonIdx].Trim();
173+
bounds = tp[(colonIdx + 1)..].Trim();
174+
}
175+
else
176+
{
177+
name = tp.Trim();
178+
bounds = null;
179+
}
180+
181+
if (typeParamBounds.TryGetValue(name, out var existingBounds))
182+
{
183+
// Same name already declared — check for conflicts
184+
if (!string.Equals(existingBounds, bounds, StringComparison.Ordinal))
185+
throw new InvalidOperationException(
186+
$"Conflicting bounds for type parameter '{name}': " +
187+
$"'{existingBounds ?? "(none)"}' vs '{bounds ?? "(none)"}'");
188+
}
189+
else
190+
{
191+
typeParamBounds[name] = bounds;
192+
typeParamNames.Add(name);
193+
if (bounds != null)
194+
whereClauses.Add($"where {name} : {bounds}");
195+
}
196+
}
197+
}
198+
150199
if (kind is CaptureKind captureKind)
151200
{
152201
switch (captureKind)
@@ -156,7 +205,7 @@ private static List<string> BuildTypePreamble(IReadOnlyDictionary<string, object
156205
// Always emit a field declaration for expression captures so Roslyn
157206
// knows the placeholder is a variable, not a type. Without this,
158207
// `__plh_x__ * __plh_y__` is misparsed as a pointer declaration.
159-
preamble.Add($"{(string.IsNullOrEmpty(captureType) ? "object" : captureType)} {placeholder};");
208+
fields.Add($"{(string.IsNullOrEmpty(captureType) ? "object" : captureType)} {placeholder};");
160209
break;
161210
case CaptureKind.Type:
162211
// TODO: emit scaffold that places placeholder in a type position
@@ -172,18 +221,18 @@ private static List<string> BuildTypePreamble(IReadOnlyDictionary<string, object
172221
var captureType = kvp.Value.GetType().GetProperty("Type")?.GetValue(kvp.Value) as string;
173222
if (!string.IsNullOrEmpty(captureType))
174223
{
175-
preamble.Add($"{captureType} {placeholder};");
224+
fields.Add($"{captureType} {placeholder};");
176225
}
177226
}
178227
}
179-
return preamble;
228+
return new ScaffoldPreamble(fields, typeParamNames, whereClauses);
180229
}
181230

182231
/// <summary>
183232
/// Build a parseable C# source from the template code.
184233
/// The scaffold shape is controlled by <paramref name="scaffoldKind"/>.
185234
/// </summary>
186-
private static string BuildScaffold(string code, IReadOnlyList<string> preamble,
235+
private static string BuildScaffold(string code, ScaffoldPreamble preamble,
187236
IReadOnlyList<string> usings, IReadOnlyList<string> context, ScaffoldKind? scaffoldKind)
188237
{
189238
var sb = new System.Text.StringBuilder();
@@ -201,10 +250,23 @@ private static string BuildScaffold(string code, IReadOnlyList<string> preamble,
201250
sb.AppendLine(c);
202251
}
203252

204-
sb.AppendLine("class __T__ {");
253+
// Emit class declaration with type parameters if any captures declare them
254+
sb.Append("class __T__");
255+
if (preamble.TypeParameterNames.Count > 0)
256+
{
257+
sb.Append('<');
258+
sb.Append(string.Join(", ", preamble.TypeParameterNames));
259+
sb.Append('>');
260+
}
261+
if (preamble.WhereClauses.Count > 0)
262+
{
263+
sb.Append(' ');
264+
sb.Append(string.Join(" ", preamble.WhereClauses));
265+
}
266+
sb.AppendLine(" {");
205267

206268
// Typed capture declarations as class fields — in scope for all scaffold kinds
207-
foreach (var decl in preamble)
269+
foreach (var decl in preamble.Fields)
208270
{
209271
sb.Append(" ");
210272
sb.AppendLine(decl);
@@ -575,7 +637,7 @@ private static Block AutoFormatSyntheticBlock(Block blk, CompilationUnit cu, J o
575637
return blk.WithStatements(formattedStmts).WithPrefix(preservedBlockPrefix);
576638
}
577639

578-
private static string BuildCacheKey(string code, IReadOnlyList<string> preamble,
640+
private static string BuildCacheKey(string code, ScaffoldPreamble preamble,
579641
IReadOnlyList<string> usings, IReadOnlyList<string> context,
580642
IReadOnlyDictionary<string, string> dependencies, ScaffoldKind? scaffoldKind = null)
581643
{
@@ -589,10 +651,22 @@ private static string BuildCacheKey(string code, IReadOnlyList<string> preamble,
589651
sb.Append("code:");
590652
sb.Append(code);
591653

592-
if (preamble.Count > 0)
654+
if (preamble.Fields.Count > 0)
593655
{
594656
sb.Append("|preamble:");
595-
sb.Append(string.Join(",", preamble));
657+
sb.Append(string.Join(",", preamble.Fields));
658+
}
659+
660+
if (preamble.TypeParameterNames.Count > 0)
661+
{
662+
sb.Append("|typeParams:");
663+
sb.Append(string.Join(",", preamble.TypeParameterNames));
664+
}
665+
666+
if (preamble.WhereClauses.Count > 0)
667+
{
668+
sb.Append("|where:");
669+
sb.Append(string.Join(",", preamble.WhereClauses));
596670
}
597671

598672
if (usings.Count > 0)

0 commit comments

Comments
 (0)