From 1008d2b754df405721f393c912bf4b38163c9613 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 13 May 2026 18:11:53 +0200 Subject: [PATCH] Fix InvalidCastException analysing a constructor's Uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language.GetCodeMappingInfo was unconditionally casting the incoming EntityHandle to TypeDefinitionHandle. MethodUsesAnalyzer calls it with the method's own handle and expects the language to walk to the declaring type — the WPF impl does, the Avalonia port didn't. Right-clicking a constructor → Analyze → expanding "Uses" therefore threw at runtime; the error surfaced only as a single-line message in the analyzer pane because AnalyzerErrorNode dropped the stack. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../Analyzers/AnalyzerConstructorUsesTests.cs | 144 ++++++++++++++++++ ILSpy/Languages/Language.cs | 16 +- 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs diff --git a/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs b/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs new file mode 100644 index 000000000..559c63bc4 --- /dev/null +++ b/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs @@ -0,0 +1,144 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Analyzers; + +using ILSpy.Analyzers; +using ILSpy.AppEnv; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Analyzers; + +/// +/// Regression: analysing the "Used By" of a constructor must not throw. The reported +/// user-visible failure was an InvalidCastException in the live app; this test exercises +/// the same path (analyzer enumeration + the search-node WrapResult switch) but bypasses +/// the search node's catch so any exception bubbles up to NUnit with its stack trace. +/// +[TestFixture] +public class AnalyzerConstructorUsesTests +{ + [AvaloniaTest] + public async Task Used_By_Analyzer_Runs_Over_A_Constructor_Without_Throwing() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + // Pick any concrete type with at least one public instance ctor; the type itself + // doesn't matter — what matters is that the symbol fed to the analyzer is a method + // whose IsConstructor is true. + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Lookup`2"); + typeNode.EnsureLazyChildren(); + var ctorTreeNode = typeNode.Children.OfType() + .FirstOrDefault(m => m.MethodDefinition.IsConstructor); + (ctorTreeNode is null).Should().BeFalse( + "Lookup defines at least one constructor — the test depends on it"); + var ctor = (IMethod)ctorTreeNode!.Member!; + ctor.IsConstructor.Should().BeTrue(); + + // Resolve the "Used By" analyzer that actually applies to this method. + var analyzer = AnalyzerTreeNode.Analyzers + .Where(a => a.Metadata?.Header == "Used By") + .Select(a => a.CreateExport().Value) + .First(a => a.Show(ctor)); + + // Drive Analyze directly (no AnalyzerSearchTreeNode → no swallowing catch). Take + // a small slice so we don't pay for a full scan; the bug repros on the first call + // site if it's going to throw at all. + var assemblyList = vm.AssemblyTreeModel.AssemblyList!; + var context = new AnalyzerContext { + CancellationToken = CancellationToken.None, + Language = AppComposition.Current.GetExport().CurrentLanguage, + AssemblyList = assemblyList, + }; + var results = analyzer.Analyze(ctor, context).Take(20).ToList(); + results.Should().NotBeEmpty("constructors of public LINQ types must have at least one in-tree caller"); + } + + [AvaloniaTest] + public async Task Uses_Analyzer_Runs_Over_A_Constructor_Without_Throwing() + { + // "Uses" is the forward direction (what does the body of this method touch?). + // MethodUsesAnalyzer can return type/method/field/property/event ISymbols — the + // path most likely to surface a switch-arm hole for some result shape we missed. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Lookup`2"); + typeNode.EnsureLazyChildren(); + var ctor = (IMethod)typeNode.Children.OfType() + .First(m => m.MethodDefinition.IsConstructor).Member!; + + var analyzer = AnalyzerTreeNode.Analyzers + .Where(a => a.Metadata?.Header == "Uses") + .Select(a => a.CreateExport().Value) + .First(a => a.Show(ctor)); + + var assemblyList = vm.AssemblyTreeModel.AssemblyList!; + var context = new AnalyzerContext { + CancellationToken = CancellationToken.None, + Language = AppComposition.Current.GetExport().CurrentLanguage, + AssemblyList = assemblyList, + }; + var results = analyzer.Analyze(ctor, context).Take(20).ToList(); + results.Should().NotBeEmpty(); + } + + [AvaloniaTest] + public async Task Analyze_Pushes_A_Constructor_Onto_The_Pane_Without_Throwing() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Lookup`2"); + typeNode.EnsureLazyChildren(); + var ctor = (IMethod)typeNode.Children.OfType() + .First(m => m.MethodDefinition.IsConstructor).Member!; + + var analyzerVm = AppComposition.Current.GetExport(); + var beforeCount = analyzerVm.Root.Children.Count; + + // This was the user-reported throw point. + analyzerVm.Analyze(ctor); + + analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1); + } +} diff --git a/ILSpy/Languages/Language.cs b/ILSpy/Languages/Language.cs index 3f0547d1c..fefc1533d 100644 --- a/ILSpy/Languages/Language.cs +++ b/ILSpy/Languages/Language.cs @@ -56,11 +56,21 @@ namespace ILSpy.Languages public bool HasLanguageVersions => LanguageVersions.Count > 0; /// - /// Token-to-source-line mapping for navigation. Default returns a no-op CodeMappingInfo — - /// language subclasses with full decompilation override and produce a real one. + /// Token-to-source-line mapping for navigation. Default walks + /// up to its declaring type and hands back a keyed on + /// that type. The the caller passes may name any member + /// kind (method, field, …) — analyzers like MethodUsesAnalyzer pass the + /// method's own token and expect the language to walk to the type itself, not crash + /// trying to cast the method handle. Subclasses with full decompilation override and + /// build the mapping from the decompiler pipeline. /// public virtual CodeMappingInfo GetCodeMappingInfo(MetadataFile module, EntityHandle member) - => new(module, (TypeDefinitionHandle)member); + { + var declaringType = (TypeDefinitionHandle)member.GetDeclaringType(module.Metadata); + if (declaringType.IsNil && member.Kind == HandleKind.TypeDefinition) + declaringType = (TypeDefinitionHandle)member; + return new CodeMappingInfo(module, declaringType); + } /// /// Stable name string for an entity reachable only by metadata token. Falls back to the