Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions ICSharpCode.ILSpyX/Dependencies/UnresolvedReferencesReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Piero Viano
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;

using ICSharpCode.Decompiler.Metadata;

namespace ICSharpCode.ILSpyX.Dependencies
{
/// <summary>An assembly reference that could not be resolved, with its resolution log.</summary>
public sealed class UnresolvedReference
{
public UnresolvedReference(AssemblyReference reference, IReadOnlyList<(MessageKind Kind, string Message)> messages)
{
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
Messages = messages ?? throw new ArgumentNullException(nameof(messages));
}

public AssemblyReference Reference { get; }

/// <summary>The probing messages the assembly resolver logged for this reference.</summary>
public IReadOnlyList<(MessageKind Kind, string Message)> Messages { get; }
}

/// <summary>The unresolved references of one referencing assembly.</summary>
public sealed class UnresolvedReferenceGroup
{
public UnresolvedReferenceGroup(LoadedAssembly assembly, IReadOnlyList<UnresolvedReference> references)
{
Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly));
References = references ?? throw new ArgumentNullException(nameof(references));
}

public LoadedAssembly Assembly { get; }

public IReadOnlyList<UnresolvedReference> References { get; }
}

/// <summary>
/// Collects every assembly reference that does not resolve, grouped by the referencing
/// assembly. Resolution goes through each assembly's own resolver (the same one the References
/// folder uses), so the probing log it records is what the report shows.
/// </summary>
public static class UnresolvedReferencesReport
{
/// <summary>
/// Resolves every assembly reference of <paramref name="assemblies"/> and returns the groups
/// that have at least one unresolved reference, in input order. Assemblies that failed to
/// load are skipped. Must not run on the UI thread: resolving may probe the file system.
/// </summary>
public static IReadOnlyList<UnresolvedReferenceGroup> Collect(IEnumerable<LoadedAssembly> assemblies,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(assemblies);
var groups = new List<UnresolvedReferenceGroup>();
foreach (var assembly in assemblies.ToList())
{
cancellationToken.ThrowIfCancellationRequested();
var module = assembly.GetMetadataFileOrNull();
if (module == null)
continue;
var resolver = assembly.GetAssemblyResolver();
var unresolved = new List<UnresolvedReference>();
foreach (var reference in module.AssemblyReferences)
{
cancellationToken.ThrowIfCancellationRequested();
MetadataFile? resolved;
try
{
resolved = resolver.Resolve(reference);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
resolved = null;
}
if (resolved != null)
continue;
IReadOnlyList<(MessageKind, string)> messages =
assembly.LoadedAssemblyReferencesInfo.TryGetInfo(reference.FullName, out var info)
? info.Messages.ToList()
: Array.Empty<(MessageKind, string)>();
unresolved.Add(new UnresolvedReference(reference, messages));
}
if (unresolved.Count > 0)
groups.Add(new UnresolvedReferenceGroup(assembly, unresolved));
}
return groups;
}
}
}
131 changes: 131 additions & 0 deletions ILSpy.Tests/Dependencies/DependencyFixtures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Piero Viano
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.IO;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.Loader;

namespace ICSharpCode.ILSpy.Tests.Dependencies;

/// <summary>
/// Emits small library/consumer assembly pairs for the dependency features. The library defines
/// <c>Api.Compute(int)</c>; the consumer calls it from <see cref="BodyUserType"/>.<see cref="BodyUserMethod"/>,
/// so it carries an assembly reference to the library.
/// </summary>
static class DependencyFixtures
{
public const string BodyUserType = "UsesInBody";
public const string BodyUserMethod = "Run";

/// <summary>A fresh, empty temp directory.</summary>
public static string NewDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), $"ILSpyDependencyFixture_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
return dir;
}

/// <summary>A unique simple assembly name with the given prefix.</summary>
public static string UniqueName(string prefix) => prefix + Guid.NewGuid().ToString("N").Substring(0, 8);

/// <summary>Emits the library <c>&lt;directory&gt;/&lt;name&gt;.dll</c> and returns its path.</summary>
public static string EmitLibrary(string directory, string name)
{
var assemblyName = new AssemblyName(name) { Version = new Version(1, 0, 0, 0) };
var ab = new PersistedAssemblyBuilder(assemblyName, typeof(object).Assembly);
var module = ab.DefineDynamicModule(name);

var api = module.DefineType($"{name}.Api", TypeAttributes.Public | TypeAttributes.Class);
var compute = api.DefineMethod("Compute", MethodAttributes.Public | MethodAttributes.Static, typeof(int), [typeof(int)]);
var il = compute.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ret);
api.CreateType();

var path = Path.Combine(directory, name + ".dll");
ab.Save(path);
return path;
}

/// <summary>
/// Emits the consumer <c>&lt;directory&gt;/&lt;name&gt;.dll</c> compiled against the library at
/// <paramref name="libraryPath"/> and returns its path. The library is read into a collectible
/// load context from memory, so its file is neither locked nor kept loaded.
/// </summary>
public static string EmitConsumer(string directory, string name, string libraryPath)
{
var context = new AssemblyLoadContext(name, isCollectible: true);
try
{
using var stream = new MemoryStream(File.ReadAllBytes(libraryPath));
var library = context.LoadFromStream(stream);
string libraryName = library.GetName().Name!;
var apiType = library.GetType($"{libraryName}.Api", throwOnError: true)!;

var ab = new PersistedAssemblyBuilder(new AssemblyName(name) { Version = new Version(1, 0, 0, 0) }, typeof(object).Assembly);
var module = ab.DefineDynamicModule(name);

var bodyUser = module.DefineType($"{name}.{BodyUserType}", TypeAttributes.Public | TypeAttributes.Class);
var run = bodyUser.DefineMethod(BodyUserMethod, MethodAttributes.Public | MethodAttributes.Static, typeof(int), Type.EmptyTypes);
var il = run.GetILGenerator();
il.Emit(OpCodes.Ldc_I4_1);
il.Emit(OpCodes.Call, apiType.GetMethod("Compute")!);
il.Emit(OpCodes.Ret);
bodyUser.CreateType();

var path = Path.Combine(directory, name + ".dll");
ab.Save(path);
return path;
}
finally
{
context.Unload();
}
}

