diff --git a/ILSpy.Tests/Search/SearchResultFactoryIconTests.cs b/ILSpy.Tests/Search/SearchResultFactoryIconTests.cs new file mode 100644 index 000000000..a794a98b6 --- /dev/null +++ b/ILSpy.Tests/Search/SearchResultFactoryIconTests.cs @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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.Linq; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.Search; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +// Search results must carry the same composed icons as the assembly tree: base symbol by +// entity kind, plus accessibility and static overlays. The factory therefore has to go +// through Images.GetIcon (via the tree nodes' GetIcon helpers), not hand out bare base +// images — a bare Images.Field for a private static field loses both mini-overlays. +[TestFixture] +public class SearchResultFactoryIconTests +{ + AvaloniaSearchResultFactory factory = null!; + ITypeDefinition fixtureType = null!; + + [OneTimeSetUp] + public void LoadFixtureTypeFromOwnAssembly() + { + // Read the fixture entities from this test assembly's own metadata, so the + // accessibility/static flags come from a real type system, not mocks. + var path = typeof(SearchResultFactoryIconTests).Assembly.Location; + var file = new PEFile(path); + var resolver = new UniversalAssemblyResolver(path, throwOnError: false, + targetFramework: file.DetectTargetFrameworkId()); + var typeSystem = new DecompilerTypeSystem(file, resolver); + fixtureType = typeSystem.MainModule.TypeDefinitions + .Single(t => t.Name == nameof(SearchIconFixture)); + factory = new AvaloniaSearchResultFactory(new CSharpLanguage()); + } + + [AvaloniaTest] + public void Private_Static_Field_Result_Composes_Static_And_Private_Overlays() + { + var field = fixtureType.Fields.Single(f => f.Name == "privateStaticField"); + + var result = factory.Create(field); + + var icon = result.Image.Should().BeOfType().Subject; + icon.BaseImage.Should().BeSameAs(Images.Field); + icon.BaseScale.Should().Be(0.8); + icon.Overlays.Should().Equal(Images.OverlayStatic, Images.OverlayPrivate); + } + + [AvaloniaTest] + public void Nested_Interface_Result_Uses_Interface_Base_Icon() + { + var nested = fixtureType.NestedTypes + .Single(t => t.Name == nameof(SearchIconFixture.INested)).GetDefinition()!; + + var icon = (LayeredImage)factory.Create(nested).Image; + + icon.BaseImage.Should().BeSameAs(Images.Interface); + } + + [AvaloniaTest] + public void Nested_Enum_Result_Uses_Enum_Base_Icon() + { + var nested = fixtureType.NestedTypes + .Single(t => t.Name == "NestedEnum").GetDefinition()!; + + var icon = (LayeredImage)factory.Create(nested).Image; + + icon.BaseImage.Should().BeSameAs(Images.Enum); + } + + [AvaloniaTest] + public void Protected_Internal_Nested_Type_Gets_Plain_Protected_Overlay() + { + var nested = fixtureType.NestedTypes + .Single(t => t.Name == "NestedProtectedInternal").GetDefinition()!; + + var icon = (LayeredImage)factory.Create(nested).Image; + + // Types map protected-internal to the plain protected badge; only members show + // the combined protected-internal badge. Matches the WPF frontend's type-only + // overlay mapping. + icon.Overlays.Should().Equal(Images.OverlayProtected); + } + + [AvaloniaTest] + public void Location_Image_Reflects_Declaring_Type_Icon() + { + var field = fixtureType.Fields.Single(f => f.Name == "privateStaticField"); + + var result = factory.Create(field); + + // The declaring type (SearchIconFixture) is a top-level internal class. + var location = result.LocationImage.Should().BeOfType().Subject; + location.BaseImage.Should().BeSameAs(Images.Class); + location.Overlays.Should().Equal(Images.OverlayInternal); + } + + [AvaloniaTest] + public void Internal_Static_Method_Result_Composes_Static_And_Internal_Overlays() + { + var method = fixtureType.Methods.Single(m => m.Name == "InternalStaticMethod"); + + var icon = (LayeredImage)factory.Create(method).Image; + + icon.BaseImage.Should().BeSameAs(Images.Method); + icon.Overlays.Should().Equal(Images.OverlayStatic, Images.OverlayInternal); + } + + [AvaloniaTest] + public void Protected_Property_Result_Composes_Protected_Overlay() + { + var property = fixtureType.Properties.Single(p => p.Name == "ProtectedProperty"); + + var icon = (LayeredImage)factory.Create(property).Image; + + icon.BaseImage.Should().BeSameAs(Images.Property); + icon.Overlays.Should().Equal(Images.OverlayProtected); + } + + [AvaloniaTest] + public void Private_Event_Result_Composes_Private_Overlay() + { + var @event = fixtureType.Events.Single(e => e.Name == "privateEvent"); + + var icon = (LayeredImage)factory.Create(@event).Image; + + icon.BaseImage.Should().BeSameAs(Images.Event); + icon.Overlays.Should().Equal(Images.OverlayPrivate); + } + + [AvaloniaTest] + public void Top_Level_Type_Result_Falls_Back_To_Namespace_Location_Image() + { + // A top-level type has no declaring type, so the location column shows its namespace. + factory.Create(fixtureType).LocationImage.Should().BeSameAs(Images.Namespace); + } +} + +#pragma warning disable CS0169, CS0067 // members exist only as metadata probes for the tests above +class SearchIconFixture +{ + static int privateStaticField; + + internal interface INested { } + + enum NestedEnum { None } + + protected internal class NestedProtectedInternal { } + + internal static void InternalStaticMethod() { } + + protected int ProtectedProperty { get; } + + event System.EventHandler? privateEvent; +} +#pragma warning restore CS0169, CS0067 diff --git a/ILSpy/Analyzers/AnalyzedEventTreeNode.cs b/ILSpy/Analyzers/AnalyzedEventTreeNode.cs index 1a3044bd6..a4bd0f9a2 100644 --- a/ILSpy/Analyzers/AnalyzedEventTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzedEventTreeNode.cs @@ -24,6 +24,8 @@ using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler.Output; using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TreeNodes; + namespace ICSharpCode.ILSpy.Analyzers.TreeNodes { internal sealed class AnalyzedEventTreeNode : AnalyzerEntityTreeNode @@ -47,8 +49,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes protected override RichText? BuildRichText() => CreateMemberRichText(prefix, MemberSignatureFlags); - public override object Icon => Images.GetIcon(Images.Event, - Images.GetOverlay(analyzedEvent.Accessibility), analyzedEvent.IsStatic); + public override object Icon => EventTreeNode.GetIcon(analyzedEvent); protected override void LoadChildren() { diff --git a/ILSpy/Analyzers/AnalyzedFieldTreeNode.cs b/ILSpy/Analyzers/AnalyzedFieldTreeNode.cs index da64e7e44..fd2d257f8 100644 --- a/ILSpy/Analyzers/AnalyzedFieldTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzedFieldTreeNode.cs @@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler.Output; using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TreeNodes; + namespace ICSharpCode.ILSpy.Analyzers.TreeNodes { internal sealed class AnalyzedFieldTreeNode : AnalyzerEntityTreeNode @@ -44,8 +46,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes protected override RichText? BuildRichText() => CreateMemberRichText("", MemberSignatureFlags); - public override object Icon => Images.GetIcon(Images.Field, - Images.GetOverlay(analyzedField.Accessibility), analyzedField.IsStatic); + public override object Icon => FieldTreeNode.GetIcon(analyzedField); protected override void LoadChildren() => AddAnalyzerChildren(analyzedField); } diff --git a/ILSpy/Analyzers/AnalyzedMethodTreeNode.cs b/ILSpy/Analyzers/AnalyzedMethodTreeNode.cs index 5b6278d9a..d1cc6f672 100644 --- a/ILSpy/Analyzers/AnalyzedMethodTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzedMethodTreeNode.cs @@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler.Output; using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TreeNodes; + namespace ICSharpCode.ILSpy.Analyzers.TreeNodes { internal class AnalyzedMethodTreeNode : AnalyzerEntityTreeNode @@ -46,20 +48,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes protected override RichText? BuildRichText() => CreateMemberRichText(prefix, MemberSignatureFlags); - public override object Icon => ResolveIcon(analyzedMethod); - - internal static object ResolveIcon(IMethod method) - { - var baseImage = method.IsConstructor - ? Images.Constructor - : method.IsOperator - ? Images.Operator - : Images.Method; - return Images.GetIcon(baseImage, - Images.GetOverlay(method.Accessibility), - method.IsStatic, - method.IsExtensionMethod); - } + public override object Icon => MethodTreeNode.GetIcon(analyzedMethod); protected override void LoadChildren() => AddAnalyzerChildren(analyzedMethod); } diff --git a/ILSpy/Analyzers/AnalyzedPropertyTreeNode.cs b/ILSpy/Analyzers/AnalyzedPropertyTreeNode.cs index 0ee416226..80ea1cc65 100644 --- a/ILSpy/Analyzers/AnalyzedPropertyTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzedPropertyTreeNode.cs @@ -23,6 +23,8 @@ using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler.Output; using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TreeNodes; + namespace ICSharpCode.ILSpy.Analyzers.TreeNodes { internal sealed class AnalyzedPropertyTreeNode : AnalyzerEntityTreeNode @@ -46,8 +48,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes protected override RichText? BuildRichText() => CreateMemberRichText(prefix, MemberSignatureFlags); - public override object Icon => Images.GetIcon(Images.Property, - Images.GetOverlay(analyzedProperty.Accessibility), analyzedProperty.IsStatic); + public override object Icon => PropertyTreeNode.GetIcon(analyzedProperty); protected override void LoadChildren() { diff --git a/ILSpy/Analyzers/AnalyzedTypeTreeNode.cs b/ILSpy/Analyzers/AnalyzedTypeTreeNode.cs index 2160f3410..1c0c2b66d 100644 --- a/ILSpy/Analyzers/AnalyzedTypeTreeNode.cs +++ b/ILSpy/Analyzers/AnalyzedTypeTreeNode.cs @@ -22,6 +22,8 @@ using AvaloniaEdit.Highlighting; using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TreeNodes; + namespace ICSharpCode.ILSpy.Analyzers.TreeNodes { internal sealed class AnalyzedTypeTreeNode : AnalyzerEntityTreeNode @@ -41,20 +43,7 @@ namespace ICSharpCode.ILSpy.Analyzers.TreeNodes protected override RichText? BuildRichText() => CreateMemberRichText("", TypeSignatureFlags); - public override object Icon => ResolveIcon(analyzedType); - - static object ResolveIcon(ITypeDefinition type) - { - var baseImage = type.Kind switch { - TypeKind.Interface => Images.Interface, - TypeKind.Struct or TypeKind.Void => Images.Struct, - TypeKind.Delegate => Images.Delegate, - TypeKind.Enum => Images.Enum, - _ => Images.Class, - }; - return Images.GetIcon(baseImage, - Images.GetOverlay(type.Accessibility), type.IsStatic); - } + public override object Icon => TypeTreeNode.GetIcon(analyzedType); protected override void LoadChildren() => AddAnalyzerChildren(analyzedType); } diff --git a/ILSpy/Search/AvaloniaSearchResultFactory.cs b/ILSpy/Search/AvaloniaSearchResultFactory.cs index 50cb40059..6505570d6 100644 --- a/ILSpy/Search/AvaloniaSearchResultFactory.cs +++ b/ILSpy/Search/AvaloniaSearchResultFactory.cs @@ -26,6 +26,7 @@ using ICSharpCode.ILSpyX.Abstractions; using ICSharpCode.ILSpyX.Search; using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.TreeNodes; namespace ICSharpCode.ILSpy.Search { @@ -56,7 +57,7 @@ namespace ICSharpCode.ILSpy.Search Assembly = entity.ParentModule?.FullAssemblyName ?? string.Empty, ToolTip = entity.ParentModule?.MetadataFile?.FileName, Image = GetIcon(entity), - LocationImage = declaringType != null ? Images.Class : Images.Namespace, + LocationImage = declaringType != null ? TypeTreeNode.GetIcon(declaringType) : Images.Namespace, AssemblyImage = Images.Assembly, }; } @@ -66,9 +67,9 @@ namespace ICSharpCode.ILSpy.Search return new ResourceSearchResult { Resource = resource, Fitness = 1.0f / Math.Max(1, resource.Name.Length), - Image = Images.Library, + Image = node.Icon ?? Images.Resource, Name = resource.Name, - LocationImage = Images.Library, + LocationImage = parent.Icon ?? Images.Library, Location = (parent.Text as string) ?? string.Empty, Assembly = module.FullName, ToolTip = module.FileName, @@ -123,12 +124,14 @@ namespace ICSharpCode.ILSpy.Search _ => member.Name, }; + // Delegate to the tree nodes' icon helpers so search results carry the same composed + // icons (kind-specific base + accessibility/static overlays) as the assembly tree. static object GetIcon(IEntity member) => member switch { - ITypeDefinition => Images.Class, - IField => Images.Field, - IProperty => Images.Property, - IMethod => Images.Method, - IEvent => Images.Event, + ITypeDefinition t => TypeTreeNode.GetIcon(t), + IField f => FieldTreeNode.GetIcon(f), + IProperty p => PropertyTreeNode.GetIcon(p), + IMethod m => MethodTreeNode.GetIcon(m), + IEvent e => EventTreeNode.GetIcon(e), _ => Images.Library, }; } diff --git a/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs b/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs index 451c5217b..1d3ef0cf7 100644 --- a/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs +++ b/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs @@ -82,24 +82,16 @@ namespace ICSharpCode.ILSpy.Compare }; public override object Icon => entry.Entity switch { - ITypeDefinition t => TypeIconForKind(t), - IMethod => ICSharpCode.ILSpy.Images.Method, - IField => ICSharpCode.ILSpy.Images.Field, - IProperty => ICSharpCode.ILSpy.Images.Property, - IEvent => ICSharpCode.ILSpy.Images.Event, + ITypeDefinition t => TypeTreeNode.GetIcon(t), + IMethod m => MethodTreeNode.GetIcon(m), + IField f => FieldTreeNode.GetIcon(f), + IProperty p => PropertyTreeNode.GetIcon(p), + IEvent e => EventTreeNode.GetIcon(e), INamespace => ICSharpCode.ILSpy.Images.Namespace, IModule => ICSharpCode.ILSpy.Images.Assembly, _ => ICSharpCode.ILSpy.Images.Class, }; - static object TypeIconForKind(ITypeDefinition t) => t.Kind switch { - TypeKind.Interface => ICSharpCode.ILSpy.Images.Interface, - TypeKind.Struct => ICSharpCode.ILSpy.Images.Struct, - TypeKind.Enum => ICSharpCode.ILSpy.Images.Enum, - TypeKind.Delegate => ICSharpCode.ILSpy.Images.Delegate, - _ => ICSharpCode.ILSpy.Images.Class, - }; - public override void Decompile(Language language, ICSharpCode.Decompiler.ITextOutput output, DecompilationOptions options) { // Compare nodes aren't decompilable on their own — they're a diff overlay. diff --git a/ILSpy/TreeNodes/DerivedTypesEntryNode.cs b/ILSpy/TreeNodes/DerivedTypesEntryNode.cs index 01ce0cdb5..915f3ef23 100644 --- a/ILSpy/TreeNodes/DerivedTypesEntryNode.cs +++ b/ILSpy/TreeNodes/DerivedTypesEntryNode.cs @@ -53,9 +53,7 @@ namespace ICSharpCode.ILSpy.TreeNodes public override object? NavigationText => $"{Text} ({ICSharpCode.ILSpy.Properties.Resources.DerivedTypes})"; - public override object Icon => type.Kind == TypeKind.Interface - ? Images.Interface - : Images.Class; + public override object Icon => TypeTreeNode.GetIcon(type); protected override void LoadChildren() { diff --git a/ILSpy/TreeNodes/EventTreeNode.cs b/ILSpy/TreeNodes/EventTreeNode.cs index 21e3b145a..5faf66de8 100644 --- a/ILSpy/TreeNodes/EventTreeNode.cs +++ b/ILSpy/TreeNodes/EventTreeNode.cs @@ -49,8 +49,10 @@ namespace ICSharpCode.ILSpy.TreeNodes public override object NavigationText => Language.EntityToString(EventDefinition, ConversionFlags.ShowDeclaringType); - public override object Icon => Images.GetIcon(Images.Event, - Images.GetOverlay(EventDefinition.Accessibility), EventDefinition.IsStatic); + public override object Icon => GetIcon(EventDefinition); + + public static Avalonia.Media.IImage GetIcon(IEvent @event) => Images.GetIcon(Images.Event, + Images.GetOverlay(@event.Accessibility), @event.IsStatic); public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) => language.DecompileEvent(EventDefinition, output, options); diff --git a/ILSpy/TreeNodes/TypeTreeNode.cs b/ILSpy/TreeNodes/TypeTreeNode.cs index 008e93a9d..6e0f8c299 100644 --- a/ILSpy/TreeNodes/TypeTreeNode.cs +++ b/ILSpy/TreeNodes/TypeTreeNode.cs @@ -64,18 +64,24 @@ namespace ICSharpCode.ILSpy.TreeNodes public override object Text => Language.TypeToString(typeDefinition, ConversionFlags.None) + GetSuffixString(handle); - public override object Icon { - get { - var baseImage = typeDefinition.Kind switch { - TypeKind.Interface => Images.Interface, - TypeKind.Struct or TypeKind.Void => Images.Struct, - TypeKind.Delegate => Images.Delegate, - TypeKind.Enum => Images.Enum, - _ => Images.Class, - }; - return Images.GetIcon(baseImage, - Images.GetOverlay(typeDefinition.Accessibility), typeDefinition.IsStatic); - } + public override object Icon => GetIcon(typeDefinition); + + public static Avalonia.Media.IImage GetIcon(ITypeDefinition type) + { + var baseImage = type.Kind switch { + TypeKind.Interface => Images.Interface, + TypeKind.Struct or TypeKind.Void => Images.Struct, + TypeKind.Delegate => Images.Delegate, + TypeKind.Enum => Images.Enum, + _ => Images.Class, + }; + // Types map protected-internal to the plain protected badge; only members show + // the combined protected-internal badge. Matches the WPF frontend's type-only + // overlay mapping. + var overlay = type.Accessibility == Accessibility.ProtectedOrInternal + ? AccessOverlayIcon.Protected + : Images.GetOverlay(type.Accessibility); + return Images.GetIcon(baseImage, overlay, type.IsStatic); } public override bool CanExpandRecursively => true;