Skip to content

Commit 71809c7

Browse files
authored
C#: Improve csproj manipulation and add reattestation (#7316)
* C#: Improve csproj manipulation and add reattestation Significantly enhance csproj handling for MSBuild projects, add reattestation. Refactor RPC and solution parsing to fix logical errors. Fix versioning issues and add proper variable handling in csproj files and central package management. Migrate NuGet dependency recipes (AddNuGetPackageReference, ChangeDotNetTargetFramework, FindNuGetPackageReference, RemoveNuGetPackageReference, UpgradeNuGetPackageVersion) with full test coverage, enabling existing recipes to handle more real-world project structures and upgrade scenarios. * C#: Fix CI failures and consolidate csproj parsing - Consolidate CsprojParser into single ParseAll code path that writes all files to a shared temp dir and runs dotnet restore, so MSBuild imports (Directory.Build.props/.targets) resolve correctly - Add TargetFramework to test csproj XML so dotnet restore succeeds - Recipe reads property references from XML (the "program") while marker provides MSBuild-resolved values (what the compiler sees) - Fix RPC ParseSolution to use XmlParser + CreateMarker directly since files are already on disk with restore done * Add license headers to XML grammar files * Remove C#-only recipes from recipes.csv These recipes run in .NET, not Java, so they can't be validated against the Java classpath. * Retry CI
1 parent 7767c5f commit 71809c7

File tree

70 files changed

