From bbeb2ebd1c4498d73c5b7300b2ee09d2b03bfd9f Mon Sep 17 00:00:00 2001 From: Andrew Stakhov Date: Tue, 28 Apr 2026 17:58:09 -0400 Subject: [PATCH 1/4] Add MSBuild csproj recipes + sln Project filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C# (csproj transformations, registered with CsprojRecipeActivator): - AddFrameworkReference: adds when removing meta-packages (e.g., Microsoft.AspNetCore.App after dropping Microsoft.AspNetCore.All) - RemoveDotNetCliToolReference: removes items (the netcoreapp2.x per-project CLI tool mechanism, gone in net3+) - RemoveMSBuildProperty: removes named properties (e.g., RuntimeFrameworkVersion, PackageTargetFallback) Java (sln cleanup): - org.openrewrite.csharp.sln.RemoveNjsprojFromSolution: removes Project entries with .njsproj (Node.js Tools) extension from .sln/.slnx, plus associated ProjectConfigurationPlatforms / NestedProjects entries. dotnet build can't process .njsproj because the SDK doesn't ship Microsoft.NodejsTools.targets. Note: the Java sln recipe is in the rewrite-csharp jar but mod CLI's marketplace currently doesn't expose Java recipes from rewrite-csharp via the NuGet recipe bundle path — the recipe is registered in recipes.csv and discoverable on classpath but not yet usable from a NuGet recipe package. Foundation is in place for follow-up. --- .../CSharp/Recipes/AddFrameworkReference.cs | 58 +++++++ .../Recipes/AddFrameworkReferenceVisitor.cs | 85 ++++++++++ .../CSharp/Recipes/CsprojRecipeActivator.cs | 3 + .../Recipes/RemoveDotNetCliToolReference.cs | 53 +++++++ .../RemoveDotNetCliToolReferenceVisitor.cs | 53 +++++++ .../CSharp/Recipes/RemoveMSBuildProperty.cs | 50 ++++++ .../Recipes/RemoveMSBuildPropertyVisitor.cs | 63 ++++++++ .../Recipes/AddFrameworkReferenceTests.cs | 127 +++++++++++++++ .../RemoveDotNetCliToolReferenceTests.cs | 87 ++++++++++ .../Recipes/RemoveMSBuildPropertyTests.cs | 94 +++++++++++ .../csharp/sln/RemoveNjsprojFromSolution.java | 148 ++++++++++++++++++ .../resources/META-INF/rewrite/recipes.csv | 3 +- 12 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReference.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReferenceVisitor.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReference.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReferenceVisitor.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildProperty.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildPropertyVisitor.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/AddFrameworkReferenceTests.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveDotNetCliToolReferenceTests.cs create mode 100644 rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveMSBuildPropertyTests.cs create mode 100644 rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReference.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReference.cs new file mode 100644 index 00000000000..5687f15d3f0 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReference.cs @@ -0,0 +1,58 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.Core; +using OpenRewrite.Xml; +using ExecutionContext = OpenRewrite.Core.ExecutionContext; + +namespace OpenRewrite.CSharp.Recipes; + +///

+/// Adds a <FrameworkReference> to a .csproj file's project root if one +/// with a matching Include doesn't already exist. The reference is placed in a +/// dedicated <ItemGroup> appended to the project. No-op when the SDK +/// is Microsoft.NET.Sdk.Web, which already imports +/// Microsoft.AspNetCore.App implicitly. +/// +public class AddFrameworkReference : ScanningRecipe +{ + public override string DisplayName => "Add framework reference"; + + public override string Description => + "Adds a `` to a .csproj if it isn't already present."; + + [Option(DisplayName = "Framework name", + Description = "The shared framework name to reference.", + Example = "Microsoft.AspNetCore.App")] + public string FrameworkName { get; set; } = ""; + + [Option(DisplayName = "Trigger package glob", + Description = "Optional glob: only add the framework reference when a `` " + + "matching this glob is present in the project. Leave blank to always add.", + Example = "Microsoft.AspNetCore.*", + Required = false)] + public string? TriggerPackageGlob { get; set; } + + public override DotNetBuildContext GetInitialValue(ExecutionContext ctx) => DotNetBuildContext.GetOrCreate(ctx); + + public override ITreeVisitor GetScanner(DotNetBuildContext acc) => new BuildContextScanner(); + + public override ITreeVisitor GetVisitor(DotNetBuildContext acc) + { + return Preconditions.Check( + new IsProjectFile(), + new AddFrameworkReferenceVisitor(FrameworkName, TriggerPackageGlob)); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReferenceVisitor.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReferenceVisitor.cs new file mode 100644 index 00000000000..696055347f0 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/AddFrameworkReferenceVisitor.cs @@ -0,0 +1,85 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.Core; +using OpenRewrite.Xml; +using ExecutionContext = OpenRewrite.Core.ExecutionContext; + +namespace OpenRewrite.CSharp.Recipes; + +///

