Skip to content

Commit 1f7e309

Browse files
C#: Roslyn-based AutoFormat via tree-walking reconciler (#6953)
* C# Roslyn-based AutoFormat via tree-walking reconciler Add auto-formatting support for C# LSTs using Roslyn's built-in formatter. The pipeline prints the LST to a string, formats it with Roslyn in-memory, parses back to an LST (without type attribution), then walks both trees in parallel to copy whitespace from the formatted tree to the original — preserving IDs, types, and markers. .NET side: - WhitespaceReconciler: reflection-based parallel tree walk with caching - RoslynFormatter: orchestrates print → format → parse → reconcile - FormatStyle: auto-detects indentation (tabs/spaces, size, line endings) - AutoFormatVisitor + AutoFormatExtensions: visitor entry points - AutoFormat/MaybeAutoFormat convenience methods on CSharpVisitor Java side: - CSharpAutoFormatService: no-op service preventing Java formatter on C# trees - Registered in Cs.CompilationUnit.service() * Test that well-formatted code returns referentially identical tree
1 parent a995651 commit 1f7e309

File tree

10 files changed

+1364
-0
lines changed

10 files changed

+1364
-0
lines changed

rewrite-csharp/csharp/OpenRewrite/CSharp/CSharpVisitor.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
using OpenRewrite.Core;
17+
using OpenRewrite.CSharp.Format;
1618
using OpenRewrite.Java;
1719

