Skip to content

Commit f5fab77

Browse files
Add typed captures to C# template engine (#7057)
Captures can now declare a type (e.g., `Capture.Of<Expression>("s", type: "string")`) which causes the template engine to emit a typed class field in the scaffold. This gives placeholders proper type attribution from the parser, enabling patterns like `{s}.Length` and `{s}.ToUpper()` to parse and resolve correctly. The preamble is emitted as class fields rather than local variables so the method body contains only the template code — no counting or stripping needed.
1 parent abfe7bb commit f5fab77

3 files changed

Lines changed: 217 additions & 12 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,19 @@ public sealed class Capture<T> where T : J
3131
public bool IsVariadic { get; }
3232
public int? MinCount { get; }
3333
public int? MaxCount { get; }
34+
public string? Type { get; }
3435
public Func<T, Cursor, bool>? Constraint { get; }
3536

3637
internal Capture(string name, bool variadic = false,
3738
int? minCount = null, int? maxCount = null,
39+
string? type = null,
3840
Func<T, Cursor, bool>? constraint = null)
3941
{
4042
Name = name;
4143
IsVariadic = variadic;
4244
MinCount = minCount;
4345
MaxCount = maxCount;
46+
Type = type;
4447
Constraint = constraint;
4548
}
4649

@@ -61,9 +64,12 @@ public static class Capture
6164

6265
/// <summary>
6366
/// Create a capture that matches a single AST node of type <typeparamref name="T"/>.
67+
/// When <paramref name="type"/> is specified, the template engine generates a typed
68+
/// variable declaration in the scaffold preamble, giving the placeholder proper type
69+
/// attribution from the parser.
6470
/// </summary>
65-
public static Capture<T> Of<T>(string? name = null) where T : J
66-
=> new(name ?? $"_capture_{Interlocked.Increment(ref _counter)}");
71+
public static Capture<T> Of<T>(string? name = null, string? type = null) where T : J
72+
=> new(name ?? $"_capture_{Interlocked.Increment(ref _counter)}", type: type);
6773

6874
/// <summary>
6975
/// Create a variadic capture that matches zero or more elements.

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

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,31 +37,53 @@ internal static class TemplateEngine
3737
internal static J Parse(string code, IReadOnlyDictionary<string, object> captures,
3838
IReadOnlyList<string> usings, IReadOnlyList<string> context)
3939
{
40-
var cacheKey = BuildCacheKey(code, usings, context);
40+
var cacheKey = BuildCacheKey(code, captures, usings, context);
4141
if (GlobalCache.TryGetValue(cacheKey, out var cached))
4242
return cached;
4343

44-
var result = ParseInternal(code, usings, context);
44+
var result = ParseInternal(code, captures, usings, context);
4545
GlobalCache.TryAdd(cacheKey, result);
4646
return result;
4747
}
4848

49-
private static J ParseInternal(string code, IReadOnlyList<string> usings,
50-
IReadOnlyList<string> context)
49+
private static J ParseInternal(string code, IReadOnlyDictionary<string, object> captures,
50+
IReadOnlyList<string> usings, IReadOnlyList<string> context)
5151
{
52-
var scaffold = BuildScaffold(code, usings, context);
52+
var preamble = BuildTypePreamble(captures);
53+
var scaffold = BuildScaffold(code, preamble, usings, context);
5354
var parser = new CSharpParser();
5455
var cu = parser.Parse(scaffold, "__template__.cs");
5556

5657
return ExtractTemplateNode(cu, code);
5758
}
5859