+/// Visitor that adds a <FrameworkReference> to a .csproj file root if +/// one with a matching Include doesn't already exist. Skips projects whose Sdk +/// attribute already implicitly imports the same framework +/// (Microsoft.NET.Sdk.Web implicitly references +/// Microsoft.AspNetCore.App). +/// +public class AddFrameworkReferenceVisitor(string frameworkName, string? triggerPackageGlob = null) : XmlVisitor +{ + private bool _alreadyPresent; + private bool _triggerMatched; + + public override Xml.Xml VisitDocument(Document document, ExecutionContext ctx) + { + _alreadyPresent = false; + _triggerMatched = string.IsNullOrEmpty(triggerPackageGlob); + + // Skip when the SDK already imports the same framework implicitly. + var sdk = document.Root.GetAttributeValue("Sdk"); + if (sdk != null && SdkImplicitlyReferences(sdk, frameworkName)) + return document; + + var d = (Document)base.VisitDocument(document, ctx); + + if (_alreadyPresent || !_triggerMatched) + return d; + + var tag = $""; + var itemGroup = TagExtensions.BuildTag( + $"\n {tag}\n "); + DoAfterVisit(new AddToTagVisitor(d.Root, itemGroup)); + DoAfterVisit(MSBuildProjectHelper.RegenerateMarkerVisitor()); + return d; + } + + public override Xml.Xml VisitTag(Tag tag, ExecutionContext ctx) + { + var t = (Tag)base.VisitTag(tag, ctx); + if (t.Name == "FrameworkReference") + { + var include = t.GetAttributeValue("Include"); + if (include == frameworkName) + _alreadyPresent = true; + } + else if (!string.IsNullOrEmpty(triggerPackageGlob) && t.Name == "PackageReference") + { + var include = t.GetAttributeValue("Include"); + if (include != null && GlobMatcher.Matches(include, triggerPackageGlob!)) + _triggerMatched = true; + } + return t; + } + + private static bool SdkImplicitlyReferences(string sdk, string framework) + { + return sdk switch + { + "Microsoft.NET.Sdk.Web" => framework is "Microsoft.AspNetCore.App" or "Microsoft.NETCore.App", + "Microsoft.NET.Sdk.Worker" => framework is "Microsoft.AspNetCore.App" or "Microsoft.NETCore.App", + "Microsoft.NET.Sdk.BlazorWebAssembly" => framework is "Microsoft.AspNetCore.App" or "Microsoft.NETCore.App", + _ => false + }; + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/CsprojRecipeActivator.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/CsprojRecipeActivator.cs index 36ee3bdbcc3..d2b5a029c45 100644 --- a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/CsprojRecipeActivator.cs +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/CsprojRecipeActivator.cs @@ -28,5 +28,8 @@ public void Activate(RecipeMarketplace marketplace) marketplace.Install(new UpgradeNuGetPackageVersion(), CsprojCategory); marketplace.Install(new ChangeDotNetTargetFramework(), CsprojCategory); marketplace.Install(new FindNuGetPackageReference(), CsprojCategory); + marketplace.Install(new RemoveMSBuildProperty(), CsprojCategory); + marketplace.Install(new RemoveDotNetCliToolReference(), CsprojCategory); + marketplace.Install(new AddFrameworkReference(), CsprojCategory); } } diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReference.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReference.cs new file mode 100644 index 00000000000..e6106676e7f --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReference.cs @@ -0,0 +1,53 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.Core; +using OpenRewrite.Xml; +using ExecutionContext = OpenRewrite.Core.ExecutionContext; + +namespace OpenRewrite.CSharp.Recipes; + +///

+/// Removes <DotNetCliToolReference> entries (matching by glob on the +/// Include attribute) from .csproj files. CLI tool references are obsolete starting +/// with .NET Core 3.0 — they were the netcoreapp2.x mechanism for shipping per-project +/// CLI tools, and have since been replaced by global / local tools and SDK-built-in +/// commands (e.g. dotnet watch). +/// +public class RemoveDotNetCliToolReference : ScanningRecipe +{ + public override string DisplayName => "Remove DotNetCliToolReference"; + + public override string Description => + "Removes a `` element from .csproj files. " + + "Use `*` to remove every CLI tool reference."; + + [Option(DisplayName = "Tool name", + Description = "The CLI tool package name to remove. Supports glob patterns. " + + "Use `*` to remove all CLI tool references.", + Example = "Microsoft.DotNet.Watcher.Tools")] + public string ToolName { get; set; } = ""; + + public override DotNetBuildContext GetInitialValue(ExecutionContext ctx) => DotNetBuildContext.GetOrCreate(ctx); + + public override ITreeVisitor GetScanner(DotNetBuildContext acc) => new BuildContextScanner(); + + public override ITreeVisitor GetVisitor(DotNetBuildContext acc) + { + return Preconditions.Check( + new IsProjectFile(), + new RemoveDotNetCliToolReferenceVisitor(ToolName)); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReferenceVisitor.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReferenceVisitor.cs new file mode 100644 index 00000000000..ae9c46d39b3 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveDotNetCliToolReferenceVisitor.cs @@ -0,0 +1,53 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.Core; +using OpenRewrite.Xml; +using ExecutionContext = OpenRewrite.Core.ExecutionContext; + +namespace OpenRewrite.CSharp.Recipes; + +///