+9193
-3104
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+9193
-3104
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
root = true
2+
3+
[*.cs]
4+
max_line_length = 150
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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.Xml;
18+
using ExecutionContext = OpenRewrite.Core.ExecutionContext;
19+
20+
namespace OpenRewrite.CSharp;
21+
22+
/// <summary>
23+
/// A reusable scanner visitor that captures build-related XML documents
24+
/// (csproj, props, targets, nuget.config, etc.) as raw LSTs into a
25+
/// <see cref="DotNetBuildContext"/> stored in the <see cref="ExecutionContext"/>.
26+
///
27+
/// Used automatically by <see cref="Recipes.CsProjRecipe"/> in its scan phase.
28+
/// Can also be composed into custom scanners for recipes that extend
29+
/// <see cref="ScanningRecipe{T}"/> directly.
30+
/// </summary>
31+
public class BuildContextScanner : XmlVisitor<ExecutionContext>
32+
{
33+
public override Xml.Xml VisitDocument(Document document, ExecutionContext ctx)
34+
{
35+
DotNetBuildContext.GetOrCreate(ctx).CaptureIfBuildFile(document);
36+
return document;
37+
}
38+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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.Xml;
18+
using Serilog;
19+
using ExecutionContext = OpenRewrite.Core.ExecutionContext;
20+
21+
namespace OpenRewrite.CSharp;
22+
23+
/// <summary>
24+
/// Captures the key build files from the repository so they can be
25+
/// materialized to disk during reattestation (dotnet restore).
26+
///
27+
/// Stores raw LST <see cref="Document"/> objects for files in the LST stream
28+
/// (printed only at materialization time) and raw string content for files
29+
/// captured from disk that are not part of the LST stream.
30+
///
31+
/// Populated in two ways:
32+
/// 1. From disk during ParseSolution (captures Directory.Build.props, .targets,
33+
/// nuget.config, and other files not in the LST stream)
34+
/// 2. From LSTs during the scan phase of <see cref="CsProjRecipe"/> implementations
35+
/// (captures the raw Document objects being visited)
36+
///
37+
/// Retrieved by <see cref="MSBuildProjectHelper"/> when regenerating MSBuildProject markers.
38+
/// </summary>
39+
public class DotNetBuildContext
40+
{
41+
private const string ContextKey = "org.openrewrite.csharp.DotNetBuildContext";
42+
43+
/// <summary>
44+
/// File extensions and names that are needed for a correct dotnet restore.
45+
/// </summary>
46+
private static readonly HashSet<string> BuildFileExtensions = new(StringComparer.OrdinalIgnoreCase)
47+
{
48+
".csproj", ".vbproj", ".fsproj",
49+
".sln", ".slnx",
50+
".props", ".targets"
51+
};
52+
53+
private static readonly HashSet<string> BuildFileNames = new(StringComparer.OrdinalIgnoreCase)
54+
{
55+
"nuget.config"
56+
};
57+
58+
/// <summary>
59+
/// LST documents captured during the scan phase. Printed at materialization time.
60+
/// </summary>
61+
public Dictionary<string, Document> Documents { get; } = new();
62+
63+
/// <summary>
64+
/// Raw string content for files captured from disk (not in the LST stream).
65+
/// </summary>
66+
private readonly Dictionary<string, string> _diskFiles = new();
67+
68+
private readonly object _lock = new();
69+
70+
/// <summary>
71+
/// Gets or creates the DotNetBuildContext from the ExecutionContext.
72+
/// </summary>
73+
public static DotNetBuildContext GetOrCreate(ExecutionContext ctx)
74+
{
75+
return ctx.ComputeMessageIfAbsent(ContextKey, _ => new DotNetBuildContext());
76+
}
77+
78+
/// <summary>
79+
/// Gets the DotNetBuildContext if one exists, or null.
80+
/// </summary>
81+
public static DotNetBuildContext? Get(ExecutionContext ctx)
82+
{
83+
return ctx.GetMessage<DotNetBuildContext>(ContextKey);
84+
}
85+
86+
/// <summary>
87+
/// Returns true if the given source path is a build-related file that should be captured.
88+
/// </summary>
89+
public static bool IsBuildFile(string sourcePath)
90+
{
91+
var fileName = Path.GetFileName(sourcePath);
92+
if (BuildFileNames.Contains(fileName))
93+
return true;
94+
95+
var ext = Path.GetExtension(sourcePath);
96+
return ext.Length > 0 && BuildFileExtensions.Contains(ext);
97+
}
98+
99+
/// <summary>
100+
/// Captures the raw LST Document if it is a build-related file.
101+
/// The document is stored as-is; printing is deferred to materialization.
102+
/// </summary>
103+
public void CaptureIfBuildFile(Document doc)
104+
{
105+
if (!IsBuildFile(doc.SourcePath))
106+
return;
107+
108+
lock (_lock)
109+
{
110+
Documents[doc.SourcePath] = doc;
111+
}
112+
}
113+
114+
/// <summary>
115+
/// Captures a file with explicit path and content (for files not in the LST stream).
116+
/// </summary>
117+
public void Capture(string relativePath, string content)
118+
{
119+
lock (_lock)
120+
{
121+
_diskFiles[relativePath] = content;
122+
}
123+
}
124+
125+
/// <summary>
126+
/// Scans a root directory for build-related files and captures their content.
127+
/// Call this during ParseSolution to capture files that may not appear in the LST stream
128+
/// (e.g., Directory.Build.props, Directory.Build.targets, nuget.config).
129+
/// </summary>
130+
/// <param name="rootDir">The repository root directory to scan.</param>
131+
public void CaptureFromDisk(string rootDir)
132+
{
133+
try
134+
{
135+
foreach (var file in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
136+
{
137+
// Skip common non-build directories
138+
var relativePath = Path.GetRelativePath(rootDir, file);
139+
if (relativePath.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
140+
relativePath.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") ||
141+
relativePath.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}") ||
142+
relativePath.Contains($"{Path.DirectorySeparatorChar}node_modules{Path.DirectorySeparatorChar}") ||
143+
relativePath.StartsWith($"bin{Path.DirectorySeparatorChar}") ||
144+
relativePath.StartsWith($"obj{Path.DirectorySeparatorChar}") ||
145+
relativePath.StartsWith($".git{Path.DirectorySeparatorChar}"))
146+
continue;
147+
148+
if (!IsBuildFile(file))
149+
continue;
150+
151+
try
152+
{
153+
var content = File.ReadAllText(file);
154+
// Normalize to forward slashes for consistency with SourcePath
155+
var normalizedPath = relativePath.Replace(Path.DirectorySeparatorChar, '/');
156+
lock (_lock)
157+
{
158+
_diskFiles.TryAdd(normalizedPath, content);
159+
}
160+
}
161+
catch (Exception ex)
162+
{
163+
Log.Debug("DotNetBuildContext: failed to read {Path}: {Error}", file, ex.Message);
164+
}
165+
}
166+
167+
Log.Debug("DotNetBuildContext: captured {Count} build files from {RootDir}", _diskFiles.Count, rootDir);
168+
}
169+
catch (Exception ex)
170+
{
171+
Log.Debug("DotNetBuildContext: failed to scan {RootDir}: {Error}", rootDir, ex.Message);
172+
}
173+
}
174+
175+
/// <summary>
176+
/// Stores this DotNetBuildContext into the given ExecutionContext.
177+
/// </summary>
178+
public void StoreIn(ExecutionContext ctx)
179+
{
180+
ctx.PutMessage(ContextKey, this);
181+
}
182+
183+
/// <summary>
184+
/// Writes all captured files to the given root directory, preserving
185+
/// their relative path structure. LST documents are printed at this point.
186+
/// </summary>
187+
/// <param name="rootDir">The directory to write files into.</param>
188+
/// <param name="exclude">Optional path to exclude (e.g., the .csproj already written by the caller).</param>
189+
public void MaterializeAll(string rootDir, string? exclude = null)
190+
{
191+
Dictionary<string, Document> lstFiles;
192+
Dictionary<string, string> diskFiles;
193+
lock (_lock)
194+
{
195+
lstFiles = new Dictionary<string, Document>(Documents);
196+
diskFiles = new Dictionary<string, string>(_diskFiles);
197+
}
198+
199+
// Collect all paths that have LST versions (they take precedence)
200+
var lstPaths = new HashSet<string>(lstFiles.Keys, StringComparer.OrdinalIgnoreCase);
201+
202+
// Write disk-captured files first (skipping any that have LST versions)
203+
foreach (var (relativePath, content) in diskFiles)
204+
{
205+
if (exclude != null && string.Equals(relativePath, exclude, StringComparison.OrdinalIgnoreCase))
206+
continue;
207+
if (lstPaths.Contains(relativePath))
208+
continue;
209+
210+
WriteFile(rootDir, relativePath, content);
211+
}
212+
213+
// Write LST-captured files (printed at materialization time)
214+
foreach (var (relativePath, doc) in lstFiles)
215+
{
216+
if (exclude != null && string.Equals(relativePath, exclude, StringComparison.OrdinalIgnoreCase))
217+
continue;
218+
219+
var content = XmlParser.Print(doc);
220+
WriteFile(rootDir, relativePath, content);
221+
}
222+
}
223+
224+
private static void WriteFile(string rootDir, string relativePath, string content)
225+
{
226+
var fullPath = Path.Combine(rootDir, relativePath);
227+
var dir = Path.GetDirectoryName(fullPath);
228+
if (dir != null)
229+
Directory.CreateDirectory(dir);
230+
231+
File.WriteAllText(fullPath, content);
232+
}
233+
}

0 commit comments

Comments
 (0)