Skip to content

Commit 9f9402f

Browse files
committed
Replace line-based directive interleaving with sentinel-based section assembly
The previous line-number-based approach stored absolute line positions in DirectiveLine.lineNumber and required all branches to have identical line counts. If a recipe added or removed lines within a branch, the printer produced corrupted output. Ghost comments (//DIRECTIVE:N) are now emitted in clean source in place of directive lines. These survive Roslyn parsing as whitespace text and act as positional sentinels. A DirectiveBoundaryInjector scans the parsed tree and attaches DirectiveBoundaryMarker to nodes adjacent to directive boundaries, giving recipes structural access to boundary positions. The printer splits each branch's output by the ghost comment pattern into sections, then assembles sections from the appropriate branch — allowing different branches to have different section sizes after recipe modifications.
1 parent 156d996 commit 9f9402f

7 files changed

Lines changed: 310 additions & 53 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,14 @@ private CompilationUnit ParseMulti(string source, string sourcePath,
4242
SemanticModel? semanticModel, HashSet<string> symbols)
4343
{
4444
var directiveLines = PreprocessorSourceTransformer.GetDirectivePositions(source);
45-
var permutations = PreprocessorSourceTransformer.GenerateUniquePermutations(source, symbols);
45+
46+
// Build mapping from line number to directive index for ghost comment emission
47+
var directiveLineToIndex = new Dictionary<int, int>();
48+
for (int idx = 0; idx < directiveLines.Count; idx++)
49+
directiveLineToIndex[directiveLines[idx].LineNumber] = idx;
50+
51+
var permutations = PreprocessorSourceTransformer.GenerateUniquePermutations(
52+
source, symbols, directiveLineToIndex);
4653
PreprocessorSourceTransformer.ComputeActiveBranchIndices(directiveLines, permutations);
4754

4855
var branches = new List<JRightPadded<CompilationUnit>>();
@@ -66,8 +73,11 @@ private CompilationUnit ParseMulti(string source, string sourcePath,
6673
cu = ParseSingle(cleanSource, sourcePath, null);
6774
}
6875

76+
// Convert ghost comments in whitespace to DirectiveBoundaryMarker markers
77+
cu = (CompilationUnit)DirectiveBoundaryInjector.Inject(cu);
78+
6979
// Add ConditionalBranchMarker to identify this as a branch
70-
cu = cu.WithMarkers(cu.Markers.Add(new ConditionalBranchMarker( Guid.NewGuid(), definedSymbols.ToList())));
80+
cu = cu.WithMarkers(cu.Markers.Add(new ConditionalBranchMarker(Guid.NewGuid(), definedSymbols.ToList())));
7181
branches.Add(new JRightPadded<CompilationUnit>(cu, Space.Empty, Markers.Empty));
7282
}
7383

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

Lines changed: 70 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.RegularExpressions;
12
using Rewrite.Core;
23
using Rewrite.Java;
34

@@ -2464,9 +2465,12 @@ protected void VisitSpace(Space space, PrintOutputCapture<P> p)
24642465

24652466
#region Preprocessor Directives
24662467

2468+
private static readonly Regex GhostCommentPattern = new(@"//DIRECTIVE:(\d+)\r?\n?", RegexOptions.Compiled);
2469+
24672470
public override J VisitConditionalDirective(ConditionalDirective cd, PrintOutputCapture<P> p)
24682471
{
2469-
// Print each branch to a separate buffer
2472+
// 1. Print each branch to a separate buffer.
2473+
// Ghost comments (//DIRECTIVE:N) in whitespace appear verbatim in the output.
24702474
var branchOutputs = new string[cd.Branches.Count];
24712475
for (int i = 0; i < cd.Branches.Count; i++)
24722476
{
@@ -2475,58 +2479,84 @@ public override J VisitConditionalDirective(ConditionalDirective cd, PrintOutput
24752479
branchOutputs[i] = capture.ToString();
24762480
}
24772481

2478-
// Split each into lines
2479-
var branchLines = new string[branchOutputs.Length][];
2480-
for (int i = 0; i < branchOutputs.Length; i++)
2482+
// 2. Split each branch output by ghost comment sentinels into sections.
2483+
// Each branch produces N+1 sections for N directives, with matching directive indices.
2484+
var branchSections = new List<string>[branchOutputs.Length];
2485+
var branchTrailingNewlines = new List<bool>[branchOutputs.Length];
2486+
int[]? directiveOrder = null;
2487+
2488+
for (int b = 0; b < branchOutputs.Length; b++)
24812489
{
2482-
branchLines[i] = branchOutputs[i].Split('\n');
2490+
var sections = new List<string>();
2491+
var trailingNewlines = new List<bool>();
2492+
var dirIndices = new List<int>();
2493+
var matches = GhostCommentPattern.Matches(branchOutputs[b]);
2494+
2495+
int lastEnd = 0;
2496+
foreach (Match m in matches)
2497+
{
2498+
sections.Add(branchOutputs[b][lastEnd..m.Index]);
2499+
dirIndices.Add(int.Parse(m.Groups[1].Value));
2500+
trailingNewlines.Add(m.Value.EndsWith('\n'));
2501+
lastEnd = m.Index + m.Length;
2502+
}
2503+
sections.Add(branchOutputs[b][lastEnd..]);
2504+
2505+
branchSections[b] = sections;
2506+
branchTrailingNewlines[b] = trailingNewlines;
2507+
directiveOrder ??= dirIndices.ToArray();
24832508
}
24842509

2485-
// Build directive line lookup
2486-
var directiveLookup = new Dictionary<int, DirectiveLine>();
2487-
foreach (var dl in cd.DirectiveLines)
2510+
// If no ghost comments found (shouldn't happen), fall back to primary branch output
2511+
if (directiveOrder == null || directiveOrder.Length == 0)
24882512
{
2489-
directiveLookup[dl.LineNumber] = dl;
2513+
p.Append(branchOutputs[0]);
2514+
return cd;
24902515
}
24912516

2492-
// Line-level interleaving using stack-based algorithm
2517+
// 3. Assemble output by interleaving sections with directive text.
2518+
// Stack tracks the active branch index (starts with primary branch).
24932519
var stack = new Stack<int>();
2494-
stack.Push(0); // start with primary branch
2495-
int totalLines = branchLines[0].Length;
2520+
stack.Push(0);
24962521

2497-
for (int lineNum = 0; lineNum < totalLines; lineNum++)
2522+
// Section before the first directive — always from primary branch
2523+
p.Append(branchSections[0][0]);
2524+
2525+
for (int d = 0; d < directiveOrder.Length; d++)
24982526
{
2499-
if (lineNum > 0) p.Append("\n");
2527+
int directiveIndex = directiveOrder[d];
2528+
var directive = cd.DirectiveLines[directiveIndex];
25002529

2501-
if (directiveLookup.TryGetValue(lineNum, out var directive))
2502-
{
2503-
// Emit directive text
2504-
p.Append(directive.Text);
2530+
// Emit original directive text (e.g., "#if DEBUG")
2531+
p.Append(directive.Text);
25052532

2506-
switch (directive.Kind)
2507-
{
2508-
case PreprocessorDirectiveKind.If:
2509-
stack.Push(directive.ActiveBranchIndex);
2510-
break;
2511-
case PreprocessorDirectiveKind.Elif:
2512-
case PreprocessorDirectiveKind.Else:
2513-
stack.Pop();
2514-
stack.Push(directive.ActiveBranchIndex);
2515-
break;
2516-
case PreprocessorDirectiveKind.Endif:
2517-
stack.Pop();
2518-
break;
2519-
}
2533+
// Restore the newline that the ghost comment occupied
2534+
if (branchTrailingNewlines[0][d])
2535+
p.Append('\n');
2536+
2537+
// Update the active branch stack
2538+
switch (directive.Kind)
2539+
{
2540+
case PreprocessorDirectiveKind.If:
2541+
stack.Push(directive.ActiveBranchIndex);
2542+
break;
2543+
case PreprocessorDirectiveKind.Elif:
2544+
case PreprocessorDirectiveKind.Else:
2545+
stack.Pop();
2546+
stack.Push(directive.ActiveBranchIndex);
2547+
break;
2548+
case PreprocessorDirectiveKind.Endif:
2549+
stack.Pop();
2550+
break;
25202551
}
2521-
else
2552+
2553+
// Emit next section from the active branch
2554+
int activeBranch = stack.Peek();
2555+
int sectionIndex = d + 1;
2556+
if (activeBranch < branchSections.Length &&
2557+
sectionIndex < branchSections[activeBranch].Count)
25222558
{
2523-
// Emit code from active branch
2524-
int activeBranch = stack.Peek();
2525-
if (activeBranch >= 0 && activeBranch < branchLines.Length &&
2526-
lineNum < branchLines[activeBranch].Length)
2527-
{
2528-
p.Append(branchLines[activeBranch][lineNum]);
2529-
}
2559+
p.Append(branchSections[activeBranch][sectionIndex]);
25302560
}
25312561
}
25322562

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,43 @@ public ConditionalBranchMarker RpcReceive(ConditionalBranchMarker before, RpcRec
10511051
public override int GetHashCode() => Id.GetHashCode();
10521052
}
10531053

1054+
/// <summary>
1055+
/// Marks a tree node adjacent to one or more conditional preprocessor directive boundaries.
1056+
/// Each directive index references a position in the <see cref="ConditionalDirective.DirectiveLines"/> list.
1057+
/// </summary>
1058+
public sealed class DirectiveBoundaryMarker(
1059+
Guid id,
1060+
IList<int> directiveIndices
1061+
) : Marker, IRpcCodec<DirectiveBoundaryMarker>, IEquatable<DirectiveBoundaryMarker>
1062+
{
1063+
public Guid Id { get; } = id;
1064+
public IList<int> DirectiveIndices { get; } = directiveIndices;
1065+
1066+
public DirectiveBoundaryMarker WithId(Guid id) =>
1067+
id == Id ? this : new(id, DirectiveIndices);
1068+
public DirectiveBoundaryMarker WithDirectiveIndices(IList<int> directiveIndices) =>
1069+
ReferenceEquals(directiveIndices, DirectiveIndices) ? this : new(Id, directiveIndices);
1070+
1071+
public void RpcSend(DirectiveBoundaryMarker after, RpcSendQueue q)
1072+
{
1073+
q.GetAndSend(after, m => m.Id);
1074+
q.GetAndSendList(after, m => m.DirectiveIndices, i => i.ToString(), i =>
1075+
q.GetAndSend(i, x => x.ToString()));
1076+
}
1077+
1078+
public DirectiveBoundaryMarker RpcReceive(DirectiveBoundaryMarker before, RpcReceiveQueue q)
1079+
{
1080+
return before
1081+
.WithId(q.ReceiveAndGet<Guid, string>(before.Id, Guid.Parse))
1082+
.WithDirectiveIndices(q.ReceiveList(before.DirectiveIndices, idx =>
1083+
q.ReceiveAndGet<int, string>(idx, int.Parse))!);
1084+
}
1085+
1086+
public bool Equals(DirectiveBoundaryMarker? other) => other is not null && Id == other.Id;
1087+
public override bool Equals(object? obj) => Equals(obj as DirectiveBoundaryMarker);
1088+
public override int GetHashCode() => Id.GetHashCode();
1089+
}
1090+
10541091
/// <summary>
10551092
/// A #pragma warning disable/restore directive.
10561093
/// </summary>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2024 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 System.Text.RegularExpressions;
17+
using Rewrite.Core;
18+
using Rewrite.Java;
19+
using static Rewrite.Java.J;
20+
21+
namespace Rewrite.CSharp;
22+
23+
/// <summary>
24+
/// Scans a parsed tree for ghost comments (//DIRECTIVE:N) embedded in Space whitespace strings
25+
/// and attaches <see cref="DirectiveBoundaryMarker"/> to the owning nodes.
26+
/// <para>
27+
/// Ghost comments are emitted by <see cref="PreprocessorSourceTransformer.Transform"/> in place
28+
/// of directive lines (e.g. #if, #else, #endif). Because the C# parser does not yet extract
29+
/// comments from trivia, these appear as raw text in Space.Whitespace rather than as TextComment
30+
/// entries in Space.Comments.
31+
/// </para>
32+
/// <para>
33+
/// Ghost comments are intentionally left in the whitespace strings so the printer can use them
34+
/// as sentinels for section-based assembly. The markers provide structured metadata for recipes.
35+
/// </para>
36+
/// </summary>
37+
public partial class DirectiveBoundaryInjector : CSharpVisitor<int>
38+
{
39+
[GeneratedRegex(@"//DIRECTIVE:(\d+)")]
40+
private static partial Regex GhostPattern();
41+
42+
/// <summary>
43+
/// Inject directive boundary markers into the given tree by scanning for ghost comments.
44+
/// Ghost comments remain in the whitespace for printer use as sentinels.
45+
/// </summary>
46+
public static J Inject(J tree)
47+
{
48+
return new DirectiveBoundaryInjector().VisitNonNull(tree, 0);
49+
}
50+
51+
public override J? PostVisit(J tree, int p)
52+
{
53+
return AddMarkerIfNeeded(tree, tree.Prefix.Whitespace);
54+
}
55+
56+
public override J VisitBlock(Block block, int p)
57+
{
58+
block = (Block)base.VisitBlock(block, p);
59+
60+
var indices = FindDirectiveIndices(block.End.Whitespace);
61+
if (indices.Count > 0)
62+
{
63+
var marker = new DirectiveBoundaryMarker(Guid.NewGuid(), indices);
64+
block = block.WithMarkers(block.Markers.Add(marker));
65+
}
66+
67+
return block;
68+
}
69+
70+
public override J VisitCompilationUnit(CompilationUnit compilationUnit, int p)
71+
{
72+
compilationUnit = (CompilationUnit)base.VisitCompilationUnit(compilationUnit, p);
73+
74+
var indices = FindDirectiveIndices(compilationUnit.Eof.Whitespace);
75+
if (indices.Count > 0)
76+
{
77+
var marker = new DirectiveBoundaryMarker(Guid.NewGuid(), indices);
78+
compilationUnit = compilationUnit.WithMarkers(compilationUnit.Markers.Add(marker));
79+
}
80+
81+
return compilationUnit;
82+
}
83+
84+
private J AddMarkerIfNeeded(J node, string whitespace)
85+
{
86+
var indices = FindDirectiveIndices(whitespace);
87+
if (indices.Count == 0)
88+
return node;
89+
90+
var marker = new DirectiveBoundaryMarker(Guid.NewGuid(), indices);
91+
var newMarkers = node.Markers.Add(marker);
92+
return SetMarkers(node, newMarkers);
93+
}
94+
95+
private static List<int> FindDirectiveIndices(string whitespace)
96+
{
97+
var indices = new List<int>();
98+
var matches = GhostPattern().Matches(whitespace);
99+
foreach (Match match in matches)
100+
{
101+
indices.Add(int.Parse(match.Groups[1].Value));
102+
}
103+
return indices;
104+
}
105+
106+
/// <summary>
107+
/// Uses reflection to call WithMarkers on any concrete J type,
108+
/// following the pattern established by <see cref="SearchResult.Found{T}"/>.
109+
/// </summary>
110+
private static J SetMarkers(J node, Markers markers)
111+
{
112+
var withMarkers = node.GetType().GetMethod("WithMarkers", [typeof(Markers)]);
113+
return withMarkers != null ? (J)withMarkers.Invoke(node, [markers])! : node;
114+
}
115+
}

0 commit comments

Comments
 (0)