+/// Visitor that removes a <DotNetCliToolReference> element from +/// .csproj files. Supports glob patterns for the tool name. +/// +public class RemoveDotNetCliToolReferenceVisitor(string toolName) : XmlVisitor +{ + private bool _modified; + + public override Xml.Xml VisitDocument(Document document, ExecutionContext ctx) + { + _modified = false; + var d = (Document)base.VisitDocument(document, ctx); + if (_modified) + DoAfterVisit(MSBuildProjectHelper.RegenerateMarkerVisitor()); + return d; + } + + public override Xml.Xml VisitTag(Tag tag, ExecutionContext ctx) + { + var t = (Tag)base.VisitTag(tag, ctx); + if (t.Name == "DotNetCliToolReference") + { + var include = t.GetAttributeValue("Include"); + if (include != null && GlobMatcher.Matches(include, toolName)) + { + _modified = true; + DoAfterVisit(new RemoveContentVisitor(t, true, false)); + } + } + return t; + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildProperty.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildProperty.cs new file mode 100644 index 00000000000..fb996f0dd83 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildProperty.cs @@ -0,0 +1,50 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.Core; +using OpenRewrite.Xml; +using ExecutionContext = OpenRewrite.Core.ExecutionContext; + +namespace OpenRewrite.CSharp.Recipes; + +///

+/// Removes an MSBuild property element (e.g. RuntimeFrameworkVersion) from a +/// PropertyGroup in .csproj files. Useful for stripping legacy properties that +/// are no longer applicable after upgrading the target framework. +/// +public class RemoveMSBuildProperty : ScanningRecipe +{ + public override string DisplayName => "Remove MSBuild property"; + + public override string Description => + "Removes an MSBuild property element (e.g. ``) from " + + "`` in .csproj files."; + + [Option(DisplayName = "Property name", + Description = "The MSBuild property element name to remove (case-sensitive).", + Example = "RuntimeFrameworkVersion")] + public string PropertyName { get; set; } = ""; + + public override DotNetBuildContext GetInitialValue(ExecutionContext ctx) => DotNetBuildContext.GetOrCreate(ctx); + + public override ITreeVisitor GetScanner(DotNetBuildContext acc) => new BuildContextScanner(); + + public override ITreeVisitor GetVisitor(DotNetBuildContext acc) + { + return Preconditions.Check( + new IsProjectFile(), + new RemoveMSBuildPropertyVisitor(PropertyName)); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildPropertyVisitor.cs b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildPropertyVisitor.cs new file mode 100644 index 00000000000..0c315028689 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/CSharp/Recipes/RemoveMSBuildPropertyVisitor.cs @@ -0,0 +1,63 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.Core; +using OpenRewrite.Xml; +using ExecutionContext = OpenRewrite.Core.ExecutionContext; + +namespace OpenRewrite.CSharp.Recipes; + +///

