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)