60+
/// <summary>
61+
/// Build typed field declarations for captures that have a Type.
62+
/// These are emitted as class fields on the scaffold class so they are in scope
63+
/// inside the method body. This avoids mixing preamble statements with the template
64+
/// code, so <see cref="ExtractTemplateNode"/> doesn't need to skip anything.
65+
/// </summary>
66+
private static List<string> BuildTypePreamble(IReadOnlyDictionary<string, object> captures)
67+
{
68+
var preamble = new List<string>();
69+
foreach (var kvp in captures)
70+
{
71+
var captureType = kvp.Value.GetType().GetProperty("Type")?.GetValue(kvp.Value) as string;
72+
if (!string.IsNullOrEmpty(captureType))
73+
{
74+
preamble.Add($"{captureType} {Placeholder.ToPlaceholder(kvp.Key)};");
75+
}
76+
}
77+
return preamble;
78+
}
79+
5980
/// <summary>
6081
/// Build a parseable C# source from the template code.
61-
/// Wraps the template code in: usings + class __T__ { void __M__() { code; } }
82+
/// Typed capture declarations are emitted as class fields (before the method),
83+
/// so the method body contains only the template code.
6284
/// </summary>
63-
private static string BuildScaffold(string code, IReadOnlyList<string> usings,
64-
IReadOnlyList<string> context)
85+
private static string BuildScaffold(string code, IReadOnlyList<string> preamble,
86+
IReadOnlyList<string> usings, IReadOnlyList<string> context)
6587
{
6688
var sb = new System.Text.StringBuilder();
6789

@@ -79,6 +101,14 @@ private static string BuildScaffold(string code, IReadOnlyList<string> usings,
79101
}
80102

81103
sb.AppendLine("class __T__ {");
104+
105+
// Typed capture declarations as class fields — in scope inside the method
106+
foreach (var decl in preamble)
107+
{
108+
sb.Append(" ");
109+
sb.AppendLine(decl);
110+
}
111+
82112
sb.AppendLine(" void __M__() {");
83113
sb.Append(" ");
84114
sb.Append(code);
@@ -212,12 +242,24 @@ internal static J AutoFormat(J tree, Cursor cursor)
212242
return RoslynFormatter.FormatSubtree(cu, original.Id, tree, stopAfter: null);
213243
}
214244

