Browse Source

Compare pane — diff two assemblies into a merged tree

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.
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
26a905aa6e
  1. 117
      ILSpy.Tests/Compare/CompareContextMenuTests.cs
  2. 136
      ILSpy.Tests/Compare/CompareEngineTests.cs
  3. 4
      ILSpy/App.axaml
  4. 71
      ILSpy/Commands/CompareContextMenuEntry.cs
  5. 303
      ILSpy/CompareEngine.cs
  6. 118
      ILSpy/Entry.cs
  7. 131
      ILSpy/TreeNodes/ComparisonEntryTreeNode.cs
  8. 91
      ILSpy/ViewModels/CompareTabPageModel.cs
  9. 50
      ILSpy/Views/CompareView.axaml
  10. 30
      ILSpy/Views/CompareView.axaml.cs

117
ILSpy.Tests/Compare/CompareContextMenuTests.cs

@ -0,0 +1,117 @@ @@ -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;
/// <summary>
/// Pins the right-click → Compare flow: visibility gate (exactly two valid assembly
/// nodes), and that <c>Execute</c> opens a fresh tab containing a
/// <see cref="CompareTabPageModel"/> with a populated root.
/// </summary>
[TestFixture]
public class CompareContextMenuTests
{
[AvaloniaTest]
public async Task Compare_Is_Visible_When_Two_Assemblies_Are_Selected()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2);
var entry = AppComposition.Current.GetExport<global::ILSpy.ContextMenuEntryRegistry>()
.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<AssemblyTreeNode>(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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var entry = AppComposition.Current.GetExport<global::ILSpy.ContextMenuEntryRegistry>()
.Entries.Single(e => e.Metadata.Header == "Compare...").Value;
var coreLibName = typeof(object).Assembly.GetName().Name!;
var node = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 2);
var entry = AppComposition.Current.GetExport<global::ILSpy.ContextMenuEntryRegistry>()
.Entries.Single(e => e.Metadata.Header == "Compare...").Value;
var dockWorkspace = AppComposition.Current.GetExport<global::ILSpy.Docking.DockWorkspace>();
var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
.Where(a => a.IsLoadedAsValidAssembly).Take(2).ToList();
var nodes = assemblies.Select(a =>
(SharpTreeNode)vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>(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<ContentTabPage>()
.Select(t => t.Content)
.OfType<CompareTabPageModel>()
.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");
}
}

136
ILSpy.Tests/Compare/CompareEngineTests.cs

@ -0,0 +1,136 @@ @@ -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;
/// <summary>
/// Unit tests for the assembly-comparison engine. The diff logic is pure data — synthetic
/// <see cref="Entry"/> trees, no decompiler required — so each scenario runs in
/// milliseconds.
/// </summary>
[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<Entry> { Leaf("M1"), Leaf("M2") };
var right = new List<Entry> { 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<Entry> { Leaf("Stays"), Leaf("Gone") };
var right = new List<Entry> { 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<Entry> { Leaf("Stays") };
var right = new List<Entry> { 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));
}
}

4
ILSpy/App.axaml

@ -5,6 +5,7 @@ @@ -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 @@ @@ -57,6 +58,9 @@
<DataTemplate DataType="vm:DebugStepsPaneModel">
<metaViews:DebugSteps />
</DataTemplate>
<DataTemplate DataType="compare:CompareTabPageModel">
<compare:CompareView />
</DataTemplate>
<local:ViewLocator/>
</Application.DataTemplates>

71
ILSpy/Commands/CompareContextMenuEntry.cs

@ -0,0 +1,71 @@ @@ -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
{
/// <summary>
/// Right-click → "Compare…" on exactly two valid managed assemblies in the tree.
/// Opens a fresh tab whose content is a <see cref="CompareTabPageModel"/> driven by
/// <see cref="CompareEngine"/>. Order matters: the first-selected node becomes the
/// "left" side, the second becomes the "right".
/// </summary>
[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<DockWorkspace>();
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<T>() where T : class
{
try
{ return AppComposition.Current.GetExport<T>(); }
catch { return null; }
}
}
}

