From 26a905aa6e8ba8e22b48bea0b4037e71e87c4009 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 14 May 2026 16:03:32 +0200 Subject: [PATCH] =?UTF-8?q?Compare=20pane=20=E2=80=94=20diff=20two=20assem?= =?UTF-8?q?blies=20into=20a=20merged=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the assembly-comparison feature across. Right-click two valid managed assemblies in the tree → "Compare..." → fresh tab opens with a diff tree: identical entries hidden by default, additions tinted green, removals pink, signature-matched-but-different rows blue. Identical entries surface via the "Show identical entries" toolbar checkbox. --- .../Compare/CompareContextMenuTests.cs | 117 +++++++ ILSpy.Tests/Compare/CompareEngineTests.cs | 136 ++++++++ ILSpy/App.axaml | 4 + ILSpy/Commands/CompareContextMenuEntry.cs | 71 ++++ ILSpy/CompareEngine.cs | 303 ++++++++++++++++++ ILSpy/Entry.cs | 118 +++++++ ILSpy/TreeNodes/ComparisonEntryTreeNode.cs | 131 ++++++++ ILSpy/ViewModels/CompareTabPageModel.cs | 91 ++++++ ILSpy/Views/CompareView.axaml | 50 +++ ILSpy/Views/CompareView.axaml.cs | 30 ++ 10 files changed, 1051 insertions(+) create mode 100644 ILSpy.Tests/Compare/CompareContextMenuTests.cs create mode 100644 ILSpy.Tests/Compare/CompareEngineTests.cs create mode 100644 ILSpy/Commands/CompareContextMenuEntry.cs create mode 100644 ILSpy/CompareEngine.cs create mode 100644 ILSpy/Entry.cs create mode 100644 ILSpy/TreeNodes/ComparisonEntryTreeNode.cs create mode 100644 ILSpy/ViewModels/CompareTabPageModel.cs create mode 100644 ILSpy/Views/CompareView.axaml create mode 100644 ILSpy/Views/CompareView.axaml.cs diff --git a/ILSpy.Tests/Compare/CompareContextMenuTests.cs b/ILSpy.Tests/Compare/CompareContextMenuTests.cs new file mode 100644 index 000000000..c2cf5aa1a --- /dev/null +++ b/ILSpy.Tests/Compare/CompareContextMenuTests.cs @@ -0,0 +1,117 @@ +// 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.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.Compare; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Compare; + +/// +/// Pins the right-click → Compare flow: visibility gate (exactly two valid assembly +/// nodes), and that Execute opens a fresh tab containing a +/// with a populated root. +/// +[TestFixture] +public class CompareContextMenuTests +{ + [AvaloniaTest] + public async Task Compare_Is_Visible_When_Two_Assemblies_Are_Selected() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2); + + var entry = AppComposition.Current.GetExport() + .Entries.Single(e => e.Metadata.Header == "Compare...").Value; + var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Where(a => a.IsLoadedAsValidAssembly).Take(2).ToList(); + assemblies.Should().HaveCount(2, "the fixture must load at least two valid assemblies"); + + var nodes = assemblies.Select(a => + (SharpTreeNode)vm.AssemblyTreeModel.FindNode(a.ShortName)).ToArray(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = nodes }).Should().BeTrue(); + entry.IsEnabled(new TextViewContext { SelectedTreeNodes = nodes }).Should().BeTrue(); + } + + [AvaloniaTest] + public async Task Compare_Is_Hidden_For_Single_Selection() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var entry = AppComposition.Current.GetExport() + .Entries.Single(e => e.Metadata.Header == "Compare...").Value; + var coreLibName = typeof(object).Assembly.GetName().Name!; + var node = vm.AssemblyTreeModel.FindNode(coreLibName); + entry.IsVisible(new TextViewContext { + SelectedTreeNodes = new[] { (SharpTreeNode)node } + }).Should().BeFalse( + "Compare needs exactly two assemblies — single selection must hide the entry"); + } + + [AvaloniaTest] + public async Task Execute_Opens_A_Tab_Containing_A_CompareTabPageModel_With_Populated_Tree() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2); + + var entry = AppComposition.Current.GetExport() + .Entries.Single(e => e.Metadata.Header == "Compare...").Value; + var dockWorkspace = AppComposition.Current.GetExport(); + var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Where(a => a.IsLoadedAsValidAssembly).Take(2).ToList(); + var nodes = assemblies.Select(a => + (SharpTreeNode)vm.AssemblyTreeModel.FindNode(a.ShortName)).ToArray(); + + entry.Execute(new TextViewContext { SelectedTreeNodes = nodes }); + + // The new tab's Content is the CompareTabPageModel. Walk the dock's tabs and find it. + var page = dockWorkspace.Documents?.VisibleDockables? + .OfType() + .Select(t => t.Content) + .OfType() + .FirstOrDefault(); + Assert.That(page, Is.Not.Null, "a ContentTabPage with CompareTabPageModel content must exist"); + ReferenceEquals(page!.LeftAssembly, assemblies[0]).Should().BeTrue( + "first-selected node becomes the left-hand assembly of the comparison"); + ReferenceEquals(page.RightAssembly, assemblies[1]).Should().BeTrue( + "second-selected node becomes the right-hand assembly"); + ((object?)page.RootEntry).Should().NotBeNull("the merged-tree root must be set at construction"); + } +} diff --git a/ILSpy.Tests/Compare/CompareEngineTests.cs b/ILSpy.Tests/Compare/CompareEngineTests.cs new file mode 100644 index 000000000..2e847bd7c --- /dev/null +++ b/ILSpy.Tests/Compare/CompareEngineTests.cs @@ -0,0 +1,136 @@ +// 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.Collections.Generic; +using System.Linq; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; + +using ILSpy.Compare; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Compare; + +/// +/// Unit tests for the assembly-comparison engine. The diff logic is pure data — synthetic +/// trees, no decompiler required — so each scenario runs in +/// milliseconds. +/// +[TestFixture] +public class CompareEngineTests +{ + static Entry Leaf(string signature) => new Entry { Signature = signature, Entity = null!, }; + + static Entry Branch(string signature, params Entry[] children) => new Entry { + Signature = signature, + Entity = null!, + Children = children.ToList(), + }; + + [Test] + public void CalculateDiff_Pairs_Identical_Signatures() + { + var left = new List { Leaf("M1"), Leaf("M2") }; + var right = new List { Leaf("M1"), Leaf("M2") }; + var diff = CompareEngine.CalculateDiff(left, right); + diff.Should().HaveCount(2); + diff.Should().OnlyContain(p => p.Left != null && p.Right != null); + diff.All(p => p.Left!.Kind == DiffKind.None).Should().BeTrue(); + } + + [Test] + public void CalculateDiff_Marks_Left_Only_As_Remove() + { + var left = new List { Leaf("Stays"), Leaf("Gone") }; + var right = new List { Leaf("Stays") }; + var diff = CompareEngine.CalculateDiff(left, right); + var gone = diff.Single(p => p.Left?.Signature == "Gone"); + ((object?)gone.Right).Should().BeNull(); + gone.Left!.Kind.Should().Be(DiffKind.Remove); + } + + [Test] + public void CalculateDiff_Marks_Right_Only_As_Add() + { + var left = new List { Leaf("Stays") }; + var right = new List { Leaf("Stays"), Leaf("New") }; + var diff = CompareEngine.CalculateDiff(left, right); + var added = diff.Single(p => p.Right?.Signature == "New"); + ((object?)added.Left).Should().BeNull(); + added.Right!.Kind.Should().Be(DiffKind.Add); + } + + [Test] + public void MergeTrees_Aligns_Identical_Subtrees_And_Tags_Diff_On_Children() + { + var left = Branch("Type", + Leaf("Stays"), + Leaf("Gone")); + var right = Branch("Type", + Leaf("Stays"), + Leaf("New")); + var merged = CompareEngine.MergeTrees(left, right); + merged.Children.Should().NotBeNull(); + merged.Children!.Should().HaveCount(3, + "the merge keeps one slot per distinct signature across both sides"); + merged.Children.Should().Contain(c => c.Signature == "Gone" && c.Kind == DiffKind.Remove); + merged.Children.Should().Contain(c => c.Signature == "New" && c.Kind == DiffKind.Add); + merged.Children.Should().Contain(c => c.Signature == "Stays" && c.Kind == DiffKind.None); + } + + [Test] + public void RecursiveKind_Summarises_All_Removed_Children_As_Remove() + { + // A branch whose every leaf is gone reads as Remove at the branch level — + // matches WPF's "if every child is the same change kind, propagate it". + var entry = Branch("T", + new Entry { Signature = "a", Entity = null!, Kind = DiffKind.Remove }, + new Entry { Signature = "b", Entity = null!, Kind = DiffKind.Remove }); + entry.RecursiveKind.Should().Be(DiffKind.Remove); + } + + [Test] + public void RecursiveKind_Mixed_Changes_Becomes_Update() + { + var entry = Branch("T", + new Entry { Signature = "a", Entity = null!, Kind = DiffKind.Add }, + new Entry { Signature = "b", Entity = null!, Kind = DiffKind.Remove }); + entry.RecursiveKind.Should().Be(DiffKind.Update); + } + + [Test] + public void RecursiveKind_All_Identical_Children_Is_None() + { + var entry = Branch("T", + new Entry { Signature = "a", Entity = null!, Kind = DiffKind.None }, + new Entry { Signature = "b", Entity = null!, Kind = DiffKind.None }); + entry.RecursiveKind.Should().Be(DiffKind.None); + } + + [Test] + public void EntryComparer_Keys_On_Signature_String() + { + var left = Leaf("public void Foo()"); + var right = Leaf("public void Foo()"); + EntryComparer.Instance.Equals(left, right).Should().BeTrue(); + EntryComparer.Instance.GetHashCode(left).Should().Be(EntryComparer.Instance.GetHashCode(right)); + } +} diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index 417cd49cb..79a01c1b8 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -5,6 +5,7 @@ xmlns:tree="using:ILSpy.AssemblyTree" xmlns:search="using:ILSpy.Search" xmlns:analyzers="using:ILSpy.Analyzers" + xmlns:compare="using:ILSpy.Compare" xmlns:textView="using:ILSpy.TextView" xmlns:metaViews="using:ILSpy.Views" xmlns:vm="using:ILSpy.ViewModels" @@ -57,6 +58,9 @@ + + + diff --git a/ILSpy/Commands/CompareContextMenuEntry.cs b/ILSpy/Commands/CompareContextMenuEntry.cs new file mode 100644 index 000000000..42ce41f86 --- /dev/null +++ b/ILSpy/Commands/CompareContextMenuEntry.cs @@ -0,0 +1,71 @@ +// 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.Composition; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.AppEnv; +using ILSpy.Compare; +using ILSpy.Docking; +using ILSpy.TreeNodes; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Compare…" on exactly two valid managed assemblies in the tree. + /// Opens a fresh tab whose content is a driven by + /// . Order matters: the first-selected node becomes the + /// "left" side, the second becomes the "right". + /// + [ExportContextMenuEntry(Header = "Compare...", Order = 9999)] + [Shared] + public sealed class CompareContextMenuEntry : IContextMenuEntry + { + public bool IsEnabled(TextViewContext context) => IsVisible(context); + + public bool IsVisible(TextViewContext context) + => context.SelectedTreeNodes is + [AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }, + AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }]; + + public void Execute(TextViewContext context) + { + if (context.SelectedTreeNodes is not + [AssemblyTreeNode left, AssemblyTreeNode right]) + return; + + var dockWorkspace = TryGetExport(); + if (dockWorkspace == null) + return; + var pane = new CompareTabPageModel(left.LoadedAssembly, right.LoadedAssembly); + // OpenNewTab wraps the content viewmodel in a ContentTabPage; the DataTemplate + // for CompareTabPageModel routes through CompareView. Pass null for sourceNode + // so the new tab isn't anchored to either assembly's tree node — the compare + // view is a pair, not a single-node decompile result. + dockWorkspace.OpenNewTab(pane); + } + + static T? TryGetExport() where T : class + { + try + { return AppComposition.Current.GetExport(); } + catch { return null; } + } + } +} diff --git a/ILSpy/CompareEngine.cs b/ILSpy/CompareEngine.cs new file mode 100644 index 000000000..aaf58ff5c --- /dev/null +++ b/ILSpy/CompareEngine.cs @@ -0,0 +1,303 @@ +// 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.Collections.Generic; +using System.Diagnostics; +using System.Reflection.Metadata; + +using ICSharpCode.Decompiler.CSharp.OutputVisitor; +using ICSharpCode.Decompiler.Output; +using ICSharpCode.Decompiler.TypeSystem; + +namespace ILSpy.Compare +{ + /// + /// Builds a merged tree from two assemblies' type systems. The + /// process is: + /// + /// Walk each assembly's public surface () into a + /// per-side tree keyed on C#-formatted signature strings. + /// Recursively merge the two trees () — paired entries + /// get an ; left-only entries are tagged + /// ; right-only are tagged . + /// + /// Signatures are the keying mechanism because metadata tokens don't survive + /// re-compilation; wraps the string-equality check. + /// + public static class CompareEngine + { + /// + /// Walks 's public types and members and returns + /// (flat-list-of-leaves, namespace-rooted-tree). The flat list is for the side + /// that drives diff pairing in ; the tree is the + /// hierarchical view the UI consumes. + /// + public static (List AllEntries, Entry Root) CreateEntityTree(ICompilation typeSystem) + { + var module = (MetadataModule)typeSystem.MainModule!; + var metadata = module.MetadataFile!.Metadata; + var ambience = new CSharpAmbience { + ConversionFlags = ConversionFlags.All & ~ConversionFlags.ShowDeclaringType, + }; + + var results = new List(); + var typeEntries = new Dictionary(); + var namespaceEntries = new Dictionary(System.StringComparer.Ordinal); + var root = new Entry { Entity = module, Signature = module.FullAssemblyName }; + + Entry? TryCreateEntry(IEntity entity) + { + if (entity.EffectiveAccessibility() != Accessibility.Public) + return null; + Entry? parent = null; + // Nested entities are only included when their declaring type is itself a + // recorded entry (i.e. public). Skipping otherwise drops the whole subtree — + // matching the WPF behaviour that "compare" shows public-API only. + if (entity.DeclaringTypeDefinition != null + && !typeEntries.TryGetValue((TypeDefinitionHandle)entity.DeclaringTypeDefinition.MetadataToken, out parent)) + { + return null; + } + var entry = new Entry { + Signature = ambience.ConvertSymbol(entity), + Entity = entity, + Parent = parent, + }; + if (parent != null) + { + parent.Children ??= new List(); + parent.Children.Add(entry); + } + return entry; + } + + foreach (var typeDefHandle in metadata.TypeDefinitions) + { + var typeDef = module.GetDefinition(typeDefHandle); + if (typeDef.EffectiveAccessibility() != Accessibility.Public) + continue; + var entry = typeEntries[typeDefHandle] = new Entry { + Signature = ambience.ConvertSymbol(typeDef), + Entity = typeDef, + }; + if (typeDef.DeclaringType == null) + { + if (!namespaceEntries.TryGetValue(typeDef.Namespace, out var nsEntry)) + { + namespaceEntries[typeDef.Namespace] = nsEntry = new Entry { + Parent = root, + Signature = typeDef.Namespace, + Entity = ResolveNamespace(typeDef.Namespace, typeDef.ParentModule!)!, + }; + root.Children ??= new List(); + root.Children.Add(nsEntry); + } + entry.Parent = nsEntry; + nsEntry.Children ??= new List(); + nsEntry.Children.Add(entry); + } + } + + foreach (var fieldHandle in metadata.FieldDefinitions) + { + var entry = TryCreateEntry(module.GetDefinition(fieldHandle)); + if (entry != null) + results.Add(entry); + } + foreach (var eventHandle in metadata.EventDefinitions) + { + var entry = TryCreateEntry(module.GetDefinition(eventHandle)); + if (entry != null) + results.Add(entry); + } + foreach (var propertyHandle in metadata.PropertyDefinitions) + { + var entry = TryCreateEntry(module.GetDefinition(propertyHandle)); + if (entry != null) + results.Add(entry); + } + foreach (var methodHandle in metadata.MethodDefinitions) + { + var methodDef = module.GetDefinition(methodHandle); + // Accessor methods (get_X / set_X / add_X / remove_X) are folded into their + // owning property / event row in the tree; comparing them separately would + // double-count every property as four entries. + if (methodDef.AccessorOwner != null) + continue; + var entry = TryCreateEntry(methodDef); + if (entry != null) + results.Add(entry); + } + + return (results, root); + } + + static INamespace? ResolveNamespace(string namespaceName, IModule module) + { + INamespace current = module.RootNamespace; + var parts = namespaceName.Split('.'); + for (int i = 0; i < parts.Length; i++) + { + if (i == 0 && string.IsNullOrEmpty(parts[i])) + continue; + var next = current.GetChildNamespace(parts[i]); + if (next != null) + current = next; + else + return null; + } + return current; + } + + /// + /// Recursively merges two parallel entry trees into a single one. Each interior + /// node's children are paired by ; unmatched children + /// keep their leaf tag (Add / Remove) so the recursive + /// summary on ancestors reflects the actual change shape. + /// + public static Entry MergeTrees(Entry a, Entry b) + { + var m = new Entry { + Entity = a.Entity, + OtherEntity = b.Entity, + Signature = a.Signature, + }; + if (a.Children?.Count > 0 && b.Children?.Count > 0) + { + var diff = CalculateDiff(a.Children, b.Children); + m.Children ??= new List(); + foreach (var (left, right) in diff) + { + if (left != null && right != null) + m.Children.Add(MergeTrees(left, right)); + else if (left != null) + m.Children.Add(left); + else if (right != null) + m.Children.Add(right); + else + Debug.Fail("CalculateDiff returned a (null, null) pair"); + m.Children[^1].Parent = m; + } + } + else if (a.Children?.Count > 0) + { + m.Children ??= new List(); + foreach (var child in a.Children) + { + child.Parent = m; + m.Children.Add(child); + } + } + else if (b.Children?.Count > 0) + { + m.Children ??= new List(); + foreach (var child in b.Children) + { + child.Parent = m; + m.Children.Add(child); + } + } + return m; + } + + /// + /// Pairs and by signature. + /// Returns one (Left, Right) tuple per slot: both non-null when matched; + /// only Left → Remove (and tags the leaf ); only + /// Right → Add (and tags the leaf ). + /// + public static List<(Entry? Left, Entry? Right)> CalculateDiff(List left, List right) + { + var leftMap = new Dictionary>(); + var rightMap = new Dictionary>(); + foreach (var item in left) + { + if (leftMap.TryGetValue(item.Signature, out var bucket)) + bucket.Add(item); + else + leftMap[item.Signature] = new List { item }; + } + foreach (var item in right) + { + if (rightMap.TryGetValue(item.Signature, out var bucket)) + bucket.Add(item); + else + rightMap[item.Signature] = new List { item }; + } + + var results = new List<(Entry? Left, Entry? Right)>(); + foreach (var (key, items) in leftMap) + { + if (rightMap.TryGetValue(key, out var rightEntries)) + { + foreach (var item in items) + { + var other = rightEntries.Find(r => EntryComparer.Instance.Equals(r, item)); + results.Add((item, other)); + if (other == null) + SetKind(item, DiffKind.Remove); + } + } + else + { + foreach (var item in items) + { + SetKind(item, DiffKind.Remove); + results.Add((item, null)); + } + } + } + foreach (var (key, items) in rightMap) + { + if (leftMap.TryGetValue(key, out var leftEntries)) + { + foreach (var item in items) + { + if (!leftEntries.Exists(l => EntryComparer.Instance.Equals(l, item))) + { + results.Add((null, item)); + SetKind(item, DiffKind.Add); + } + } + } + else + { + foreach (var item in items) + { + SetKind(item, DiffKind.Add); + results.Add((null, item)); + } + } + } + return results; + } + + static void SetKind(Entry item, DiffKind kind) + { + if (item.Children?.Count > 0) + { + foreach (var child in item.Children) + SetKind(child, kind); + } + else + { + item.Kind = kind; + } + } + } +} diff --git a/ILSpy/Entry.cs b/ILSpy/Entry.cs new file mode 100644 index 000000000..0b9ec22b3 --- /dev/null +++ b/ILSpy/Entry.cs @@ -0,0 +1,118 @@ +// 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.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +using ICSharpCode.Decompiler.TypeSystem; + +namespace ILSpy.Compare +{ + /// + /// How an compares between the left and right assemblies. Stored on + /// leaf entries by ; interior nodes derive a recursive + /// summary via . + /// + public enum DiffKind + { + /// Identical on both sides. + None = ' ', + /// Only present on the right. + Add = '+', + /// Only present on the left. + Remove = '-', + /// Present on both but the bodies differ. + Update = '~', + } + + /// + /// One node in the merged comparison tree. The is the symbol on + /// the left assembly; is the right-side counterpart when one + /// exists. is the C#-formatted text the engine keys on for + /// matching pairs across sides. + /// + [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] + public sealed class Entry + { + DiffKind? kind; + + /// + /// Per-node diff kind for leaves; for interior nodes, summarises the children: + /// all-Add → Add; all-Remove → Remove; any-non-None → Update; otherwise None. + /// + public DiffKind RecursiveKind { + get { + if (kind != null) + return kind.Value; + if (Children == null) + return DiffKind.None; + int addCount = 0, removeCount = 0, updateCount = 0; + foreach (var item in Children) + { + switch (item.RecursiveKind) + { + case DiffKind.Add: + addCount++; + break; + case DiffKind.Remove: + removeCount++; + break; + case DiffKind.Update: + updateCount++; + break; + } + } + if (addCount == Children.Count) + return DiffKind.Add; + if (removeCount == Children.Count) + return DiffKind.Remove; + if (addCount > 0 || removeCount > 0 || updateCount > 0) + return DiffKind.Update; + return DiffKind.None; + } + } + + public DiffKind Kind { + get => kind ?? DiffKind.None; + set => kind = value; + } + + public required string Signature { get; init; } + public required ISymbol Entity { get; init; } + public ISymbol? OtherEntity { get; init; } + public Entry? Parent { get; set; } + public List? Children { get; set; } + + string GetDebuggerDisplay() => $"Entry{Kind} {Entity?.ToString() ?? Signature}"; + } + + /// + /// Two entries match when their string is equal — that's + /// the only stable key across an assembly version, since metadata tokens are not + /// preserved across rebuilds. + /// + public sealed class EntryComparer : IEqualityComparer + { + public static readonly EntryComparer Instance = new(); + + public bool Equals(Entry? x, Entry? y) => x?.Signature == y?.Signature; + + public int GetHashCode([DisallowNull] Entry obj) => obj.Signature.GetHashCode(); + } +} diff --git a/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs b/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs new file mode 100644 index 000000000..9685a5e9c --- /dev/null +++ b/ILSpy/TreeNodes/ComparisonEntryTreeNode.cs @@ -0,0 +1,131 @@ +// 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 Avalonia.Media; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.Output; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX; + +using ILSpy.Languages; +using ILSpy.TreeNodes; + +namespace ILSpy.Compare +{ + /// + /// Tree-node façade over a merged . The text combines both sides' + /// formatted entity strings (with a " -> " separator when they differ); the + /// background brush is a light tint that mirrors the diff kind (green for additions, + /// pink for removals, blue for updates). Identical entries render with a transparent + /// background and stay hidden unless the toolbar's "Show Identical" toggle is on. + /// + public sealed class ComparisonEntryTreeNode : ILSpyTreeNode + { + readonly Entry entry; + readonly CompareTabPageModel pane; + + internal Entry Entry => entry; + + public ComparisonEntryTreeNode(Entry entry, CompareTabPageModel pane) + { + this.entry = entry; + this.pane = pane; + LazyLoading = entry.Children != null; + } + + protected override void LoadChildren() + { + if (entry.Children == null) + return; + // Order: changes first (Add/Remove/Update lexically before None via the negated + // enum cast), then by SymbolKind so types group together, then alphabetically + // by signature. Matches the WPF view's display order. + foreach (var item in entry.Children.OrderBy(e => (-(int)e.RecursiveKind, e.Entity.SymbolKind, e.Signature))) + Children.Add(new ComparisonEntryTreeNode(item, pane)); + } + + public override object Text { + get { + var leftText = FormatEntity(entry.Entity); + var rightText = FormatEntity(entry.OtherEntity); + return leftText + (rightText != null && leftText != rightText ? " -> " + rightText : ""); + } + } + + string? FormatEntity(ISymbol? symbol) => symbol switch { + ITypeDefinition t => Language.TypeToString(t, ConversionFlags.None), + IEntity e => Language.EntityToString(e, + ConversionFlags.All & ~(ConversionFlags.ShowDeclaringType + | ConversionFlags.UseFullyQualifiedEntityNames + | ConversionFlags.UseFullyQualifiedTypeNames)), + INamespace n => n.FullName, + IModule m => m.FullAssemblyName, + _ => null, + }; + + public override object Icon => entry.Entity switch { + ITypeDefinition t => TypeIconForKind(t), + IMethod => ILSpy.Images.Images.Method, + IField => ILSpy.Images.Images.Field, + IProperty => ILSpy.Images.Images.Property, + IEvent => ILSpy.Images.Images.Event, + INamespace => ILSpy.Images.Images.Namespace, + IModule => ILSpy.Images.Images.Assembly, + _ => ILSpy.Images.Images.Class, + }; + + static object TypeIconForKind(ITypeDefinition t) => t.Kind switch { + TypeKind.Interface => ILSpy.Images.Images.Interface, + TypeKind.Struct => ILSpy.Images.Images.Struct, + TypeKind.Enum => ILSpy.Images.Images.Enum, + TypeKind.Delegate => ILSpy.Images.Images.Delegate, + _ => ILSpy.Images.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. + // Clicking a row jumps to the underlying entity in the assembly tree, which + // then drives the normal decompile flow on whichever tab the user picks. + } + + public override FilterResult Filter(LanguageSettings settings) + { + // Hide identical rows unless the toolbar toggle says otherwise. Recursive + // because a parent node whose ALL descendants are unchanged should also hide. + return pane.ShowIdentical || entry.RecursiveKind != DiffKind.None + ? FilterResult.Match + : FilterResult.Hidden; + } + + /// + /// Pastel-tint background per diff kind: green = added on the right, pink = removed + /// from the right (i.e. only on the left), blue = signatures match but contents + /// differ. Identical rows fall through to the parent background. + /// + public IBrush BackgroundBrush => entry.RecursiveKind switch { + DiffKind.Add => new SolidColorBrush(Color.FromArgb(0xFF, 0x90, 0xEE, 0x90)), + DiffKind.Remove => new SolidColorBrush(Color.FromArgb(0xFF, 0xFF, 0xB6, 0xC1)), + DiffKind.Update => new SolidColorBrush(Color.FromArgb(0xFF, 0xAD, 0xD8, 0xE6)), + _ => Brushes.Transparent, + }; + } +} diff --git a/ILSpy/ViewModels/CompareTabPageModel.cs b/ILSpy/ViewModels/CompareTabPageModel.cs new file mode 100644 index 000000000..7445e5b07 --- /dev/null +++ b/ILSpy/ViewModels/CompareTabPageModel.cs @@ -0,0 +1,91 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using ICSharpCode.ILSpyX; + +using ILSpy.ViewModels; + +namespace ILSpy.Compare +{ + /// + /// Tab content for the assembly-comparison view. Constructed by + /// when the user right-clicks two + /// assemblies and picks "Compare…". Static content (no language switching, no + /// re-decompile) — the merge is computed once at construction. + /// + public sealed partial class CompareTabPageModel : TabPageModel + { + readonly LoadedAssembly leftAssembly; + readonly LoadedAssembly rightAssembly; + + [ObservableProperty] + private ComparisonEntryTreeNode rootEntry; + + /// + /// When true, identical rows surface in the tree. The default false hides them so + /// only the actual differences are visible — the typical use case for "what changed + /// between these two versions?" + /// + [ObservableProperty] + private bool showIdentical; + + public LoadedAssembly LeftAssembly => leftAssembly; + public LoadedAssembly RightAssembly => rightAssembly; + + public CompareTabPageModel(LoadedAssembly left, LoadedAssembly right) + { + leftAssembly = left; + rightAssembly = right; + Title = $"Compare {left.Text} - {right.Text}"; + + var leftTree = CompareEngine.CreateEntityTree(left.GetTypeSystemOrNull()!); + var rightTree = CompareEngine.CreateEntityTree(right.GetTypeSystemOrNull()!); + var merged = CompareEngine.MergeTrees(leftTree.Root, rightTree.Root); + rootEntry = new ComparisonEntryTreeNode(merged, this); + + ExpandAllCommand = new RelayCommand(OnExpandAll); + } + + public IRelayCommand ExpandAllCommand { get; } + + void OnExpandAll() + { + ExpandRecursive(RootEntry); + } + + static void ExpandRecursive(ComparisonEntryTreeNode node) + { + node.IsExpanded = true; + node.EnsureLazyChildren(); + foreach (var child in node.Children) + if (child is ComparisonEntryTreeNode c) + ExpandRecursive(c); + } + + partial void OnShowIdenticalChanged(bool value) + { + // Re-apply the filter cascade to refresh hidden/visible state — the existing + // ApplyFilterToChild path looks at the new ShowIdentical flag through + // ComparisonEntryTreeNode.Filter. + RootEntry.EnsureChildrenFiltered(); + } + } +} diff --git a/ILSpy/Views/CompareView.axaml b/ILSpy/Views/CompareView.axaml new file mode 100644 index 000000000..c67d927f4 --- /dev/null +++ b/ILSpy/Views/CompareView.axaml @@ -0,0 +1,50 @@ + + + + +