/// <summary>
/// Emits a library and a consumer next to each other in a fresh directory, so the consumer's
/// reference resolves from its own folder.
/// </summary>
public static (string LibraryPath, string ConsumerPath, string LibraryName, string ConsumerName) EmitPair(string prefix = "Dep")
{
var dir = NewDirectory();
var libraryName = UniqueName(prefix + "Lib");
var consumerName = UniqueName(prefix + "App");
var library = EmitLibrary(dir, libraryName);
var consumer = EmitConsumer(dir, consumerName, library);
return (library, consumer, libraryName, consumerName);
}

/// <summary>
/// Emits a consumer whose library reference cannot be resolved: the library is built in a
/// separate directory that is deleted afterwards.
/// </summary>
public static (string ConsumerPath, string MissingLibraryName) EmitConsumerWithMissingReference(string prefix = "Dep")
{
var libraryDir = NewDirectory();
var consumerDir = NewDirectory();
var libraryName = UniqueName(prefix + "Missing");
var library = EmitLibrary(libraryDir, libraryName);
var consumer = EmitConsumer(consumerDir, UniqueName(prefix + "Orphan"), library);
Directory.Delete(libraryDir, recursive: true);
return (consumer, libraryName);
}
}
67 changes: 67 additions & 0 deletions ILSpy.Tests/Dependencies/UnresolvedReferencesReportTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Piero Viano
//
// 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 AwesomeAssertions;

using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.Dependencies;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.Dependencies;

[TestFixture]
public class UnresolvedReferencesReportTests
{
[Test]
public async Task Missing_Reference_Is_Reported_Under_Its_Referencing_Assembly_With_The_Probe_Log()
{
var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Report");
var (libraryPath, resolvableConsumerPath, libraryName, _) = DependencyFixtures.EmitPair("Report");
var list = new AssemblyList();
var orphan = list.OpenAssembly(consumerPath);
var healthy = list.OpenAssembly(resolvableConsumerPath);
await orphan.GetLoadResultAsync();
await healthy.GetLoadResultAsync();

var groups = UnresolvedReferencesReport.Collect([orphan, healthy]);

var group = groups.Single(g => g.Assembly == orphan);
var missing = group.References.Single(r => r.Reference.Name == missingName);
missing.Messages.Should().Contain(m => m.Kind == MessageKind.Error && m.Message.Contains(missingName),
"the resolver's probe log explains why the reference failed");
groups.SelectMany(g => g.References).Should().NotContain(r => r.Reference.Name == libraryName,
"a reference that resolves is not reported");
}

[Test]
public async Task Fully_Resolved_Assemblies_Produce_No_Group()
{
var (libraryPath, _, _, _) = DependencyFixtures.EmitPair("Report");
var list = new AssemblyList();
var library = list.OpenAssembly(libraryPath);
await library.GetLoadResultAsync();

// The fixture library only references the core library, which always resolves.
UnresolvedReferencesReport.Collect([library]).Should().BeEmpty();
}
}
67 changes: 67 additions & 0 deletions ILSpy.Tests/Dependencies/UnresolvedReferencesReportUiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Piero Viano
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.

using System;
using System.Linq;
using System.Threading.Tasks;

using Avalonia.Headless.NUnit;

using AwesomeAssertions;

using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Commands;
using ICSharpCode.ILSpy.Docking;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.ViewModels;

using NUnit.Framework;

namespace ICSharpCode.ILSpy.Tests.Dependencies;

[TestFixture]
public class UnresolvedReferencesReportUiTests
{
[AvaloniaTest]
public async Task Unresolved_References_Report_Lists_The_Missing_Reference_With_Its_Log()
{
var (_, vm) = await TestHarness.BootAsync();
var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Ui");
var orphan = await vm.OpenAssemblyAsync(consumerPath);
vm.AssemblyTreeModel.SelectNode<AssemblyTreeNode>(orphan.ShortName);

var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
registry.Commands.Single(c => c.Metadata.Header == nameof(Resources.UnresolvedReferencesReport)).Metadata.ParentMenuID
.Should().Be(nameof(Resources._Navigate));
registry.GetCommand(nameof(Resources.UnresolvedReferencesReport)).Execute(null);

var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
string? text = null;
await Waiters.WaitForAsync(() => {
var content = dockWorkspace.Documents?.VisibleDockables?.OfType<ContentTabPage>()
.Select(t => t.Content).OfType<DecompilerTabPageModel>()
.FirstOrDefault(c => c.Title == Resources.UnresolvedReferencesReport);
text = content?.Text;
return text?.Contains(missingName, StringComparison.Ordinal) == true;
}, description: "unresolved references report text");
text.Should().Contain(orphan.ShortName);
text.Should().Contain("Could not find reference");
}
}
Loading