Skip to content

Commit bd713ef

Browse files
authored
C#: csproj parsing and core dependency recipes (#7013)
* C#: csproj parsing and core dependency recipes * UpgradeNuGetPackageVersion: semver selectors, NuGet source discovery, and test improvements - Support semver version selectors (latest.release, ~, ^, etc.) via NuGetVersionResolver which queries NuGet V3 flat container APIs - Discover package sources from nuget.config files during csproj parsing (C# RPC side walks up from project dir to repo root) - Surface package sources through MSBuildProject marker and ParseSolutionResult - Handle property-based version references that resolve across files (e.g. Directory.Build.props defining version properties) - Convert CSharpParseProjectTest to use rewriteRun with csharp()/csproj() assertions instead of manual RPC calls where possible - Add comprehensive test coverage: glob patterns, version ranges, floating versions, central package management, conditional refs, child elements
1 parent 92268e8 commit bd713ef

24 files changed

Lines changed: 4663 additions & 2760 deletions

rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs

Lines changed: 342 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ public RewriteRpcServer(RecipeMarketplace marketplace)
181181
}
182182

183183
[JsonRpcMethod("ParseSolution", UseSingleObjectParameterDeserialization = true)]
184-
public async Task<List<ParseSolutionResponseItem>> ParseSolution(ParseSolutionRequest request)
184+
public async Task<ParseSolutionResponse> ParseSolution(ParseSolutionRequest request)
185185
{
186186
Log.Debug("RPC ParseSolution: received request path={Path} rootDir={RootDir}", request.Path, request.RootDir);
187187
var solutionParser = new SolutionParser();
@@ -190,7 +190,7 @@ public async Task<List<ParseSolutionResponseItem>> ParseSolution(ParseSolutionRe
190190

191191
var solution = await solutionParser.LoadAsync(path, CancellationToken.None);
192192

193-
var items = new List<ParseSolutionResponseItem>();
193+
var response = new ParseSolutionResponse();
194194
var seenProjects = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
195195
var projectList = solution.Projects.Where(p => p.FilePath != null).ToList();
196196
Log.Debug("RPC ParseSolution: {ProjectCount} projects to parse", projectList.Count);
@@ -221,17 +221,299 @@ public async Task<List<ParseSolutionResponseItem>> ParseSolution(ParseSolutionRe
221221
{
222222
var id = cu.Id.ToString();
223223
_localObjects[id] = cu;
224-
items.Add(new ParseSolutionResponseItem
224+
response.Items.Add(new ParseSolutionResponseItem
225225
{
226226
Id = id,
227227
SourceFileType = "org.openrewrite.csharp.tree.Cs$CompilationUnit",
228228
ProjectPath = project.FilePath!
229229
});
230230
}
231+
232+
// Extract MSBuild project metadata from .csproj
233+
try
234+
{
235+
var metadata = ExtractProjectMetadata(project.FilePath!, rootDir);
236+
response.Projects.Add(metadata);
237+
}
238+
catch (Exception ex)
239+
{
240+
Log.Debug("RPC ParseSolution: failed to extract metadata for {ProjectPath}: {ExType}: {ExMessage}",
241+
project.FilePath, ex.GetType().Name, ex.Message);
242+
}
243+
}
244+
245+
Log.Debug("RPC ParseSolution: completed, {ItemCount} compilation units, {ProjectCount} project metadata",
246+
response.Items.Count, response.Projects.Count);
247+
return response;
248+
}
249+
250+
/// <summary>
251+
/// Extracts MSBuild project metadata from a .csproj file by parsing its XML
252+
/// and reading the resolved dependency tree from project.assets.json.
253+
/// </summary>
254+
private static ProjectMetadata ExtractProjectMetadata(string projectPath, string rootDir)
255+
{
256+
var doc = XDocument.Load(projectPath);
257+
var root = doc.Root!;
258+
var ns = root.Name.Namespace;
259+
260+
var relativePath = Path.GetRelativePath(rootDir, projectPath);
261+
262+
var metadata = new ProjectMetadata
263+
{
264+
ProjectPath = relativePath,
265+
Sdk = root.Attribute("Sdk")?.Value
266+
};
267+
268+
// Extract TargetFramework(s)
269+
var tfmElement = root.Descendants(ns + "TargetFramework").FirstOrDefault();
270+
var tfmsElement = root.Descendants(ns + "TargetFrameworks").FirstOrDefault();
271+
272+
var frameworks = new List<string>();
273+
if (tfmsElement != null)
274+
{
275+
foreach (var tfm in tfmsElement.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
276+
frameworks.Add(tfm);
277+
}
278+
else if (tfmElement != null)
279+
{
280+
frameworks.Add(tfmElement.Value.Trim());
281+
}
282+
283+
// Extract PackageReferences
284+
var packageRefs = root.Descendants(ns + "PackageReference")
285+
.Select(e => new PackageReferenceEntry
286+
{
287+
Include = e.Attribute("Include")?.Value ?? "",
288+
RequestedVersion = e.Attribute("Version")?.Value,
289+
ResolvedVersion = e.Attribute("Version")?.Value // raw = resolved in XML context
290+
})
291+
.Where(r => !string.IsNullOrEmpty(r.Include))
292+
.ToList();
293+
294+
// Extract ProjectReferences
295+
var projectRefs = root.Descendants(ns + "ProjectReference")
296+
.Select(e => new ProjectReferenceEntry
297+
{
298+
Include = e.Attribute("Include")?.Value ?? ""
299+
})
300+
.Where(r => !string.IsNullOrEmpty(r.Include))
301+
.ToList();
302+
303+
// Read resolved packages from project.assets.json
304+
var assetsPath = Path.Combine(Path.GetDirectoryName(projectPath)!, "obj", "project.assets.json");
305+
var resolvedByTfm = new Dictionary<string, List<ResolvedPackageEntry>>();
306+
if (File.Exists(assetsPath))
307+
{
308+
try
309+
{
310+
resolvedByTfm = ReadResolvedPackages(assetsPath);
311+
}
312+
catch (Exception ex)
313+
{
314+
Log.Debug("Failed to read project.assets.json at {Path}: {Ex}", assetsPath, ex.Message);
315+
}
316+
}
317+
318+
// Extract properties with provenance
319+
foreach (var propGroup in root.Descendants(ns + "PropertyGroup"))
320+
{
321+
foreach (var prop in propGroup.Elements())
322+
{
323+
var propName = prop.Name.LocalName;
324+
if (!metadata.Properties.ContainsKey(propName))
325+
{
326+
metadata.Properties[propName] = new PropertyEntry
327+
{
328+
Value = prop.Value,
329+
DefinedIn = relativePath
330+
};
331+
}
332+
}
231333
}
232334

233-
Log.Debug("RPC ParseSolution: completed, {ItemCount} compilation units total", items.Count);
234-
return items;
335+
// Discover NuGet package sources from nuget.config files
336+
metadata.PackageSources = FindNuGetPackageSources(projectPath, rootDir);
337+
338+
// Build per-TFM metadata
339+
foreach (var tfm in frameworks)
340+
{
341+
resolvedByTfm.TryGetValue(tfm, out var resolved);
342+
metadata.TargetFrameworks.Add(new TargetFrameworkEntry
343+
{
344+
TargetFramework = tfm,
345+
PackageReferences = packageRefs,
346+
ResolvedPackages = resolved ?? new List<ResolvedPackageEntry>(),
347+
ProjectReferences = projectRefs
348+
});
349+
}
350+
351+
// If no frameworks found, still include a default entry
352+
if (frameworks.Count == 0)
353+
{
354+
metadata.TargetFrameworks.Add(new TargetFrameworkEntry
355+
{
356+
PackageReferences = packageRefs,
357+
ProjectReferences = projectRefs
358+
});
359+
}
360+
361+
return metadata;
362+
}
363+
364+
/// <summary>
365+
/// Discovers NuGet package sources by walking up from the project directory
366+
/// to the repository root looking for nuget.config files.
367+
/// NuGet resolves sources hierarchically — closest config wins.
368+
/// </summary>
369+
private static List<PackageSourceEntry> FindNuGetPackageSources(string projectPath, string rootDir)
370+
{
371+
var sources = new List<PackageSourceEntry>();
372+
var dir = Path.GetDirectoryName(projectPath);
373+
374+
while (dir != null && dir.StartsWith(rootDir, StringComparison.OrdinalIgnoreCase))
375+
{
376+
var configPath = Path.Combine(dir, "nuget.config");
377+
// Case-insensitive check (NuGet.Config, nuget.config, NuGet.config all valid)
378+
if (!File.Exists(configPath))
379+
{
380+
configPath = Path.Combine(dir, "NuGet.Config");
381+
if (!File.Exists(configPath))
382+
{
383+
configPath = Path.Combine(dir, "NuGet.config");
384+
}
385+
}
386+
387+
if (File.Exists(configPath))
388+
{
389+
try
390+
{
391+
var configDoc = XDocument.Load(configPath);
392+
var packageSources = configDoc.Root?
393+
.Element("packageSources")?
394+
.Elements("add");
395+
396+
if (packageSources != null)
397+
{
398+
foreach (var source in packageSources)
399+
{
400+
var key = source.Attribute("key")?.Value;
401+
var url = source.Attribute("value")?.Value;
402+
if (key != null && url != null &&
403+
!sources.Any(s => s.Key == key))
404+
{
405+
sources.Add(new PackageSourceEntry
406+
{
407+
Key = key,
408+
Url = url
409+
});
410+
}
411+
}
412+
}
413+
}
414+
catch (Exception ex)
415+
{
416+
Log.Debug("Failed to parse nuget.config at {Path}: {Ex}", configPath, ex.Message);
417+
}
418+
// NuGet uses the closest config — stop walking up once we find one
419+
break;
420+
}
421+
422+
if (dir == rootDir) break;
423+
dir = Path.GetDirectoryName(dir);
424+
}
425+
426+
// Default to nuget.org if no sources found
427+
if (sources.Count == 0)
428+
{
429+
sources.Add(new PackageSourceEntry
430+
{
431+
Key = "nuget.org",
432+
Url = "https://api.nuget.org/v3/index.json"
433+
});
434+
}
435+
436+
return sources;
437+
}
438+
439+
/// <summary>
440+
/// Reads the resolved dependency tree from project.assets.json.
441+
/// Returns a dictionary keyed by target framework moniker.
442+
/// </summary>
443+
private static Dictionary<string, List<ResolvedPackageEntry>> ReadResolvedPackages(string assetsPath)
444+
{
445+
var result = new Dictionary<string, List<ResolvedPackageEntry>>();
446+
var json = JObject.Parse(File.ReadAllText(assetsPath));
447+
var targets = json["targets"] as JObject;
448+
if (targets == null) return result;
449+
450+
foreach (var (tfmKey, tfmValue) in targets)
451+
{
452+
// tfmKey is like "net8.0" or ".NETCoreApp,Version=v8.0"
453+
var tfm = tfmKey.Contains(',')
454+
? NormalizeTfm(tfmKey)
455+
: tfmKey;
456+
457+
var packages = new List<ResolvedPackageEntry>();
458+
if (tfmValue is JObject tfmObj)
459+
{
460+
foreach (var (pkgKey, pkgValue) in tfmObj)
461+
{
462+
// pkgKey is "PackageName/Version"
463+
var parts = pkgKey.Split('/', 2);
464+
if (parts.Length != 2) continue;
465+
466+
var type = pkgValue?["type"]?.Value<string>();
467+
if (type != "package") continue;
468+
469+
var deps = new List<ResolvedPackageEntry>();
470+
var dependencies = pkgValue?["dependencies"] as JObject;
471+
if (dependencies != null)
472+
{
473+
foreach (var (depName, depVersion) in dependencies)
474+
{
475+
deps.Add(new ResolvedPackageEntry
476+
{
477+
Name = depName,
478+
ResolvedVersion = depVersion?.Value<string>() ?? "",
479+
Depth = 1
480+
});
481+
}
482+
}
483+
484+
packages.Add(new ResolvedPackageEntry
485+
{
486+
Name = parts[0],
487+
ResolvedVersion = parts[1],
488+
Dependencies = deps,
489+
Depth = 0
490+
});
491+
}
492+
}
493+
494+
result[tfm] = packages;
495+
}
496+
497+
return result;
498+
}
499+
500+
/// <summary>
501+
/// Normalizes a full framework identifier like ".NETCoreApp,Version=v8.0" to "net8.0".
502+
/// </summary>
503+
private static string NormalizeTfm(string fullTfm)
504+
{
505+
// Simple heuristic: extract the version part
506+
if (fullTfm.StartsWith(".NETCoreApp,Version=v") || fullTfm.StartsWith(".NETCoreApp,Version=V"))
507+
{
508+
var version = fullTfm.Substring(".NETCoreApp,Version=v".Length);
509+
return "net" + version;
510+
}
511+
if (fullTfm.StartsWith(".NETStandard,Version=v") || fullTfm.StartsWith(".NETStandard,Version=V"))
512+
{
513+
var version = fullTfm.Substring(".NETStandard,Version=v".Length);
514+
return "netstandard" + version;
515+
}
516+
return fullTfm;
235517
}
236518

237519
/// <summary>
@@ -1284,13 +1566,68 @@ public class ParseSolutionRequest
12841566
public string RootDir { get; set; } = "";
12851567
}
12861568

1569+
public class ParseSolutionResponse
1570+
{
1571+
public List<ParseSolutionResponseItem> Items { get; set; } = new();
1572+
public List<ProjectMetadata> Projects { get; set; } = new();
1573+
}
1574+
12871575
public class ParseSolutionResponseItem
12881576
{
12891577
public string Id { get; set; } = "";
12901578
public string SourceFileType { get; set; } = "";
12911579
public string ProjectPath { get; set; } = "";
12921580
}
12931581

1582+
public class ProjectMetadata
1583+
{
1584+
public string ProjectPath { get; set; } = "";
1585+
public string? Sdk { get; set; }
1586+
public Dictionary<string, PropertyEntry> Properties { get; set; } = new();
1587+
public List<TargetFrameworkEntry> TargetFrameworks { get; set; } = new();
1588+
public List<PackageSourceEntry> PackageSources { get; set; } = new();
1589+
}
1590+
1591+
public class PackageSourceEntry
1592+
{
1593+
public string Key { get; set; } = "";
1594+
public string Url { get; set; } = "";
1595+
}
1596+
1597+
public class PropertyEntry
1598+
{
1599+
public string Value { get; set; } = "";
1600+
public string? DefinedIn { get; set; }
1601+
}
1602+
1603+
public class TargetFrameworkEntry
1604+
{
1605+
public string TargetFramework { get; set; } = "";
1606+
public List<PackageReferenceEntry> PackageReferences { get; set; } = new();
1607+
public List<ResolvedPackageEntry> ResolvedPackages { get; set; } = new();
1608+
public List<ProjectReferenceEntry> ProjectReferences { get; set; } = new();
1609+
}
1610+
1611+
public class PackageReferenceEntry
1612+
{
1613+
public string Include { get; set; } = "";
1614+
public string? RequestedVersion { get; set; }
1615+
public string? ResolvedVersion { get; set; }
1616+
}
1617+
1618+
public class ResolvedPackageEntry
1619+
{
1620+
public string Name { get; set; } = "";
1621+
public string ResolvedVersion { get; set; } = "";
1622+
public List<ResolvedPackageEntry> Dependencies { get; set; } = new();
1623+
public int Depth { get; set; }
1624+
}
1625+
1626+
public class ProjectReferenceEntry
1627+
{
1628+
public string Include { get; set; } = "";
1629+
}
1630+
12941631
public class GetObjectRequest
12951632
{
12961633
public string Id { get; set; } = "";

0 commit comments

Comments
 (0)