From 5a6972323d740bd6ac0ff12abaa723b01fc4dd26 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 27 Jul 2026 07:17:23 +0200 Subject: [PATCH] Fix #633: add an Overrides analysis showing overridden base members The analyzer panel could only walk downward (Overridden By, Implemented By); finding what a member overrides required manually climbing base types. The upward interface direction already exists as the Implements analysis, so this adds the missing base-class direction as its dual. The analyzer walks the override chain member by member via InheritanceHelper.GetBaseMember instead of collecting all signature-equal base members, so it stops at 'new virtual' shadow boundaries, which hide any further base members from overriding. No assembly-scope scan is needed; the walk is a direct type-system lookup. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Builtin/MemberOverridesAnalyzer.cs | 73 ++++++ .../Library/MemberOverridesAnalyzerTests.cs | 209 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 ICSharpCode.ILSpyX/Analyzers/Builtin/MemberOverridesAnalyzer.cs create mode 100644 ILSpy.Tests/Analyzers/Library/MemberOverridesAnalyzerTests.cs diff --git a/ICSharpCode.ILSpyX/Analyzers/Builtin/MemberOverridesAnalyzer.cs b/ICSharpCode.ILSpyX/Analyzers/Builtin/MemberOverridesAnalyzer.cs new file mode 100644 index 000000000..d3175c395 --- /dev/null +++ b/ICSharpCode.ILSpyX/Analyzers/Builtin/MemberOverridesAnalyzer.cs @@ -0,0 +1,73 @@ +// 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.Collections.Generic; +using System.Composition; +using System.Diagnostics; + +using ICSharpCode.Decompiler.TypeSystem; + +namespace ICSharpCode.ILSpyX.Analyzers.Builtin +{ + /// + /// Shows the base members overridden by a member; the dual of the "Overridden By" analysis. + /// + [ExportAnalyzer(Header = "Overrides", Order = 35)] + [Shared] + class MemberOverridesAnalyzer : IAnalyzer + { + public IEnumerable Analyze(ISymbol analyzedSymbol, AnalyzerContext context) + { + Debug.Assert(analyzedSymbol is IMember); + var member = (IMember)analyzedSymbol; + // Walk the override chain member by member instead of collecting all + // signature-equal base members: this stops at 'new virtual' shadow + // boundaries, which hide any further base members from overriding. + var visitedMembers = new HashSet(); + while (member.IsOverride) + { + member = member.MemberDefinition; + if (!visitedMembers.Add(member)) + { + // abort if we seem to be in an infinite loop (cyclic inheritance) + break; + } + var baseMember = InheritanceHelper.GetBaseMember(member); + if (baseMember == null) + break; + yield return baseMember; + member = baseMember; + } + } + + public bool Show(ISymbol? symbol) + { + switch (symbol?.SymbolKind) + { + case SymbolKind.Event: + case SymbolKind.Indexer: + case SymbolKind.Method: + case SymbolKind.Property: + return ((IMember)symbol).IsOverride; + + default: + return false; + } + } + } +} diff --git a/ILSpy.Tests/Analyzers/Library/MemberOverridesAnalyzerTests.cs b/ILSpy.Tests/Analyzers/Library/MemberOverridesAnalyzerTests.cs new file mode 100644 index 000000000..e4b2737d1 --- /dev/null +++ b/ILSpy.Tests/Analyzers/Library/MemberOverridesAnalyzerTests.cs @@ -0,0 +1,209 @@ +// 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; +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.Decompiler.TypeSystem.Implementation; +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Analyzers; +using ICSharpCode.ILSpyX.Analyzers.Builtin; + +using ICSharpCode.ILSpy.Languages; + +using NSubstitute; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers.Library; + +[TestFixture] +public class MemberOverridesAnalyzerTests +{ + static readonly SymbolKind[] ValidSymbolKinds = { SymbolKind.Event, SymbolKind.Indexer, SymbolKind.Method, SymbolKind.Property }; + static readonly SymbolKind[] InvalidSymbolKinds = + Enum.GetValues(typeof(SymbolKind)).Cast().Except(ValidSymbolKinds).ToArray(); + + ICompilation testAssembly = null!; + + [OneTimeSetUp] + public void Setup() + { + var fileName = GetType().Assembly.Location; + using var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read); + var module = new PEFile(fileName, stream, PEStreamOptions.PrefetchEntireImage, MetadataReaderOptions.None); + testAssembly = new SimpleCompilation(module.WithOptions(TypeSystemOptions.Default), MinimalCorlib.Instance); + } + + [Test] + public void VerifyDoesNotShowForNoSymbol() + { + var analyzer = new MemberOverridesAnalyzer(); + var shouldShow = analyzer.Show(symbol: null!); + Assert.That(!shouldShow, "The analyzer will be unexpectedly shown for no symbol"); + } + + [Test] + [TestCaseSource(nameof(InvalidSymbolKinds))] + public void VerifyDoesNotShowForNonMembers(SymbolKind symbolKind) + { + var symbolMock = Substitute.For(); + symbolMock.SymbolKind.Returns(symbolKind); + var analyzer = new MemberOverridesAnalyzer(); + var shouldShow = analyzer.Show(symbolMock); + Assert.That(!shouldShow, $"The analyzer will be unexpectedly shown for symbol '{symbolKind}'"); + } + + [Test] + [TestCaseSource(nameof(ValidSymbolKinds))] + public void VerifyDoesNotShowForNonOverrideMembers(SymbolKind symbolKind) + { + var memberMock = SetupMemberMock(symbolKind, isOverride: false); + var analyzer = new MemberOverridesAnalyzer(); + var shouldShow = analyzer.Show(memberMock); + Assert.That(!shouldShow, $"The analyzer will be unexpectedly shown for non-override symbol '{symbolKind}'"); + } + + [Test] + [TestCaseSource(nameof(ValidSymbolKinds))] + public void VerifyShowsForOverrideMembers(SymbolKind symbolKind) + { + var memberMock = SetupMemberMock(symbolKind, isOverride: true); + var analyzer = new MemberOverridesAnalyzer(); + var shouldShow = analyzer.Show(memberMock); + Assert.That(shouldShow, $"The analyzer will not be shown for override symbol '{symbolKind}'"); + } + + [Test] + public void VerifyReturnsAllOverriddenBaseMembers() + { + var symbol = SetupMethodForAnalysis(typeof(LeafClass), nameof(LeafClass.TestMethod)); + var analyzer = new MemberOverridesAnalyzer(); + + var results = analyzer.Analyze(symbol, CreateContext()).OfType().ToList(); + + // The analyzer walks the override chain nearest-base-first; the order is what + // the analyzer panel displays, so assert it. + Assert.That(results.Select(r => r.DeclaringTypeDefinition!.Name), + Is.EqualTo(new[] { nameof(MiddleClass), nameof(GrandBaseClass) })); + } + + [Test] + public void VerifyDoesNotReturnInterfaceMembers() + { + // Interface members are the "Implements" analysis; "Overrides" only walks base classes. + var symbol = SetupMethodForAnalysis(typeof(LeafClass), nameof(LeafClass.TestMethod)); + var analyzer = new MemberOverridesAnalyzer(); + + var results = analyzer.Analyze(symbol, CreateContext()).OfType().ToList(); + + Assert.That(results.Select(r => r.DeclaringTypeDefinition!.Kind), + Has.All.Not.EqualTo(TypeKind.Interface)); + } + + [Test] + public void VerifyReturnsOverriddenProperty() + { + var typeDefinition = testAssembly.FindType(typeof(LeafClass)).GetDefinition(); + var symbol = typeDefinition!.Properties.First(p => p.Name == nameof(LeafClass.TestProperty)); + var analyzer = new MemberOverridesAnalyzer(); + + var results = analyzer.Analyze(symbol, CreateContext()).OfType().ToList(); + + Assert.That(results.Select(r => r.DeclaringTypeDefinition!.Name), + Is.EquivalentTo(new[] { nameof(GrandBaseClass) })); + } + + [Test] + public void VerifyDoesNotReturnShadowedNonVirtualMembers() + { + // ShadowBase.ShadowedMethod is unrelated to the override chain that starts + // at ShadowMiddle's 'new virtual' declaration; it must not appear. + var symbol = SetupMethodForAnalysis(typeof(ShadowLeaf), nameof(ShadowLeaf.ShadowedMethod)); + var analyzer = new MemberOverridesAnalyzer(); + + var results = analyzer.Analyze(symbol, CreateContext()).OfType().ToList(); + + Assert.That(results.Select(r => r.DeclaringTypeDefinition!.Name), + Is.EquivalentTo(new[] { nameof(ShadowMiddle) })); + } + + static AnalyzerContext CreateContext() + { + return new AnalyzerContext { + AssemblyList = new AssemblyList(), + Language = new CSharpLanguage(), + }; + } + + ISymbol SetupMethodForAnalysis(Type type, string methodName) + { + var typeDefinition = testAssembly.FindType(type).GetDefinition(); + return typeDefinition!.Methods.First(m => m.Name == methodName); + } + + static IMember SetupMemberMock(SymbolKind symbolKind, bool isOverride) + { + var memberMock = Substitute.For(); + memberMock.SymbolKind.Returns(symbolKind); + memberMock.IsOverride.Returns(isOverride); + return memberMock; + } + + interface ITestInterface + { + void TestMethod(); + } + + class GrandBaseClass + { + public virtual void TestMethod() => throw new NotImplementedException(); + public virtual int TestProperty => throw new NotImplementedException(); + } + + class MiddleClass : GrandBaseClass + { + public override void TestMethod() => throw new NotImplementedException(); + } + + class LeafClass : MiddleClass, ITestInterface + { + public override void TestMethod() => throw new NotImplementedException(); + public override int TestProperty => throw new NotImplementedException(); + } + + class ShadowBase + { + public void ShadowedMethod() => throw new NotImplementedException(); + } + + class ShadowMiddle : ShadowBase + { + public new virtual void ShadowedMethod() => throw new NotImplementedException(); + } + + class ShadowLeaf : ShadowMiddle + { + public override void ShadowedMethod() => throw new NotImplementedException(); + } +}