+/// Visitor that removes an MSBuild property element nested inside a +/// PropertyGroup in .csproj files. Can be used standalone in custom recipe +/// edit phases. +/// +public class RemoveMSBuildPropertyVisitor(string propertyName) : XmlVisitor +{ + private bool _modified; + + public override Xml.Xml VisitDocument(Document document, ExecutionContext ctx) + { + _modified = false; + var d = (Document)base.VisitDocument(document, ctx); + if (_modified) + DoAfterVisit(MSBuildProjectHelper.RegenerateMarkerVisitor()); + return d; + } + + public override Xml.Xml VisitTag(Tag tag, ExecutionContext ctx) + { + var t = (Tag)base.VisitTag(tag, ctx); + if (t.Name == propertyName && IsInPropertyGroup()) + { + _modified = true; + DoAfterVisit(new RemoveContentVisitor(t, true, false)); + } + return t; + } + + private bool IsInPropertyGroup() + { + // Walk parents up to find the enclosing element. + var parent = Cursor.Parent; + while (parent != null) + { + if (parent.Value is Tag pTag) + return pTag.Name == "PropertyGroup"; + parent = parent.Parent; + } + return false; + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/AddFrameworkReferenceTests.cs b/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/AddFrameworkReferenceTests.cs new file mode 100644 index 00000000000..0a07b41a082 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/AddFrameworkReferenceTests.cs @@ -0,0 +1,127 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.CSharp.Recipes; +using OpenRewrite.Test; + +namespace OpenRewrite.Tests.CSharp.Recipes; + +public class AddFrameworkReferenceTests : RewriteTest +{ + [Fact] + public void AddsWhenTriggerMatches() + { + RewriteRun( + spec => spec.SetRecipe(new AddFrameworkReference + { + FrameworkName = "Microsoft.AspNetCore.App", + TriggerPackageGlob = "Microsoft.AspNetCore.*" + }), + CsProj( + """ + + + net10.0 + + + + + + """, + """ + + + net10.0 + + + + + + + + + """ + ) + ); + } + + [Fact] + public void NoChangeWhenSdkImplicitlyImports() + { + // Microsoft.NET.Sdk.Web already imports Microsoft.AspNetCore.App + RewriteRun( + spec => spec.SetRecipe(new AddFrameworkReference + { + FrameworkName = "Microsoft.AspNetCore.App" + }), + CsProj( + """ + + + net10.0 + + + """ + ) + ); + } + + [Fact] + public void NoChangeWhenAlreadyPresent() + { + RewriteRun( + spec => spec.SetRecipe(new AddFrameworkReference + { + FrameworkName = "Microsoft.AspNetCore.App" + }), + CsProj( + """ + + + net10.0 + + + + + + """ + ) + ); + } + + [Fact] + public void NoChangeWhenTriggerNotMatched() + { + RewriteRun( + spec => spec.SetRecipe(new AddFrameworkReference + { + FrameworkName = "Microsoft.AspNetCore.App", + TriggerPackageGlob = "Microsoft.AspNetCore.*" + }), + CsProj( + """ + + + net10.0 + + + + + + """ + ) + ); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveDotNetCliToolReferenceTests.cs b/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveDotNetCliToolReferenceTests.cs new file mode 100644 index 00000000000..64cae992423 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveDotNetCliToolReferenceTests.cs @@ -0,0 +1,87 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.CSharp.Recipes; +using OpenRewrite.Test; + +namespace OpenRewrite.Tests.CSharp.Recipes; + +public class RemoveDotNetCliToolReferenceTests : RewriteTest +{ + [Fact] + public void RemoveSpecificCliTool() + { + RewriteRun( + spec => spec.SetRecipe(new RemoveDotNetCliToolReference + { + ToolName = "Microsoft.DotNet.Watcher.Tools" + }), + CsProj( + """ + + + net10.0 + + + + + + + """, + """ + + + net10.0 + + + + + + """ + ) + ); + } + + [Fact] + public void RemoveAllCliToolsViaWildcard() + { + RewriteRun( + spec => spec.SetRecipe(new RemoveDotNetCliToolReference + { + ToolName = "*" + }), + CsProj( + """ + + + net10.0 + + + + + + + """, + """ + + + net10.0 + + + """ + ) + ); + } +} diff --git a/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveMSBuildPropertyTests.cs b/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveMSBuildPropertyTests.cs new file mode 100644 index 00000000000..c4d7a1a7f78 --- /dev/null +++ b/rewrite-csharp/csharp/OpenRewrite/Tests/CSharp/Recipes/RemoveMSBuildPropertyTests.cs @@ -0,0 +1,94 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using OpenRewrite.CSharp.Recipes; +using OpenRewrite.Test; + +namespace OpenRewrite.Tests.CSharp.Recipes; + +public class RemoveMSBuildPropertyTests : RewriteTest +{ + [Fact] + public void RemovesProperty() + { + RewriteRun( + spec => spec.SetRecipe(new RemoveMSBuildProperty + { + PropertyName = "RuntimeFrameworkVersion" + }), + CsProj( + """ + + + net10.0 + 2.1.1 + + + """, + """ + + + net10.0 + + + """ + ) + ); + } + + [Fact] + public void NoChangeWhenPropertyAbsent() + { + RewriteRun( + spec => spec.SetRecipe(new RemoveMSBuildProperty + { + PropertyName = "RuntimeFrameworkVersion" + }), + CsProj( + """ + + + net10.0 + + + """ + ) + ); + } + + [Fact] + public void OnlyMatchesInsidePropertyGroup() + { + // A child element named identically inside an ItemGroup or elsewhere should not be touched. + RewriteRun( + spec => spec.SetRecipe(new RemoveMSBuildProperty + { + PropertyName = "RuntimeFrameworkVersion" + }), + CsProj( + """ + + + net10.0 + + + + + + """ + ) + ); + } +} diff --git a/rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java b/rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java new file mode 100644 index 00000000000..818ee32e58c --- /dev/null +++ b/rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java @@ -0,0 +1,148 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.csharp.sln; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.SourceFile; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.text.PlainText; +import org.openrewrite.text.PlainTextParser; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Removes Project entries with the given file extension (e.g., {@code .njsproj}) from + * Visual Studio Solution (.sln/.slnx) files. Cleans up the corresponding + * ProjectConfigurationPlatforms entries and (best-effort) NestedProjects entries. + *

