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 @@ + + + + + + + + +