1820
namespace OpenRewrite.CSharp;
@@ -1613,4 +1615,29 @@ public virtual J VisitFunctionPointerType(FunctionPointerType functionPointerTyp
16131615
{
16141616
return functionPointerType;
16151617
}
1618+
1619+
/// <summary>
1620+
/// Auto-formats the given tree node using Roslyn within the enclosing compilation unit.
1621+
/// </summary>
1622+
protected T AutoFormat<T>(T tree, P p, Cursor cursor) where T : class, J
1623+
{
1624+
return tree.AutoFormat(cursor);
1625+
}
1626+
1627+
/// <summary>
1628+
/// Auto-formats the given tree node if it differs from the original (before).
1629+
/// </summary>
1630+
protected T MaybeAutoFormat<T>(T before, T after, P p, Cursor cursor) where T : class, J
1631+
{
1632+
return ReferenceEquals(before, after) ? after : AutoFormat(after, p, cursor);
1633+
}
1634+
1635+
/// <summary>
1636+
/// Auto-formats the given tree node if it differs from the original (before),
1637+
/// stopping after the specified node.
1638+
/// </summary>
1639+
protected T MaybeAutoFormat<T>(T before, T after, J? stopAfter, P p, Cursor cursor) where T : class, J
1640+
{
1641+
return ReferenceEquals(before, after) ? after : after.AutoFormat(cursor, stopAfter);
1642+
}
16161643
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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.Java;
18+
19+
namespace OpenRewrite.CSharp.Format;
20+
21+
/// <summary>
22+
/// Extension methods for auto-formatting C# LST nodes.
23+
/// </summary>
24+
public static class AutoFormatExtensions
25+
{
26+
/// <summary>
27+
/// Auto-formats this node using Roslyn within its enclosing compilation unit.
28+
/// </summary>
29+
public static T AutoFormat<T>(this T tree, Cursor cursor) where T : class, J
30+
{
31+
var visitor = new AutoFormatVisitor<int>();
32+
var result = visitor.Format(tree, cursor);
33+
return result as T ?? tree;
34+
}
35+
36+
/// <summary>
37+
/// Auto-formats this node using Roslyn, stopping after the specified node.
38+
/// </summary>
39+
public static T AutoFormat<T>(this T tree, Cursor cursor, J? stopAfter) where T : class, J
40+
{
41+
var visitor = new AutoFormatVisitor<int>(stopAfter);
42+
var result = visitor.Format(tree, cursor);
43+
return result as T ?? tree;
44+
}
45+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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.Java;
18+
19+
namespace OpenRewrite.CSharp.Format;
20+
21+
/// <summary>
22+
/// Visitor entry point for auto-formatting C# code using Roslyn.
23+
/// Delegates to <see cref="RoslynFormatter"/> for the actual formatting pipeline.
24+
/// </summary>
25+
public class AutoFormatVisitor<P> : CSharpVisitor<P>
26+
{
27+
private readonly J? _stopAfter;
28+
29+
public AutoFormatVisitor(J? stopAfter = null)
30+
{
31+
_stopAfter = stopAfter;
32+
}
33+
34+
public override J VisitCompilationUnit(CompilationUnit cu, P p)
35+
{
36+
return RoslynFormatter.Format(cu, targetSubtree: null, stopAfter: _stopAfter);
37+
}
38+
39+
/// <summary>
40+
/// Formats a subtree within its enclosing compilation unit.
41+
/// Returns the formatted subtree (not the entire CU).
42+
/// </summary>
43+
public J Format(J tree, Cursor cursor)
44+
{
45+
if (tree is CompilationUnit treeCu)
46+
return RoslynFormatter.Format(treeCu, targetSubtree: null, stopAfter: _stopAfter);
47+
48+
var cu = cursor.FirstEnclosing<CompilationUnit>();
49+
if (cu == null)
50+
return tree;
51+
52+
var formattedCu = RoslynFormatter.Format(cu, targetSubtree: tree, stopAfter: _stopAfter);
53+
54+
// Find the target subtree in the formatted CU by matching ID
55+
return FindById(formattedCu, tree.Id) ?? tree;
56+
}
57+
58+
private static J? FindById(J root, Guid targetId)
59+
{
60+
J? found = null;
61+
var finder = new IdFinder(targetId, f => found = f);
62+
finder.Visit(root, 0);
63+
return found;
64+
}
65+
66+
private class IdFinder(Guid targetId, Action<J> onFound) : CSharpVisitor<int>
67+
{
68+
protected override J? Accept(J tree, int p)
69+
{
70+
if (tree.Id == targetId)
71+
{
72+
onFound(tree);
73+
return tree;
74+
}
75+
return base.Accept(tree, p);
76+
}
77+
}
78+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
namespace OpenRewrite.CSharp.Format;
17+
18+
/// <summary>
19+
/// Auto-detected indentation style from source text.
20+
/// </summary>
21+
public sealed class FormatStyle
22+
{
23+
public bool UseTabs { get; }
24+
public int IndentationSize { get; }
25+
public string NewLine { get; }
26+
27+
public FormatStyle(bool useTabs, int indentationSize, string newLine)
28+
{
29+
UseTabs = useTabs;
30+
IndentationSize = indentationSize;
31+
NewLine = newLine;
32+
}
33+
34+
/// <summary>
35+
/// Detects indentation style from source text by analyzing leading whitespace.
36+
/// </summary>
37+
public static FormatStyle DetectStyle(string source)
38+
{
39+
var tabCount = 0;
40+
var spaceCount = 0;
41+
var indentSizes = new Dictionary<int, int>(); // size → count
42+
var newLine = "\n";
43+
44+
// Detect line ending
45+
if (source.Contains("\r\n"))
46+
newLine = "\r\n";
47+
48+
var lines = source.Split('\n');
49+
foreach (var rawLine in lines)
50+
{
51+
var line = rawLine.TrimEnd('\r');
52+
if (line.Length == 0) continue;
53+
54+
var leadingSpaces = 0;
55+
var leadingTabs = 0;
56+
foreach (var c in line)
57+
{
58+
if (c == ' ') leadingSpaces++;
59+
else if (c == '\t') leadingTabs++;
60+
else break;
61+
}
62+
63+
if (leadingTabs > 0)
64+
{
65+
tabCount++;
66+
}
67+
else if (leadingSpaces > 0)
68+
{
69+
spaceCount++;
70+
if (leadingSpaces <= 16)
71+
{
72+
indentSizes.TryGetValue(leadingSpaces, out var count);
73+
indentSizes[leadingSpaces] = count + 1;
74+
}
75+
}
76+
}
77+
78+
var useTabs = tabCount > spaceCount;
79+
var indentSize = DetectIndentSize(indentSizes);
80+
81+
return new FormatStyle(useTabs, indentSize, newLine);
82+
}
83+
84+
private static int DetectIndentSize(Dictionary<int, int> indentSizes)
85+
{
86+
if (indentSizes.Count == 0) return 4;
87+
88+
// Compute GCD of all observed indent sizes
89+
var gcd = 0;
90+
foreach (var size in indentSizes.Keys)
91+
{
92+
gcd = gcd == 0 ? size : Gcd(gcd, size);
93+
}
94+
95+
// Sanity check: GCD should be a reasonable indent size
96+
if (gcd >= 2 && gcd <= 8)
97+
return gcd;
98+
99+
// Fallback to 4
100+
return 4;
101+
}
102+
103+
private static int Gcd(int a, int b)
104+
{
105+
while (b != 0)
106+
{
107+
var t = b;
108+
b = a % b;
109+
a = t;
110+
}
111+
return a;
112+
}
113+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 Microsoft.CodeAnalysis;
17+
using Microsoft.CodeAnalysis.CSharp;
18+
using Microsoft.CodeAnalysis.Formatting;
19+
using OpenRewrite.Core;
20+
using OpenRewrite.Java;
21+
22+
namespace OpenRewrite.CSharp.Format;
23+
24+
/// <summary>
25+
/// Orchestrates the C# auto-formatting pipeline:
26+
/// print → Roslyn format → parse → reconcile whitespace.
27+
/// </summary>
28+
public static class RoslynFormatter
29+
{
30+
/// <summary>
31+
/// Formats the entire compilation unit.
32+
/// </summary>
33+
public static CompilationUnit Format(CompilationUnit cu)
34+
{
35+
return Format(cu, targetSubtree: null, stopAfter: null);
36+
}
37+
38+
/// <summary>
39+
/// Formats the compilation unit, optionally limiting to a subtree.
40+
/// </summary>
41+
public static CompilationUnit Format(CompilationUnit cu, J? targetSubtree, J? stopAfter)
42+
{
43+
// 1. Print to string
44+
var printer = new CSharpPrinter<int>();
45+
var source = printer.Print(cu);
46+
47+
// 2. Detect style
48+
var style = FormatStyle.DetectStyle(source);
49+
50+
// 3. Format with Roslyn
51+
var formattedSource = FormatWithRoslyn(source, style);
52+
53+
// 4. If formatting didn't change anything, return original
54+
if (string.Equals(source, formattedSource, StringComparison.Ordinal))
55+
return cu;
56+
57+
// 5. Parse formatted string back to LST (no type attribution)
58+
var parser = new CSharpParser();
59+
CompilationUnit formattedCu;
60+
try
61+
{
62+
formattedCu = parser.Parse(formattedSource, cu.SourcePath);
63+
}
64+
catch (Exception)
65+
{
66+
// If parsing fails (shouldn't happen with Roslyn), return original
67+
return cu;
68+
}
69+
70+
// 6. Reconcile whitespace
71+
var reconciler = new WhitespaceReconciler();
72+
var result = reconciler.Reconcile(cu, formattedCu, targetSubtree, stopAfter);
73+
74+
if (!reconciler.IsCompatible)
75+
return cu;
76+
77+
return result as CompilationUnit ?? cu;
78+
}
79+
80+
internal static string FormatWithRoslyn(string source, FormatStyle style)
81+
{
82+
var syntaxTree = CSharpSyntaxTree.ParseText(source);
83+
var root = syntaxTree.GetRoot();
84+
85+
using var workspace = new AdhocWorkspace();
86+
var options = workspace.Options
87+
.WithChangedOption(FormattingOptions.UseTabs, LanguageNames.CSharp, style.UseTabs)
88+
.WithChangedOption(FormattingOptions.IndentationSize, LanguageNames.CSharp, style.IndentationSize)
89+
.WithChangedOption(FormattingOptions.TabSize, LanguageNames.CSharp, style.IndentationSize)
90+
.WithChangedOption(FormattingOptions.NewLine, LanguageNames.CSharp, style.NewLine);
91+
92+
var formatted = Formatter.Format(root, workspace, options);
93+
return formatted.ToFullString();
94+
}
95+
}

0 commit comments

Comments
 (0)