215-
private static string BuildCacheKey(string code, IReadOnlyList<string> usings,
216-
IReadOnlyList<string> context)
245+
private static string BuildCacheKey(string code, IReadOnlyDictionary<string, object> captures,
246+
IReadOnlyList<string> usings, IReadOnlyList<string> context)
217247
{
218248
var sb = new System.Text.StringBuilder();
219249
sb.Append("code:");
220250
sb.Append(code);
251+
252+
// Include capture types in cache key so typed and untyped patterns
253+
// with the same code don't collide
254+
foreach (var kvp in captures.OrderBy(c => c.Key))
255+
{
256+
var captureType = kvp.Value.GetType().GetProperty("Type")?.GetValue(kvp.Value) as string;
257+
if (!string.IsNullOrEmpty(captureType))
258+
{
259+
sb.Append($"|type:{kvp.Key}={captureType}");
260+
}
261+
}
262+
221263
if (usings.Count > 0)
222264
{
223265
sb.Append("|usings:");
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
using OpenRewrite.Core;
17+
using OpenRewrite.CSharp;
18+
using OpenRewrite.CSharp.Template;
19+
using OpenRewrite.Java;
20+
using OpenRewrite.Test;
21+
using ExecutionContext = OpenRewrite.Core.ExecutionContext;
22+
23+
namespace OpenRewrite.Tests.Template;
24+
25+
public class TypedCaptureTests : RewriteTest
26+
{
27+
[Fact]
28+
public void TypedCaptureStoresType()
29+
{
30+
var s = Capture.Of<Expression>("s", type: "string");
31+
Assert.Equal("string", s.Type);
32+
}
33+
34+
[Fact]
35+
public void UntypedCaptureHasNullType()
36+
{
37+
var s = Capture.Of<Expression>("s");
38+
Assert.Null(s.Type);
39+
}
40+
41+
[Fact]
42+
public void TypedCaptureProducesCorrectPlaceholder()
43+
{
44+
var s = Capture.Of<Expression>("s", type: "string");
45+
Assert.Equal("__plh_s__", s.ToString());
46+
}
47+
48+
[Fact]
49+
public void TypedCaptureEnablesMemberAccess()
50+
{
51+
// With a typed capture, `{s}.Length` should parse correctly because
52+
// the parser knows __plh_s__ is a string and .Length is a valid member
53+
var s = Capture.Of<Expression>("s", type: "string");
54+
var pat = CSharpPattern.Create($"{s}.Length");
55+
var tree = pat.GetTree();
56+
Assert.IsType<FieldAccess>(tree);
57+
}
58+
59+
[Fact]
60+
public void TypedCaptureEnablesMethodCall()
61+
{
62+
// With a typed capture, `{s}.ToUpper()` should parse correctly
63+
var s = Capture.Of<Expression>("s", type: "string");
64+
var pat = CSharpPattern.Create($"{s}.ToUpper()");
65+
var tree = pat.GetTree();
66+
Assert.IsType<MethodInvocation>(tree);
67+
}
68+
69+
[Fact]
70+
public void TypedCapturePatternMatchesMemberAccess()
71+
{
72+
var s = Capture.Of<Expression>("s", type: "string");
73+
RewriteRun(
74+
spec => spec.SetRecipe(FindExpression($"{s}.Length")),
75+
CSharp(
76+
"class C { void M() { string x = \"hello\"; var n = x.Length; } }",
77+
"class C { void M() { string x = \"hello\"; var n = /*~~>*/x.Length; } }"
78+
)
79+
);
80+
}
81+
82+
[Fact]
83+
public void TypedCapturePatternMatchesMethodCall()
84+
{
85+
var s = Capture.Of<Expression>("s", type: "string");
86+
RewriteRun(
87+
spec => spec.SetRecipe(FindExpression($"{s}.ToUpper()")),
88+
CSharp(
89+
"class C { void M() { string x = \"hello\"; var y = x.ToUpper(); } }",
90+
"class C { void M() { string x = \"hello\"; var y = /*~~>*/x.ToUpper(); } }"
91+
)
92+
);
93+
}
94+
95+
[Fact]
96+
public void TypedCaptureWorksAlongsideUntypedCapture()
97+
{
98+
var s = Capture.Of<Expression>("s", type: "string");
99+
var n = Capture.Of<Expression>("n");
100+
RewriteRun(
101+
spec => spec.SetRecipe(FindExpression($"{s}.Substring({n})")),
102+
CSharp(
103+
"class C { void M() { string x = \"hello\"; var y = x.Substring(1); } }",
104+
"class C { void M() { string x = \"hello\"; var y = /*~~>*/x.Substring(1); } }"
105+
)
106+
);
107+
}
108+
109+
[Fact]
110+
public void TypedCaptureWithIntType()
111+
{
112+
var n = Capture.Of<Expression>("n", type: "int");
113+
var pat = CSharpPattern.Create($"{n}.ToString()");
114+
var tree = pat.GetTree();
115+
Assert.IsType<MethodInvocation>(tree);
116+
}
117+
118+
[Fact]
119+
public void MultipleTypedCaptures()
120+
{
121+
var s = Capture.Of<Expression>("s", type: "string");
122+
var n = Capture.Of<Expression>("n", type: "int");
123+
var pat = CSharpPattern.Create($"{s}.Substring({n})");
124+
var tree = pat.GetTree();
125+
Assert.IsType<MethodInvocation>(tree);
126+
}
127+
128+
// ===============================================================
129+
// Recipe factory
130+
// ===============================================================
131+
132+
private static Core.Recipe FindExpression(TemplateStringHandler handler)
133+
=> new TypedPatternSearchRecipe(CSharpPattern.Create(handler));
134+
135+
private static Core.Recipe FindExpression(string code)
136+
=> new TypedPatternSearchRecipe(CSharpPattern.Create(code));
137+
}
138+
139+
file class TypedPatternSearchRecipe(CSharpPattern pat) : Core.Recipe
140+
{
141+
public override string DisplayName => "Find expression";
142+
public override string Description => "Searches for expressions matching the pattern.";
143+
144+
public override JavaVisitor<ExecutionContext> GetVisitor() => new SearchVisitor(pat);
145+
146+
private class SearchVisitor(CSharpPattern pat) : CSharpVisitor<ExecutionContext>
147+
{
148+
public override J? PreVisit(J tree, ExecutionContext ctx)
149+
{
150+
if (tree is Expression e)
151+
{
152+
return pat.Find(e, Cursor);
153+
}
154+
return tree;
155+
}
156+
}
157+
}

0 commit comments

Comments
 (0)