+ * Useful when the solution contains projects that {@code dotnet build} cannot handle — + * notably Node.js Tools projects ({@code .njsproj}) which require Visual Studio + * extensions that don't ship with the .NET SDK. + */ +@Value +@EqualsAndHashCode(callSuper = false) +public class RemoveNjsprojFromSolution extends Recipe { + + private static final Pattern PROJECT_BLOCK = Pattern.compile( + "(?m)^Project\\(\"\\{[^}]+}\"\\)\\s*=\\s*\"[^\"]*\",\\s*\"([^\"]*)\",\\s*\"\\{([^}]+)}\"\\s*$" + + "([\\s\\S]*?)" + + "^EndProject\\s*$\\r?\\n?"); + + @Override + public String getDisplayName() { + return "Remove projects from solution by extension"; + } + + @Override + public String getDescription() { + return "Removes Project entries with a matching file extension (e.g., `.njsproj`) " + + "from Visual Studio Solution (.sln/.slnx) files, plus associated " + + "ProjectConfigurationPlatforms and NestedProjects entries. " + + "Default extension is `.njsproj` (Node.js Tools projects, which " + + "`dotnet build` cannot process)."; + } + + @Override + public TreeVisitor getVisitor() { + return new TreeVisitor() { + @Override + public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { + String path = sourceFile.getSourcePath().toString().toLowerCase(); + return path.endsWith(".sln") || path.endsWith(".slnx"); + } + + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + SourceFile sourceFile = (SourceFile) tree; + if (!isAcceptable(sourceFile, ctx)) { + return tree; + } + PlainText plainText = PlainTextParser.convert(sourceFile); + String text = plainText.getText(); + + Set removedGuids = new HashSet<>(); + Matcher m = PROJECT_BLOCK.matcher(text); + StringBuilder out = new StringBuilder(text.length()); + int last = 0; + boolean changed = false; + while (m.find()) { + String relativePath = m.group(1); + String projectGuid = m.group(2); + out.append(text, last, m.start()); + if (relativePath.toLowerCase().endsWith(".njsproj")) { + removedGuids.add(projectGuid.toUpperCase()); + changed = true; + } else { + out.append(text, m.start(), m.end()); + } + last = m.end(); + } + out.append(text, last, text.length()); + + if (!changed) { + return tree; + } + + String afterProjects = out.toString(); + String afterCleanup = removeReferencesToGuids(afterProjects, removedGuids); + return plainText.withText(afterCleanup); + } + }; + } + + /** + * Removes any line that mentions one of the given (uppercased) project GUIDs + * — covers ProjectConfigurationPlatforms and NestedProjects sections. + */ + private static String removeReferencesToGuids(String text, Set guids) { + if (guids.isEmpty()) { + return text; + } + StringBuilder out = new StringBuilder(text.length()); + for (String line : text.split("\\r?\\n", -1)) { + String upper = line.toUpperCase(); + boolean keep = true; + for (String g : guids) { + if (upper.contains(g)) { + keep = false; + break; + } + } + if (keep) { + out.append(line).append('\n'); + } + } + // Preserve trailing-newline behavior: split with limit -1 produces a trailing empty + // entry when the input ended in \n; strip the last \n we just appended for that case. + if (text.endsWith("\n") && out.length() > 0) { + out.setLength(out.length() - 1); + } else if (!text.endsWith("\n") && out.length() > 0 && out.charAt(out.length() - 1) == '\n') { + out.setLength(out.length() - 1); + } + return out.toString(); + } +} diff --git a/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv index 6ff58ebc3c5..5a87182bb7b 100644 --- a/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv @@ -1 +1,2 @@ -ecosystem,packageName,name,displayName,description,recipeCount,category1,options +ecosystem,packageName,name,displayName,description,recipeCount,category1,category2 +maven,org.openrewrite:rewrite-csharp,org.openrewrite.csharp.sln.RemoveNjsprojFromSolution,Remove projects from solution by extension,"Removes Project entries with a matching file extension (e.g., `.njsproj`) from Visual Studio Solution (.sln/.slnx) files, plus associated ProjectConfigurationPlatforms and NestedProjects entries. Default extension is `.njsproj` (Node.js Tools projects, which `dotnet build` cannot process).",1,Sln,Csharp From f462d484650c0d16e5bedf157c8d0362002b891a Mon Sep 17 00:00:00 2001 From: Andrew Stakhov Date: Wed, 29 Apr 2026 06:10:03 -0400 Subject: [PATCH 2/4] RewriteTest: route .cs files through local parser even with sourcePath set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, any test SourceSpec with a non-null SourcePath was routed through the Java RPC parser, which doesn't ship a C# parser — so multi-project tests that needed C# files at specific paths (e.g., to test path-based per-project recipe scoping) couldn't be expressed. Now: .cs source paths route through the local CSharpParser with the test's sourcePath; only non-.cs/non-.csproj source paths go to Java RPC. Local C# parsing already supports a custom sourcePath via CSharpParser.Parse(..., sourcePath: ...) — this just plumbs the test's spec.SourcePath through. Enables tests like AddNuGetPackageReferenceIfTypeUsedTest where the recipe needs to map .cs files to their containing csproj by directory prefix. --- .../csharp/OpenRewrite/Test/RewriteTest.cs | 13 +++++++++---- .../src/main/resources/META-INF/rewrite/recipes.csv | 3 +-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/rewrite-csharp/csharp/OpenRewrite/Test/RewriteTest.cs b/rewrite-csharp/csharp/OpenRewrite/Test/RewriteTest.cs index 80308f9166b..b39f4ea6ee5 100644 --- a/rewrite-csharp/csharp/OpenRewrite/Test/RewriteTest.cs +++ b/rewrite-csharp/csharp/OpenRewrite/Test/RewriteTest.cs @@ -86,13 +86,17 @@ protected void RewriteRun(Action configure, params SourceSpec[] spec foreach (var spec in specs) { SourceFile source; + var isCsFile = spec.SourcePath != null && + spec.SourcePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); if (spec.SourcePath != null && IsCsprojPath(spec.SourcePath) && csprojParsed != null) { source = csprojParsed[spec.SourcePath]; } - else if (spec.SourcePath != null) + else if (spec.SourcePath != null && !isCsFile) { - // Remote-parsed source (e.g., XML via Java RPC) + // Remote-parsed source (e.g., XML via Java RPC). C# files always use local + // parsing — even with a custom source path — because the Java peer doesn't + // ship a C# parser. var rpc = RewriteRpcServer.Current ?? throw new InvalidOperationException( $"Parsing {spec.SourcePath} requires an RPC connection. " + @@ -103,10 +107,11 @@ protected void RewriteRun(Action configure, params SourceSpec[] spec else { // Local C# parsing + var localSourcePath = spec.SourcePath ?? "source.cs"; SemanticModel? semanticModel = null; if (metadataReferences != null) { - var syntaxTree = CSharpSyntaxTree.ParseText(spec.Before, path: "source.cs"); + var syntaxTree = CSharpSyntaxTree.ParseText(spec.Before, path: localSourcePath); var compilation = CSharpCompilation.Create("TestCompilation") .WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) .AddReferences(metadataReferences) @@ -114,7 +119,7 @@ protected void RewriteRun(Action configure, params SourceSpec[] spec semanticModel = compilation.GetSemanticModel(syntaxTree); } - source = parser.Parse(spec.Before, semanticModel: semanticModel); + source = parser.Parse(spec.Before, sourcePath: localSourcePath, semanticModel: semanticModel); // Verify no non-whitespace content leaked into Space fields if (validations.WhitespaceInSpaces) diff --git a/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv index 5a87182bb7b..6ff58ebc3c5 100644 --- a/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-csharp/src/main/resources/META-INF/rewrite/recipes.csv @@ -1,2 +1 @@ -ecosystem,packageName,name,displayName,description,recipeCount,category1,category2 -maven,org.openrewrite:rewrite-csharp,org.openrewrite.csharp.sln.RemoveNjsprojFromSolution,Remove projects from solution by extension,"Removes Project entries with a matching file extension (e.g., `.njsproj`) from Visual Studio Solution (.sln/.slnx) files, plus associated ProjectConfigurationPlatforms and NestedProjects entries. Default extension is `.njsproj` (Node.js Tools projects, which `dotnet build` cannot process).",1,Sln,Csharp +ecosystem,packageName,name,displayName,description,recipeCount,category1,options From 6e098873fa4eb1f24dfc901b9bb29bde0e065d57 Mon Sep 17 00:00:00 2001 From: Andrew Stakhov Date: Wed, 29 Apr 2026 11:11:46 -0400 Subject: [PATCH 3/4] Remove RemoveNjsprojFromSolution Java recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipe was registered in the C# environment but is not yet usable from a NuGet recipe package, and the corresponding recipes.csv entry was reverted — leaving recipeCsvValidateCompleteness to fail in CI. Drop the recipe for now; it can be reintroduced when the marketplace path for Java recipes in rewrite-csharp is in place. --- .../csharp/sln/RemoveNjsprojFromSolution.java | 148 ------------------ 1 file changed, 148 deletions(-) delete mode 100644 rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java diff --git a/rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java b/rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java deleted file mode 100644 index 818ee32e58c..00000000000 --- a/rewrite-csharp/src/main/java/org/openrewrite/csharp/sln/RemoveNjsprojFromSolution.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2026 the original author or authors. - *

