From 17b3539a20571acdc88a9841cee77fac3954c55b Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 24 Jun 2026 13:18:42 +0200 Subject: [PATCH] Add a per-assembly target-framework override for reference resolution ILSpy resolves an assembly's references against the target framework it detects from the TargetFrameworkAttribute. When that attribute is missing, wrong, or the user wants to force a different framework, there was no way to hint the correct one, so references could resolve against the wrong runtime pack or framework directory. A LoadedAssembly can now carry a TargetFrameworkIdOverride that short-circuits detection (it is the single value every LoadedAssembly-based resolution path reads), is persisted in the assembly-list XML, and is carried across a reload so a runtime change re-resolves against the new framework. The "Set Target Framework" context-menu entry edits it through a dialog with a free-form text box and an always-visible list of common monikers to pick from (the app forces overlay popups, so a dropdown would be clamped inside the small dialog); input is validated and converted from the short TFM users know (net48) to the long FrameworkName form the resolver consumes (.NETFramework,Version=v4.8) via NuGet. The direct DetectTargetFrameworkId callers in the decompiler core (project export, language version) intentionally keep reading the real attribute; only reference resolution is overridden. Resurrects a 2020 prototype (branch tfmoverride) re-implemented against the current ILSpyX/Avalonia code, whose surrounding structures no longer matched. Assisted-by: Claude:claude-opus-4-8:Claude Code --- Directory.Packages.props | 1 + .../packages.lock.json | 11 +- ICSharpCode.ILSpyCmd/packages.lock.json | 11 +- ICSharpCode.ILSpyX/AssemblyList.cs | 13 +- ICSharpCode.ILSpyX/LoadedAssembly.cs | 40 ++++- ILSpy.ReadyToRun/packages.lock.json | 12 +- ILSpy.Tests.Windows/packages.lock.json | 12 +- .../TargetFrameworkOverrideTests.cs | 148 ++++++++++++++++++ ILSpy.Tests/Editor/DecompilerViewTests.cs | 1 + .../Views/TargetFrameworkConverterTests.cs | 76 +++++++++ ILSpy.Tests/packages.lock.json | 12 +- .../SetTargetFrameworkContextMenuEntry.cs | 133 ++++++++++++++++ ILSpy/ILSpy.csproj | 3 + ILSpy/Properties/Resources.Designer.cs | 38 ++++- ILSpy/Properties/Resources.resx | 12 ++ ILSpy/TreeNodes/AssemblyTreeNode.cs | 28 +++- ILSpy/TreeNodes/ReferenceFolderTreeNode.cs | 20 ++- ILSpy/Views/SetTargetFrameworkDialog.axaml | 22 +++ ILSpy/Views/SetTargetFrameworkDialog.axaml.cs | 103 ++++++++++++ ILSpy/Views/TargetFrameworkConverter.cs | 117 ++++++++++++++ ILSpy/packages.lock.json | 11 +- TestPlugin/packages.lock.json | 12 +- 22 files changed, 791 insertions(+), 45 deletions(-) create mode 100644 ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs create mode 100644 ILSpy.Tests/Views/TargetFrameworkConverterTests.cs create mode 100644 ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs create mode 100644 ILSpy/Views/SetTargetFrameworkDialog.axaml create mode 100644 ILSpy/Views/SetTargetFrameworkDialog.axaml.cs create mode 100644 ILSpy/Views/TargetFrameworkConverter.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a816c3da..7241ed21b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -67,6 +67,7 @@ + diff --git a/ICSharpCode.Decompiler.Tests/packages.lock.json b/ICSharpCode.Decompiler.Tests/packages.lock.json index da8e45fa7..3bab2374b 100644 --- a/ICSharpCode.Decompiler.Tests/packages.lock.json +++ b/ICSharpCode.Decompiler.Tests/packages.lock.json @@ -417,11 +417,6 @@ "System.Security.Cryptography.ProtectedData": "8.0.0" } }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "7.6.0", - "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" - }, "NuGet.Packaging": { "type": "Transitive", "resolved": "7.6.0", @@ -1261,6 +1256,12 @@ "resolved": "10.0.9", "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" }, + "NuGet.Frameworks": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, "System.Composition.AttributedModel": { "type": "CentralTransitive", "requested": "[10.0.9, )", diff --git a/ICSharpCode.ILSpyCmd/packages.lock.json b/ICSharpCode.ILSpyCmd/packages.lock.json index 39e4841fc..b571d80a5 100644 --- a/ICSharpCode.ILSpyCmd/packages.lock.json +++ b/ICSharpCode.ILSpyCmd/packages.lock.json @@ -311,11 +311,6 @@ "System.Security.Cryptography.ProtectedData": "8.0.0" } }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "7.6.0", - "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" - }, "NuGet.Packaging": { "type": "Transitive", "resolved": "7.6.0", @@ -412,6 +407,12 @@ "resolved": "0.11.6", "contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA==" }, + "NuGet.Frameworks": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, "System.Collections.Immutable": { "type": "CentralTransitive", "requested": "[10.0.9, )", diff --git a/ICSharpCode.ILSpyX/AssemblyList.cs b/ICSharpCode.ILSpyX/AssemblyList.cs index a5a9bda07..86b0e1cc8 100644 --- a/ICSharpCode.ILSpyX/AssemblyList.cs +++ b/ICSharpCode.ILSpyX/AssemblyList.cs @@ -95,7 +95,10 @@ namespace ICSharpCode.ILSpyX { foreach (var asm in listElement.Elements("Assembly")) { - OpenAssembly((string)asm); + var loaded = OpenAssembly((string)asm); + // Lazy detection has not run yet, so seeding the override here makes it effective + // for the first resolution without a reload. Absent attribute -> null (auto-detect). + loaded.TargetFrameworkIdOverride = (string?)asm.Attribute("TargetFramework"); } this.dirty = false; // OpenAssembly() sets dirty, so reset it afterwards } @@ -175,7 +178,10 @@ namespace ICSharpCode.ILSpyX return new XElement( "List", new XAttribute("name", this.ListName), - assemblies.Where(asm => !asm.IsAutoLoaded).Select(asm => new XElement("Assembly", asm.FileName)) + assemblies.Where(asm => !asm.IsAutoLoaded).Select(asm => new XElement("Assembly", asm.FileName, + asm.TargetFrameworkIdOverride != null + ? new XAttribute("TargetFramework", asm.TargetFrameworkIdOverride) + : null)) ); } @@ -387,6 +393,9 @@ namespace ICSharpCode.ILSpyX fileLoaders: manager?.LoaderRegistry, applyWinRTProjections: ApplyWinRTProjections, useDebugSymbols: UseDebugSymbols); newAsm.IsAutoLoaded = target.IsAutoLoaded; + // Carry the framework hint across the reload (set before any resolution on the fresh + // instance) so the references re-resolve against the overridden target framework. + newAsm.TargetFrameworkIdOverride = target.TargetFrameworkIdOverride; lock (lockObj) { this.assemblies.Remove(target); diff --git a/ICSharpCode.ILSpyX/LoadedAssembly.cs b/ICSharpCode.ILSpyX/LoadedAssembly.cs index e68a1096a..a75ce520b 100644 --- a/ICSharpCode.ILSpyX/LoadedAssembly.cs +++ b/ICSharpCode.ILSpyX/LoadedAssembly.cs @@ -137,13 +137,27 @@ namespace ICSharpCode.ILSpyX string? targetFrameworkId; /// - /// Returns a target framework identifier in the form '<framework>Version=v<version>'. + /// Returns the effective target framework identifier in the form '<framework>,Version=v<version>'. + /// An explicit override wins over the detected value. + /// + public async Task GetTargetFrameworkIdAsync() + { + // An explicit override wins over (and bypasses) detection, even once detection has + // already cached a value, so the user's framework hint drives reference resolution. + // The setter normalizes blank to null, so a non-null override is always a usable TFM. + if (TargetFrameworkIdOverride is { } tfmOverride) + return tfmOverride; + return await GetDetectedTargetFrameworkIdAsync().ConfigureAwait(false); + } + + /// + /// Returns the detected target framework identifier in the form '<framework>,Version=v<version>'. /// Returns an empty string if no TargetFrameworkAttribute was found /// or the file doesn't contain an assembly header, i.e., is only a module. - /// + /// /// Throws an exception if the file does not contain any .NET metadata (e.g. file of unknown format). /// - public async Task GetTargetFrameworkIdAsync() + public async Task GetDetectedTargetFrameworkIdAsync() { var value = LazyInit.VolatileRead(ref targetFrameworkId); if (value == null) @@ -373,6 +387,26 @@ namespace ICSharpCode.ILSpyX /// public string? PdbFileName { get; private set; } + string? targetFrameworkIdOverride; + + /// + /// Overrides the target framework used to resolve this assembly's references, in the + /// '<framework>,Version=v<version>' form (e.g. ".NETFramework,Version=v4.8"). + /// Null means the framework detected from the TargetFrameworkAttribute is used. Blank or + /// whitespace-only values (e.g. a hand-edited assembly-list XML with TargetFramework="") + /// are normalized to null and incidental whitespace is trimmed, so an override never + /// suppresses detection with an empty effective TFM. + /// Set this before the assembly's references are first resolved (initial load) or follow + /// a change with a reload: the resolver is built once per LoadedAssembly and frozen. + /// + public string? TargetFrameworkIdOverride { + get => targetFrameworkIdOverride; + set { + var trimmed = value?.Trim(); + targetFrameworkIdOverride = string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + } + async Task LoadAsync(Task? streamTask) { using var stream = await PrepareStream(); diff --git a/ILSpy.ReadyToRun/packages.lock.json b/ILSpy.ReadyToRun/packages.lock.json index 1eaad86c7..01ccfa8f5 100644 --- a/ILSpy.ReadyToRun/packages.lock.json +++ b/ILSpy.ReadyToRun/packages.lock.json @@ -339,11 +339,6 @@ "System.Security.Cryptography.ProtectedData": "8.0.0" } }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "7.6.0", - "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" - }, "NuGet.Packaging": { "type": "Transitive", "resolved": "7.6.0", @@ -1403,6 +1398,7 @@ "Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )", "Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "NuGet.Frameworks": "[7.6.0, )", "NuGet.Protocol": "[7.6.0, )", "ProDataGrid": "[12.0.0, )", "Svg.Controls.Skia.Avalonia": "[12.0.0.11, )", @@ -1573,6 +1569,12 @@ "resolved": "0.11.6", "contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA==" }, + "NuGet.Frameworks": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, "NuGet.Protocol": { "type": "CentralTransitive", "requested": "[7.6.0, )", diff --git a/ILSpy.Tests.Windows/packages.lock.json b/ILSpy.Tests.Windows/packages.lock.json index 4a409c983..61c7eced5 100644 --- a/ILSpy.Tests.Windows/packages.lock.json +++ b/ILSpy.Tests.Windows/packages.lock.json @@ -561,11 +561,6 @@ "System.Security.Cryptography.ProtectedData": "8.0.0" } }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "7.6.0", - "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" - }, "NuGet.Packaging": { "type": "Transitive", "resolved": "7.6.0", @@ -1623,6 +1618,7 @@ "Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )", "Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "NuGet.Frameworks": "[7.6.0, )", "NuGet.Protocol": "[7.6.0, )", "ProDataGrid": "[12.0.0, )", "Svg.Controls.Skia.Avalonia": "[12.0.0.11, )", @@ -1800,6 +1796,12 @@ "resolved": "0.11.6", "contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA==" }, + "NuGet.Frameworks": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, "NuGet.Protocol": { "type": "CentralTransitive", "requested": "[7.6.0, )", diff --git a/ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs b/ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs new file mode 100644 index 000000000..71a50980a --- /dev/null +++ b/ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX; + +using ICSharpCode.ILSpy.AppEnv; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// The target-framework override lets the user hint the TFM used to resolve an assembly's +/// references instead of the one detected from its TargetFrameworkAttribute. These tests pin +/// the load-bearing behaviour: the override wins over detection, survives the assembly-list +/// XML round-trip, defaults to null for assemblies persisted without it, and is carried across +/// a reload (which is how a runtime change re-resolves against the new framework). +/// +[TestFixture] +public class TargetFrameworkOverrideTests +{ + static AssemblyListManager Manager => AppComposition.Current.GetExport().AssemblyListManager; + + [AvaloniaTest] + public async Task Override_Wins_Over_Detected_Tfm_Even_After_Detection_Cached() + { + var list = Manager.CreateList("tfm-override-" + Guid.NewGuid().ToString("N")); + var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmOverrideFixture")); + + // Force detection to run and cache its result first, so the test also proves the override + // takes precedence over an already-cached detected value. + var detected = await asm.GetTargetFrameworkIdAsync(); + asm.TargetFrameworkIdOverride = ".NETFramework,Version=v4.7.2"; + + (await asm.GetTargetFrameworkIdAsync()).Should().Be(".NETFramework,Version=v4.7.2"); + detected.Should().NotBe(".NETFramework,Version=v4.7.2", + "the fixture is not a .NET Framework 4.7.2 assembly; the override is what changes the result"); + } + + [AvaloniaTest] + public void Override_Survives_SaveAsXml_Roundtrip() + { + var list = Manager.CreateList("tfm-roundtrip-" + Guid.NewGuid().ToString("N")); + var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmRoundtripFixture")); + asm.TargetFrameworkIdOverride = ".NETCoreApp,Version=v6.0"; + + var reloaded = new AssemblyList(Manager, list.SaveAsXml()); + + reloaded.GetAssemblies().Should().ContainSingle() + .Which.TargetFrameworkIdOverride.Should().Be(".NETCoreApp,Version=v6.0"); + } + + [AvaloniaTest] + public void Assembly_Without_Override_Persists_No_Attribute_And_Loads_Null() + { + var list = Manager.CreateList("tfm-noattr-" + Guid.NewGuid().ToString("N")); + list.OpenAssembly(FixtureAssembly.Emit("TfmNoAttrFixture")); + + var xml = list.SaveAsXml(); + xml.Elements("Assembly").Should().OnlyContain(e => e.Attribute("TargetFramework") == null, + "no attribute is written when there is no override (backward-compatible XML)"); + + var reloaded = new AssemblyList(Manager, xml); + reloaded.GetAssemblies().Should().ContainSingle() + .Which.TargetFrameworkIdOverride.Should().BeNull(); + } + + [AvaloniaTest] + [TestCase("")] + [TestCase(" ")] + public async Task Blank_Override_Normalizes_To_Null_And_Falls_Back_To_Detected(string blank) + { + var list = Manager.CreateList("tfm-blank-" + Guid.NewGuid().ToString("N")); + var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmBlankFixture")); + var detected = await asm.GetTargetFrameworkIdAsync(); + + // A blank/whitespace override must not suppress detection with an empty effective TFM. + asm.TargetFrameworkIdOverride = blank; + + asm.TargetFrameworkIdOverride.Should().BeNull(); + (await asm.GetTargetFrameworkIdAsync()).Should().Be(detected); + } + + [AvaloniaTest] + public void Override_Is_Trimmed() + { + var list = Manager.CreateList("tfm-trim-" + Guid.NewGuid().ToString("N")); + var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmTrimFixture")); + + asm.TargetFrameworkIdOverride = " .NETCoreApp,Version=v8.0 "; + + asm.TargetFrameworkIdOverride.Should().Be(".NETCoreApp,Version=v8.0"); + } + + [AvaloniaTest] + public async Task Blank_TargetFramework_Attribute_In_Xml_Loads_As_Null() + { + // A hand-edited assembly-list XML may carry TargetFramework="". Loading it must behave + // like no override at all, not pin the effective TFM to an empty string. + var list = Manager.CreateList("tfm-blankxml-" + Guid.NewGuid().ToString("N")); + var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmBlankXmlFixture")); + var xml = list.SaveAsXml(); + xml.Elements("Assembly").Single().SetAttributeValue("TargetFramework", ""); + + var reloaded = new AssemblyList(Manager, xml); + var loaded = reloaded.GetAssemblies().Should().ContainSingle().Subject; + + loaded.TargetFrameworkIdOverride.Should().BeNull(); + (await loaded.GetTargetFrameworkIdAsync()).Should().Be(await asm.GetTargetFrameworkIdAsync()); + } + + [AvaloniaTest] + public void ReloadAssembly_Carries_Override_To_New_Instance() + { + var list = Manager.CreateList("tfm-reload-" + Guid.NewGuid().ToString("N")); + var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmReloadFixture")); + asm.TargetFrameworkIdOverride = ".NETStandard,Version=v2.0"; + + var reloaded = list.ReloadAssembly(asm); + + ((object?)reloaded).Should().NotBeNull(); + reloaded!.Should().NotBeSameAs(asm); + reloaded.TargetFrameworkIdOverride.Should().Be(".NETStandard,Version=v2.0"); + } +} diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs index b342af0ba..82c5417cb 100644 --- a/ILSpy.Tests/Editor/DecompilerViewTests.cs +++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs @@ -94,6 +94,7 @@ public class DecompilerViewTests // Assert — every header and a representative referenced assembly land in the listing. tab.Text.Should().Contain("Detected TargetFramework-Id:"); + tab.Text.Should().Contain("Effective TargetFramework-Id:"); tab.Text.Should().Contain("Detected RuntimePack:"); tab.Text.Should().Contain("Referenced assemblies (in metadata order):"); tab.Text.Should().Contain("System.Runtime"); diff --git a/ILSpy.Tests/Views/TargetFrameworkConverterTests.cs b/ILSpy.Tests/Views/TargetFrameworkConverterTests.cs new file mode 100644 index 000000000..9c42ec2e8 --- /dev/null +++ b/ILSpy.Tests/Views/TargetFrameworkConverterTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// The Set-Target-Framework dialog accepts the short TFMs users know (net48, net6.0, ...) but +/// ILSpy resolves with the long FrameworkName form (.NETFramework,Version=v4.8). These tests +/// pin that conversion/validation, which is the part of the dialog worth unit-testing in +/// isolation from the window. +/// +[TestFixture] +public class TargetFrameworkConverterTests +{ + [TestCase("net48", ".NETFramework,Version=v4.8")] + [TestCase("net472", ".NETFramework,Version=v4.7.2")] + [TestCase("net6.0", ".NETCoreApp,Version=v6.0")] + [TestCase("net8.0", ".NETCoreApp,Version=v8.0")] + [TestCase("netstandard2.0", ".NETStandard,Version=v2.0")] + [TestCase("netcoreapp3.1", ".NETCoreApp,Version=v3.1")] + public void Short_Tfm_Parses_To_FrameworkName(string input, string expected) + { + TargetFrameworkConverter.TryParseToFrameworkName(input, out var frameworkName, out _) + .Should().BeTrue(); + frameworkName.Should().Be(expected); + } + + [Test] + public void Long_FrameworkName_Is_Also_Accepted() + { + TargetFrameworkConverter.TryParseToFrameworkName(".NETCoreApp,Version=v6.0", out var frameworkName, out _) + .Should().BeTrue(); + frameworkName.Should().Be(".NETCoreApp,Version=v6.0"); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("not-a-tfm")] + [TestCase("garbage123")] + public void Invalid_Input_Is_Rejected_With_An_Error(string input) + { + TargetFrameworkConverter.TryParseToFrameworkName(input, out var frameworkName, out var error) + .Should().BeFalse(); + frameworkName.Should().BeNull(); + error.Should().NotBeNullOrEmpty(); + } + + [TestCase(".NETFramework,Version=v4.8", "net48")] + [TestCase(".NETCoreApp,Version=v6.0", "net6.0")] + [TestCase(".NETStandard,Version=v2.0", "netstandard2.0")] + public void FrameworkName_Converts_To_Short_Folder_Name_For_Display(string frameworkName, string expected) + { + TargetFrameworkConverter.ToShortFolderName(frameworkName).Should().Be(expected); + } +} diff --git a/ILSpy.Tests/packages.lock.json b/ILSpy.Tests/packages.lock.json index 7d4747191..95a8ee24f 100644 --- a/ILSpy.Tests/packages.lock.json +++ b/ILSpy.Tests/packages.lock.json @@ -524,11 +524,6 @@ "System.Security.Cryptography.ProtectedData": "8.0.0" } }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "7.6.0", - "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" - }, "NuGet.Packaging": { "type": "Transitive", "resolved": "7.6.0", @@ -1586,6 +1581,7 @@ "Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )", "Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "NuGet.Frameworks": "[7.6.0, )", "NuGet.Protocol": "[7.6.0, )", "ProDataGrid": "[12.0.0, )", "Svg.Controls.Skia.Avalonia": "[12.0.0.11, )", @@ -1775,6 +1771,12 @@ "resolved": "0.11.6", "contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA==" }, + "NuGet.Frameworks": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, "NuGet.Protocol": { "type": "CentralTransitive", "requested": "[7.6.0, )", diff --git a/ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs b/ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs new file mode 100644 index 000000000..82bda6d7f --- /dev/null +++ b/ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs @@ -0,0 +1,133 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Composition; +using System.Linq; +using System.Threading.Tasks; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.Views; + +namespace ICSharpCode.ILSpy.Commands +{ + /// + /// Right-click an assembly -> "Set Target Framework". Overrides the framework ILSpy resolves the + /// assembly's references against (instead of the one detected from its TargetFrameworkAttribute), + /// then reloads so the references re-resolve. Leaving the dialog blank clears the override. + /// + [ExportContextMenuEntry(Header = nameof(Resources.SetTargetFramework), Category = "Debug", Order = 420)] + [Shared] + public sealed class SetTargetFrameworkContextMenuEntry : IContextMenuEntry + { + readonly AssemblyTreeModel assemblyTreeModel; + + [ImportingConstructor] + public SetTargetFrameworkContextMenuEntry(AssemblyTreeModel assemblyTreeModel) + { + this.assemblyTreeModel = assemblyTreeModel; + } + + public bool IsEnabled(TextViewContext context) => true; + + public bool IsVisible(TextViewContext context) + => context.SelectedTreeNodes?.Length == 1 + && context.SelectedTreeNodes[0] is AssemblyTreeNode asm + && asm.LoadedAssembly.IsLoadedAsValidAssembly; + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes?.FirstOrDefault() is not AssemblyTreeNode node) + return; + ExecuteAsync(node).HandleExceptions(); + } + + async Task ExecuteAsync(AssemblyTreeNode node) + { + var owner = UiContext.MainWindow; + if (owner == null) + return; + var assembly = node.LoadedAssembly; + var current = await assembly.GetTargetFrameworkIdAsync(); + + var result = await new SetTargetFrameworkDialog(current).ShowDialog(owner); + if (result == null) + return; // cancelled + var frameworkName = result.Length == 0 ? null : result; + if (frameworkName == assembly.TargetFrameworkIdOverride + || (assembly.TargetFrameworkIdOverride == null && frameworkName == current)) + return; // unchanged + + ApplyOverride(assemblyTreeModel, node, frameworkName); + } + + /// + /// Sets the override, reloads so references re-resolve against the new framework, and restores + /// the tree selection. Shared by "Set Target Framework" and "Reset Target Framework". + /// + internal static void ApplyOverride(AssemblyTreeModel assemblyTreeModel, AssemblyTreeNode node, string? frameworkName) + { + var assembly = node.LoadedAssembly; + // Snapshot the node path before ReloadAssembly swaps the LoadedAssembly out from under + // the tree, then re-select the equivalent node so the user keeps their position. + var path = AssemblyTreeModel.GetPathForNode(node); + assembly.TargetFrameworkIdOverride = frameworkName; + assembly.AssemblyList.ReloadAssembly(assembly.FileName); + var restored = assemblyTreeModel.FindNodeByPath(path, returnBestMatch: true); + if (restored != null) + assemblyTreeModel.SelectNode(restored); + assemblyTreeModel.RefreshDecompiledView(); + } + } + + /// + /// Right-click an assembly that carries a manual target-framework override -> "Reset Target + /// Framework". Clears the override and reloads so references re-resolve against the framework + /// detected from the TargetFrameworkAttribute again. Only shown while an override is in effect, + /// so the user can revert without reopening the dialog. + /// + [ExportContextMenuEntry(Header = nameof(Resources.ResetTargetFramework), Category = "Debug", Order = 421)] + [Shared] + public sealed class ResetTargetFrameworkContextMenuEntry : IContextMenuEntry + { + readonly AssemblyTreeModel assemblyTreeModel; + + [ImportingConstructor] + public ResetTargetFrameworkContextMenuEntry(AssemblyTreeModel assemblyTreeModel) + { + this.assemblyTreeModel = assemblyTreeModel; + } + + public bool IsEnabled(TextViewContext context) => true; + + public bool IsVisible(TextViewContext context) + => context.SelectedTreeNodes?.Length == 1 + && context.SelectedTreeNodes[0] is AssemblyTreeNode asm + && asm.LoadedAssembly.IsLoadedAsValidAssembly + && asm.LoadedAssembly.TargetFrameworkIdOverride != null; + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes?.FirstOrDefault() is not AssemblyTreeNode node) + return; + SetTargetFrameworkContextMenuEntry.ApplyOverride(assemblyTreeModel, node, null); + } + } +} diff --git a/ILSpy/ILSpy.csproj b/ILSpy/ILSpy.csproj index 86b8bb5ab..c9855032b 100644 --- a/ILSpy/ILSpy.csproj +++ b/ILSpy/ILSpy.csproj @@ -120,6 +120,9 @@ + + + diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index 26cf42196..1ab64a05c 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -2836,6 +2836,15 @@ namespace ICSharpCode.ILSpy.Properties { } } + /// + /// Looks up a localized string similar to Reset Target Framework. + /// + public static string ResetTargetFramework { + get { + return ResourceManager.GetString("ResetTargetFramework", resourceCulture); + } + } + /// /// Looks up a localized string similar to Reset to defaults. /// @@ -3006,7 +3015,34 @@ namespace ICSharpCode.ILSpy.Properties { return ResourceManager.GetString("SelectPDB", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Set Target Framework.... + /// + public static string SetTargetFramework { + get { + return ResourceManager.GetString("SetTargetFramework", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Target framework:. + /// + public static string TargetFramework { + get { + return ResourceManager.GetString("TargetFramework", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown target framework. Enter a moniker such as net48 or net6.0.. + /// + public static string InvalidTargetFramework { + get { + return ResourceManager.GetString("InvalidTargetFramework", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select target folder. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 16b6051ce..423ba5286 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -780,6 +780,9 @@ Are you sure you want to continue? Insert using declarations + + Unknown target framework. Enter a moniker such as net48 or net6.0. + Are you sure that you want to delete the selected assembly list? @@ -976,6 +979,9 @@ Do you want to continue? Rename list + + Reset Target Framework + Reset to defaults @@ -1039,6 +1045,9 @@ Do you want to continue? Select version of language to output (Alt+E) + + Set Target Framework... + You must restart ILSpy for the change to take effect. @@ -1126,6 +1135,9 @@ Do you want to continue? Tab size: + + Target framework: + Theme diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 47f0e3e1e..ba94e57c5 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -27,6 +27,8 @@ using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Media; +using AvaloniaEdit.Highlighting; + using ICSharpCode.Decompiler; using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; using ICSharpCode.Decompiler.Metadata; @@ -38,11 +40,12 @@ using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpy; using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Controls.TreeView; using ICSharpCode.ILSpy.Languages; namespace ICSharpCode.ILSpy.TreeNodes { - public sealed class AssemblyTreeNode : ILSpyTreeNode + public sealed class AssemblyTreeNode : ILSpyTreeNode, IRichTextNode { readonly LoadedAssembly assembly; string? loadError; @@ -228,6 +231,29 @@ namespace ICSharpCode.ILSpy.TreeNodes public override object Text => assembly.Text; + /// + /// The label already embeds the effective target framework in parentheses (see + /// , e.g. "Foo (1.0.0.0, .NETFramework, v4.8)"). When that + /// framework comes from a user override, this bolds just that TFM fragment so overridden + /// assemblies stand out without bolding the whole row. Returns null (plain ) + /// when there is no override. + /// + public RichText? CreateRichText() + { + if (assembly.TargetFrameworkIdOverride is not { } tfmOverride) + return null; + string label = assembly.Text; + // LoadedAssembly.Text renders the TFM as the effective id with "Version=" replaced by a + // space; reproduce that and locate it so only the framework fragment is emphasized. + string fragment = tfmOverride.Replace("Version=", " "); + int start = label.LastIndexOf(fragment, StringComparison.Ordinal); + if (start < 0) + return null; + var model = new RichTextModel(); + model.SetFontWeight(start, fragment.Length, FontWeight.Bold); + return new RichText(label, model); + } + // ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not // depend on the active language. The full file path uniquely identifies the assembly. public override string ToString() => assembly.FileName; diff --git a/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs b/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs index 2e87742e7..a72d81bce 100644 --- a/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs +++ b/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System.Linq; +using System.Threading.Tasks; using Avalonia.Threading; @@ -25,6 +26,7 @@ using ICSharpCode.Decompiler.Metadata; using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpyX; namespace ICSharpCode.ILSpy.TreeNodes { @@ -61,9 +63,10 @@ namespace ICSharpCode.ILSpy.TreeNodes public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) { - var targetFramework = parentAssembly.LoadedAssembly.GetTargetFrameworkIdAsync().GetAwaiter().GetResult(); - var runtimePack = parentAssembly.LoadedAssembly.GetRuntimePackAsync().GetAwaiter().GetResult(); - output.WriteLine($"Detected TargetFramework-Id: {targetFramework}"); + var loadedAssembly = parentAssembly.LoadedAssembly; + var (detectedTargetFramework, effectiveTargetFramework, runtimePack) = GetFrameworkInfoAsync(loadedAssembly).GetAwaiter().GetResult(); + output.WriteLine($"Detected TargetFramework-Id: {detectedTargetFramework}"); + output.WriteLine($"Effective TargetFramework-Id: {effectiveTargetFramework}"); output.WriteLine($"Detected RuntimePack: {runtimePack}"); // Children realise lazily on the UI thread; we may run from a background decompile. @@ -86,5 +89,16 @@ namespace ICSharpCode.ILSpy.TreeNodes output.WriteLine(); } } + + // One await chain instead of three separate blocking GetResult() calls: each blocked wait + // can park a thread-pool thread, so resolving all three framework values in a single async + // method and blocking once is cheaper. + static async Task<(string Detected, string Effective, string RuntimePack)> GetFrameworkInfoAsync(LoadedAssembly loadedAssembly) + { + var detected = await loadedAssembly.GetDetectedTargetFrameworkIdAsync().ConfigureAwait(false); + var effective = await loadedAssembly.GetTargetFrameworkIdAsync().ConfigureAwait(false); + var runtimePack = await loadedAssembly.GetRuntimePackAsync().ConfigureAwait(false); + return (detected, effective, runtimePack); + } } } diff --git a/ILSpy/Views/SetTargetFrameworkDialog.axaml b/ILSpy/Views/SetTargetFrameworkDialog.axaml new file mode 100644 index 000000000..8e15a3509 --- /dev/null +++ b/ILSpy/Views/SetTargetFrameworkDialog.axaml @@ -0,0 +1,22 @@ + + + + + + + + +