diff --git a/ILSpy.Tests/AssemblyTree/NavigateToForwardedMemberTests.cs b/ILSpy.Tests/AssemblyTree/NavigateToForwardedMemberTests.cs new file mode 100644 index 000000000..75c7550ce --- /dev/null +++ b/ILSpy.Tests/AssemblyTree/NavigateToForwardedMemberTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// 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.IO; +using System.Linq; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpyX; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.AssemblyTree; + +/// +/// A member ID whose declaring type reaches the opened assembly only as a type forwarder: +/// the ID names System.String, but a facade like System.Runtime.dll carries no type rows at +/// all - the member lives in the assembly the forwarder points at, and the resolver has to +/// follow it there. This is what --navigateto hits on any modern framework assembly. +/// +[TestFixture] +public class NavigateToForwardedMemberTests +{ + static string FacadePath => Path.Combine( + Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll"); + + [Test] + public async Task NavigateTo_Follows_A_Type_Forwarder_Into_The_Assembly_Holding_The_Member() + { + string facadePath = FacadePath; + File.Exists(facadePath).Should().BeTrue( + "the running framework must ship the System.Runtime facade this test navigates through"); + + var assemblyList = new AssemblyList(); + var facade = assemblyList.OpenAssembly(facadePath); + var facadeFile = await facade.GetMetadataFileOrNullAsync(); + facadeFile.Should().NotBeNull(); + + // Guard the premise: if the facade defined System.String itself, the plain lookup + // would answer and the forwarder path under test would never run. + facadeFile!.Metadata.TypeDefinitions + .Select(h => facadeFile.Metadata.GetString(facadeFile.Metadata.GetTypeDefinition(h).Name)) + .Should().NotContain("String", "System.Runtime is a facade of forwarders, not definitions"); + facadeFile.Metadata.ExportedTypes.Should().NotBeEmpty("the facade forwards its types"); + + var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies( + "M:System.String.Concat(System.String,System.String)", new[] { facade }); + + entity.Should().NotBeNull("the ID must resolve through the forwarder"); + entity!.Name.Should().Be("Concat"); + entity.DeclaringType!.FullName.Should().Be("System.String"); + entity.ParentModule!.MetadataFile!.FileName.Should().NotBe(facadePath, + "the member rows live in the forwarder's target assembly, not in the facade"); + } + + [Test] + public async Task NavigateTo_Returns_Null_For_A_Member_That_No_Forwarder_Leads_To() + { + var assemblyList = new AssemblyList(); + var facade = assemblyList.OpenAssembly(FacadePath); + await facade.GetMetadataFileOrNullAsync(); + + var entity = AssemblyTreeModel.FindEntityInRelevantAssemblies( + "M:System.String.ThisMethodDoesNotExist", new[] { facade }); + + entity.Should().BeNull("an unresolvable member must not resolve to some other member"); + } +} diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 1f6a27edd..35f08df6e 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -34,7 +34,6 @@ using ICSharpCode.Decompiler; using ICSharpCode.Decompiler.Documentation; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.TypeSystem; -using ICSharpCode.Decompiler.TypeSystem.Implementation; using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; @@ -781,63 +780,67 @@ namespace ICSharpCode.ILSpy.AssemblyTree internal static IEntity? FindEntityInRelevantAssemblies(string navigateTo, IEnumerable relevantAssemblies) { - ITypeReference typeRef; - IMemberReference? memberRef = null; - if (navigateTo.StartsWith("T:", StringComparison.Ordinal)) - { - typeRef = IdStringProvider.ParseTypeName(navigateTo); - } - else - { - memberRef = IdStringProvider.ParseMemberIdString(navigateTo); - typeRef = memberRef.DeclaringTypeReference; - } - foreach (var asm in relevantAssemblies) - { - var module = asm.GetMetadataFileOrNull(); - if (module != null && CanResolveTypeInPEFile(module, typeRef, out var typeHandle)) - { - ICompilation compilation = typeHandle.Kind == HandleKind.ExportedType - ? new DecompilerTypeSystem(module, module.GetAssemblyResolver()) - : new SimpleCompilation((PEFile)module, MinimalCorlib.Instance); - return memberRef == null - ? typeRef.Resolve(new SimpleTypeResolveContext(compilation)) as ITypeDefinition - : memberRef.Resolve(new SimpleTypeResolveContext(compilation)); - } - } - return null; + // Reference assemblies are skipped so the search keeps looking for another + // assembly that might have a usable definition. + IReadOnlyList modules = [.. from asm in relevantAssemblies let mod = asm.GetMetadataFileOrNull() where mod != null && !mod.IsReferenceAssembly() select mod]; + var (module, handle) = IdStringProvider.FindEntity(navigateTo, modules); + if (module == null || handle.IsNil) + (module, handle) = FindMemberViaTypeForwarders(navigateTo, modules); + if (module == null || handle.IsNil) + return null; + var metadataModule = module.GetLoadedAssembly().GetTypeSystemOrNull()?.MainModule as MetadataModule; + if (metadataModule == null) + return null; + return metadataModule.ResolveEntity(handle); } - static bool CanResolveTypeInPEFile(MetadataFile module, ITypeReference typeRef, out EntityHandle typeHandle) + /// + /// A member ID whose declaring type is present in the given modules only as a type + /// forwarder cannot be found by alone: + /// the member rows live in the assembly the forwarder points to. Resolve that + /// assembly and search the member there. + /// + static (MetadataFile? Module, EntityHandle Handle) FindMemberViaTypeForwarders(string navigateTo, IReadOnlyList modules) { - // Reference assemblies are skipped so the loop keeps looking for an actual definition. - if (module.IsReferenceAssembly()) + if (navigateTo.Length < 2 || navigateTo[1] != ':' || navigateTo.StartsWith("T:", StringComparison.Ordinal)) + return default; + int parenPos = navigateTo.IndexOf('('); + if (parenPos < 0) + parenPos = navigateTo.LastIndexOf('~'); + if (parenPos < 0) + parenPos = navigateTo.Length; + int dotPos = navigateTo.LastIndexOf('.', parenPos - 1); + if (dotPos <= 2) + return default; + string declaringTypeId = "T:" + navigateTo[2..dotPos]; + // Forwarder chains are short; the bound only guards against cycles. + for (int depth = 0; depth < 16; depth++) { - typeHandle = default; - return false; + var (module, typeHandle) = IdStringProvider.FindEntity(declaringTypeId, modules); + if (module == null || typeHandle.Kind != HandleKind.ExportedType) + return default; + var target = ResolveForwarderTarget(module, (ExportedTypeHandle)typeHandle); + if (target == null) + return default; + modules = [target]; + var result = IdStringProvider.FindEntity(navigateTo, modules); + if (!result.Handle.IsNil) + return result; } + return default; + } - switch (typeRef) - { - case GetPotentiallyNestedClassTypeReference topLevelType: - typeHandle = topLevelType.ResolveInPEFile(module); - return !typeHandle.IsNil; - case NestedTypeReference nestedType: - if (!CanResolveTypeInPEFile(module, nestedType.DeclaringTypeReference, out typeHandle)) - return false; - if (typeHandle.Kind == HandleKind.ExportedType) - return true; - var typeDef = module.Metadata.GetTypeDefinition((TypeDefinitionHandle)typeHandle); - typeHandle = typeDef.GetNestedTypes().FirstOrDefault(t => { - var td = module.Metadata.GetTypeDefinition(t); - var typeName = ReflectionHelper.SplitTypeParameterCountFromReflectionName(module.Metadata.GetString(td.Name), out int typeParameterCount); - return nestedType.AdditionalTypeParameterCount == typeParameterCount && nestedType.Name == typeName; - }); - return !typeHandle.IsNil; - default: - typeHandle = default; - return false; - } + static MetadataFile? ResolveForwarderTarget(MetadataFile module, ExportedTypeHandle handle) + { + var metadata = module.Metadata; + var implementation = metadata.GetExportedType(handle).Implementation; + // Nested forwarded types point at their enclosing forwarder entry. + while (implementation.Kind == HandleKind.ExportedType) + implementation = metadata.GetExportedType((ExportedTypeHandle)implementation).Implementation; + if (implementation.Kind != HandleKind.AssemblyReference) + return null; + var assemblyReference = new Decompiler.Metadata.AssemblyReference(module, (AssemblyReferenceHandle)implementation); + return module.GetAssemblyResolver().Resolve(assemblyReference); } void LoadAssemblies(IEnumerable fileNames, List? loadedAssemblies = null, bool focusNode = true)