- * Licensed under the Moderne Source Available License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - *

- * https://docs.moderne.io/licensing/moderne-source-available-license - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.openrewrite.csharp.sln; - -import lombok.EqualsAndHashCode; -import lombok.Value; -import org.jspecify.annotations.Nullable; -import org.openrewrite.ExecutionContext; -import org.openrewrite.Recipe; -import org.openrewrite.SourceFile; -import org.openrewrite.Tree; -import org.openrewrite.TreeVisitor; -import org.openrewrite.text.PlainText; -import org.openrewrite.text.PlainTextParser; - -import java.util.HashSet; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Removes Project entries with the given file extension (e.g., {@code .njsproj}) from - * Visual Studio Solution (.sln/.slnx) files. Cleans up the corresponding - * ProjectConfigurationPlatforms entries and (best-effort) NestedProjects entries. - *

- * Useful when the solution contains projects that {@code dotnet build} cannot handle — - * notably Node.js Tools projects ({@code .njsproj}) which require Visual Studio - * extensions that don't ship with the .NET SDK. - */ -@Value -@EqualsAndHashCode(callSuper = false) -public class RemoveNjsprojFromSolution extends Recipe { - - private static final Pattern PROJECT_BLOCK = Pattern.compile( - "(?m)^Project\\(\"\\{[^}]+}\"\\)\\s*=\\s*\"[^\"]*\",\\s*\"([^\"]*)\",\\s*\"\\{([^}]+)}\"\\s*$" - + "([\\s\\S]*?)" - + "^EndProject\\s*$\\r?\\n?"); - - @Override - public String getDisplayName() { - return "Remove projects from solution by extension"; - } - - @Override - public String getDescription() { - return "Removes Project entries with a matching file extension (e.g., `.njsproj`) " + - "from Visual Studio Solution (.sln/.slnx) files, plus associated " + - "ProjectConfigurationPlatforms and NestedProjects entries. " + - "Default extension is `.njsproj` (Node.js Tools projects, which " + - "`dotnet build` cannot process)."; - } - - @Override - public TreeVisitor getVisitor() { - return new TreeVisitor() { - @Override - public boolean isAcceptable(SourceFile sourceFile, ExecutionContext ctx) { - String path = sourceFile.getSourcePath().toString().toLowerCase(); - return path.endsWith(".sln") || path.endsWith(".slnx"); - } - - @Override - public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { - if (!(tree instanceof SourceFile)) { - return tree; - } - SourceFile sourceFile = (SourceFile) tree; - if (!isAcceptable(sourceFile, ctx)) { - return tree; - } - PlainText plainText = PlainTextParser.convert(sourceFile); - String text = plainText.getText(); - - Set removedGuids = new HashSet<>(); - Matcher m = PROJECT_BLOCK.matcher(text); - StringBuilder out = new StringBuilder(text.length()); - int last = 0; - boolean changed = false; - while (m.find()) { - String relativePath = m.group(1); - String projectGuid = m.group(2); - out.append(text, last, m.start()); - if (relativePath.toLowerCase().endsWith(".njsproj")) { - removedGuids.add(projectGuid.toUpperCase()); - changed = true; - } else { - out.append(text, m.start(), m.end()); - } - last = m.end(); - } - out.append(text, last, text.length()); - - if (!changed) { - return tree; - } - - String afterProjects = out.toString(); - String afterCleanup = removeReferencesToGuids(afterProjects, removedGuids); - return plainText.withText(afterCleanup); - } - }; - } - - /** - * Removes any line that mentions one of the given (uppercased) project GUIDs - * — covers ProjectConfigurationPlatforms and NestedProjects sections. - */ - private static String removeReferencesToGuids(String text, Set guids) { - if (guids.isEmpty()) { - return text; - } - StringBuilder out = new StringBuilder(text.length()); - for (String line : text.split("\\r?\\n", -1)) { - String upper = line.toUpperCase(); - boolean keep = true; - for (String g : guids) { - if (upper.contains(g)) { - keep = false; - break; - } - } - if (keep) { - out.append(line).append('\n'); - } - } - // Preserve trailing-newline behavior: split with limit -1 produces a trailing empty - // entry when the input ended in \n; strip the last \n we just appended for that case. - if (text.endsWith("\n") && out.length() > 0) { - out.setLength(out.length() - 1); - } else if (!text.endsWith("\n") && out.length() > 0 && out.charAt(out.length() - 1) == '\n') { - out.setLength(out.length() - 1); - } - return out.toString(); - } -} From c77ff7577c3403be68f464b8a5e69d0b282bd18a Mon Sep 17 00:00:00 2001 From: Andrew Stakhov Date: Wed, 29 Apr 2026 15:18:55 -0400 Subject: [PATCH 4/4] RpcFixture: auto-locate Java RPC test server classpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously RPC_TEST_SERVER_CLASSPATH was required, and a stale value inherited from a different rewrite checkout would silently point tests at outdated Java JARs — surfacing as cryptic "recipe didn't fire" failures. Now resolve the classpath file from the loaded SDK assembly location (handling both in-repo runs and consuming projects that source- link via `external/openrewrite/rewrite`), and prefer the SDK-relative file when the env var is older or missing. --- .../csharp/OpenRewrite/Test/RpcFixture.cs | 100 +++++++++++++++++- 1 file changed, 95 insertions(+), 5 deletions(-) diff --git a/rewrite-csharp/csharp/OpenRewrite/Test/RpcFixture.cs b/rewrite-csharp/csharp/OpenRewrite/Test/RpcFixture.cs index b72d7cd0c69..3cf80836b85 100644 --- a/rewrite-csharp/csharp/OpenRewrite/Test/RpcFixture.cs +++ b/rewrite-csharp/csharp/OpenRewrite/Test/RpcFixture.cs @@ -74,11 +74,7 @@ public void Reset() private static ProcessStartInfo CreateJavaProcessStartInfo() { - var cpFile = Environment.GetEnvironmentVariable("RPC_TEST_SERVER_CLASSPATH") - ?? throw new InvalidOperationException( - "RPC_TEST_SERVER_CLASSPATH environment variable not set. " + - "Run './gradlew :rewrite-csharp:rpcTestClasspath' to generate the classpath file."); - + var cpFile = ResolveClasspathFile(); var classpath = File.ReadAllText(cpFile).Trim(); var psi = new ProcessStartInfo("java", "org.openrewrite.maven.rpc.JavaRewriteRpc") { @@ -92,6 +88,100 @@ private static ProcessStartInfo CreateJavaProcessStartInfo() return psi; } + ///