303
ILSpy/CompareEngine.cs

@ -0,0 +1,303 @@ @@ -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
{
/// <summary>
/// Builds a merged <see cref="Entry"/> tree from two assemblies' type systems. The
/// process is:
/// <list type="number">
/// <item>Walk each assembly's public surface (<see cref="CreateEntityTree"/>) into a
/// per-side <see cref="Entry"/> tree keyed on C#-formatted signature strings.</item>
/// <item>Recursively merge the two trees (<see cref="MergeTrees"/>) — paired entries
/// get an <see cref="Entry.OtherEntity"/>; left-only entries are tagged
/// <see cref="DiffKind.Remove"/>; right-only are tagged <see cref="DiffKind.Add"/>.</item>
/// </list>
/// Signatures are the keying mechanism because metadata tokens don't survive
/// re-compilation; <see cref="EntryComparer"/> wraps the string-equality check.
/// </summary>
public static class CompareEngine
{
/// <summary>
/// Walks <paramref name="typeSystem"/>'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 <see cref="MergeTrees"/>; the tree is the
/// hierarchical view the UI consumes.
/// </summary>
public static (List<Entry> 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<Entry>();
var typeEntries = new Dictionary<TypeDefinitionHandle, Entry>();
var namespaceEntries = new Dictionary<string, Entry>(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<Entry>();
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<Entry>();
root.Children.Add(nsEntry);
}
entry.Parent = nsEntry;
nsEntry.Children ??= new List<Entry>();
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;
}
/// <summary>
/// Recursively merges two parallel entry trees into a single one. Each interior
/// node's children are paired by <see cref="EntryComparer"/>; unmatched children
/// keep their leaf <see cref="Entry.Kind"/> tag (Add / Remove) so the recursive
/// summary on ancestors reflects the actual change shape.
/// </summary>
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<Entry>();
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<Entry>();
foreach (var child in a.Children)
{
child.Parent = m;
m.Children.Add(child);
}
}
else if (b.Children?.Count > 0)
{
m.Children ??= new List<Entry>();
foreach (var child in b.Children)
{
child.Parent = m;
m.Children.Add(child);
}
}
return m;
}
/// <summary>
/// Pairs <paramref name="left"/> and <paramref name="right"/> by signature.
/// Returns one <c>(Left, Right)</c> tuple per slot: both non-null when matched;
/// only Left → Remove (and tags the leaf <see cref="DiffKind.Remove"/>); only
/// Right → Add (and tags the leaf <see cref="DiffKind.Add"/>).
/// </summary>
public static List<(Entry? Left, Entry? Right)> CalculateDiff(List<Entry> left, List<Entry> right)
{
var leftMap = new Dictionary<string, List<Entry>>();
var rightMap = new Dictionary<string, List<Entry>>();
foreach (var item in left)
{
if (leftMap.TryGetValue(item.Signature, out var bucket))
bucket.Add(item);
else
leftMap[item.Signature] = new List<Entry> { item };
}
foreach (var item in right)
{
if (rightMap.TryGetValue(item.Signature, out var bucket))
bucket.Add(item);
else
rightMap[item.Signature] = new List<Entry> { 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;
}
}
}
}

118
ILSpy/Entry.cs