+ /// Resolves the rewrite-csharp RPC test server classpath file. + ///

+ /// Order of resolution: + /// 1. RPC_TEST_SERVER_CLASSPATH env var, if it points at an existing file + /// whose mtime isn't older than the SDK assembly that's currently loaded. + /// A stale env var (pointing at an unrelated rewrite checkout's leftover + /// classpath) silently linking the test process to outdated Java JARs is + /// a recurring source of cryptic "recipe didn't fire" failures. + /// 2. The classpath file at a deterministic location relative to the loaded + /// OpenRewrite SDK assembly: walk up from the assembly until we hit + /// rewrite-csharp/csharp/OpenRewrite/{bin,obj}, then the classpath + /// file is at rewrite-csharp/build/rpc-test-server-classpath.txt. + ///

+ private static string ResolveClasspathFile() + { + var sdkRelativeFile = AutoLocateClasspathFile(); + var envFile = Environment.GetEnvironmentVariable("RPC_TEST_SERVER_CLASSPATH"); + + // Prefer the SDK-relative file when it exists and is at least as fresh as + // any env-pointed file — guards against a stale env var inherited from a + // different rewrite checkout. + if (sdkRelativeFile != null && File.Exists(sdkRelativeFile)) + { + if (string.IsNullOrEmpty(envFile) || !File.Exists(envFile) || + File.GetLastWriteTimeUtc(sdkRelativeFile) >= + File.GetLastWriteTimeUtc(envFile)) + { + return sdkRelativeFile; + } + } + + if (!string.IsNullOrEmpty(envFile) && File.Exists(envFile)) + return envFile; + + if (sdkRelativeFile != null && File.Exists(sdkRelativeFile)) + return sdkRelativeFile; + + throw new InvalidOperationException( + "Cannot locate the Java RPC test server classpath. Either set " + + "RPC_TEST_SERVER_CLASSPATH, or run " + + "`./gradlew :rewrite-csharp:rpcTestClasspath` from the rewrite SDK " + + "checkout. Searched SDK-relative path: " + + (sdkRelativeFile ?? "(could not derive from loaded SDK assembly)")); + } + + private static string? AutoLocateClasspathFile() + { + // Two layout cases to handle: + // (a) Test runs INSIDE the rewrite SDK repo: walk up from the SDK assembly + // until we find `/rewrite-csharp/csharp/OpenRewrite/`. + // (b) Test runs in a CONSUMING project (e.g. recipes-csharp) that pulls in + // the SDK via ProjectReference. The DLL is copied to the test project's + // bin/, so the assembly path doesn't reach the SDK source. Instead, walk + // up from the assembly looking for an `external/openrewrite/rewrite` + // symlink (the convention for source-linked SDK in Conductor / consumer + // repos), then derive the classpath from the symlink target. + var sdkAssembly = typeof(OpenRewrite.CSharp.CSharpParser).Assembly.Location; + if (string.IsNullOrEmpty(sdkAssembly)) + return null; + + var dir = Path.GetDirectoryName(sdkAssembly); + while (dir != null) + { + // Case (a): inside the SDK repo. + if (Path.GetFileName(dir) == "OpenRewrite") + { + var parent = Path.GetDirectoryName(dir); + var grandparent = parent != null ? Path.GetDirectoryName(parent) : null; + if (parent != null && grandparent != null && + Path.GetFileName(parent) == "csharp" && + Path.GetFileName(grandparent) == "rewrite-csharp") + { + return Path.Combine(grandparent, "build", "rpc-test-server-classpath.txt"); + } + } + + // Case (b): consuming repo with `external/openrewrite/rewrite` symlink. + var symlink = Path.Combine(dir, "external", "openrewrite", "rewrite"); + if (Directory.Exists(symlink)) + { + var sdkMarker = Path.Combine(symlink, + "rewrite-csharp", "csharp", "OpenRewrite", "OpenRewrite.csproj"); + if (File.Exists(sdkMarker)) + { + return Path.Combine(symlink, + "rewrite-csharp", "build", "rpc-test-server-classpath.txt"); + } + } + dir = Path.GetDirectoryName(dir); + } + return null; + } + public void Dispose() { RewriteRpcServer.SetCurrent(null);