@ -0,0 +1,118 @@ @@ -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
{
/// <summary>
/// How an <see cref="Entry"/> compares between the left and right assemblies. Stored on
/// leaf entries by <see cref="CompareEngine"/>; interior nodes derive a recursive
/// summary via <see cref="Entry.RecursiveKind"/>.
/// </summary>
public enum DiffKind
{
/// <summary>Identical on both sides.</summary>
None = ' ',
/// <summary>Only present on the right.</summary>
Add = '+',
/// <summary>Only present on the left.</summary>
Remove = '-',
/// <summary>Present on both but the bodies differ.</summary>
Update = '~',
}
/// <summary>
/// One node in the merged comparison tree. The <see cref="Entity"/> is the symbol on
/// the left assembly; <see cref="OtherEntity"/> is the right-side counterpart when one
/// exists. <see cref="Signature"/> is the C#-formatted text the engine keys on for
/// matching pairs across sides.
/// </summary>
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
public sealed class Entry
{
DiffKind? kind;
/// <summary>
/// Per-node diff kind for leaves; for interior nodes, summarises the children:
/// all-Add → Add; all-Remove → Remove; any-non-None → Update; otherwise None.
/// </summary>
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<Entry>? Children { get; set; }
string GetDebuggerDisplay() => $"Entry{Kind} {Entity?.ToString() ?? Signature}";
}
/// <summary>
/// Two entries match when their <see cref="Entry.Signature"/> string is equal — that's
/// the only stable key across an assembly version, since metadata tokens are not
/// preserved across rebuilds.
/// </summary>
public sealed class EntryComparer : IEqualityComparer<Entry>
{
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();
}
}

131
ILSpy/TreeNodes/ComparisonEntryTreeNode.cs

@ -0,0 +1,131 @@ @@ -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
{
/// <summary>
/// Tree-node façade over a merged <see cref="Entry"/>. The text combines both sides'
/// formatted entity strings (with a <c>" -> "</c> 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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,
};
}
}

91
ILSpy/ViewModels/CompareTabPageModel.cs

@ -0,0 +1,91 @@ @@ -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
{
/// <summary>
/// Tab content for the assembly-comparison view. Constructed by
/// <see cref="Commands.CompareContextMenuEntry"/> 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.
/// </summary>
public sealed partial class CompareTabPageModel : TabPageModel
{
readonly LoadedAssembly leftAssembly;
readonly LoadedAssembly rightAssembly;
[ObservableProperty]
private ComparisonEntryTreeNode rootEntry;
/// <summary>
/// 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?"
/// </summary>
[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();
}
}
}

50
ILSpy/Views/CompareView.axaml

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:compare="using:ILSpy.Compare"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400"
x:Class="ILSpy.Compare.CompareView"
x:DataType="compare:CompareTabPageModel">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="4">
<CheckBox Content="Show identical entries"
IsChecked="{Binding ShowIdentical, Mode=TwoWay}"
ToolTip.Tip="Reveal rows that are identical on both sides — by default the tree only shows actual differences." />
<Button Margin="8,0,0,0" Content="Expand all"
Command="{Binding ExpandAllCommand}"
ToolTip.Tip="Expand every namespace and type in the diff tree." />
</StackPanel>
<DataGrid Name="DiffGrid"
Classes="hierarchical"
AutoGenerateColumns="False"
HeadersVisibility="None"
HierarchicalRowsEnabled="True"
GridLinesVisibility="None"
CanUserResizeColumns="False"
SelectionMode="Single"
ItemsSource="{Binding RootEntry.Children}">
<DataGrid.Columns>
<DataGridHierarchicalColumn Header="Diff" Width="*"
IsReadOnly="True"
x:CompileBindings="False"
Binding="{Binding Item}">
<DataGridHierarchicalColumn.CellTemplate>
<DataTemplate>
<Border Background="{Binding BackgroundBrush}" Padding="4,1">
<Grid ColumnDefinitions="Auto,7,*"
VerticalAlignment="Center">
<Image Grid.Column="0" Source="{Binding Icon}"
Width="16" Height="16"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding Text}"
VerticalAlignment="Center" />
</Grid>
</Border>
</DataTemplate>
</DataGridHierarchicalColumn.CellTemplate>
</DataGridHierarchicalColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

30
ILSpy/Views/CompareView.axaml.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
// 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 Avalonia.Controls;
namespace ILSpy.Compare
{
public partial class CompareView : UserControl
{
public CompareView()
{
InitializeComponent();
}
}
}
Loading…
Cancel
Save