From aae31353493dc37e16d1588783b01d0c35c6bdf2 Mon Sep 17 00:00:00 2001 From: pieroviano Date: Tue, 6 Oct 2026 07:09:13 +0300 Subject: [PATCH] Add Referenced By and Dependent Code analysis for assemblies Shows which loaded assemblies reference a given assembly and which of their members actually use it, so the impact of changing or removing an assembly can be judged without decompiling every consumer by hand. --- .../Metadata/MetadataExtensions.cs | 38 ++ .../Builtin/ModuleDependentCodeAnalyzer.cs | 68 +++ .../Builtin/ModuleReferencedByAnalyzer.cs | 59 +++ .../Dependencies/DependentCodeScanner.cs | 437 ++++++++++++++++++ .../Dependencies/ReferencedByFinder.cs | 99 ++++ .../Analyzers/AnalyzeContextMenuTests.cs | 14 +- .../Dependencies/DependencyFixtures.cs | 215 +++++++++ ILSpy.Tests/Dependencies/DependencyUiTests.cs | 158 +++++++ .../Dependencies/ModuleAnalyzersTests.cs | 128 +++++ .../Dependencies/ReferencedByFinderTests.cs | 101 ++++ ILSpy/Analyzers/AnalyzeContextMenuEntry.cs | 39 +- ILSpy/Analyzers/AnalyzerTreeViewModel.cs | 35 ++ ILSpy/Dependencies/DependencyCommands.cs | 130 ++++++ ILSpy/Dependencies/ReferencedByTreeNodes.cs | 173 +++++++ ILSpy/Properties/Resources.Designer.cs | 18 + ILSpy/Properties/Resources.resx | 6 + ILSpy/TreeNodes/AssemblyTreeNode.cs | 2 + 17 files changed, 1705 insertions(+), 15 deletions(-) create mode 100644 ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs create mode 100644 ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs create mode 100644 ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs create mode 100644 ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs create mode 100644 ILSpy.Tests/Dependencies/DependencyFixtures.cs create mode 100644 ILSpy.Tests/Dependencies/DependencyUiTests.cs create mode 100644 ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs create mode 100644 ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs create mode 100644 ILSpy/Dependencies/DependencyCommands.cs create mode 100644 ILSpy/Dependencies/ReferencedByTreeNodes.cs diff --git a/ICSharpCode.Decompiler/Metadata/MetadataExtensions.cs b/ICSharpCode.Decompiler/Metadata/MetadataExtensions.cs index 1b911b57c8c..330994fa26b 100644 --- a/ICSharpCode.Decompiler/Metadata/MetadataExtensions.cs +++ b/ICSharpCode.Decompiler/Metadata/MetadataExtensions.cs @@ -139,6 +139,44 @@ public static bool TryGetFullAssemblyName(this SRM.AssemblyReference reference, } } + /// + /// True when names the assembly defined by : + /// same simple name (ignoring case), culture and public key token. The version is not compared, + /// since binding redirects and unified frameworks satisfy references with other versions. + /// + public static bool IsReferenceTo(this IAssemblyReference reference, MetadataReader reader) + { + if (reference == null) + throw new ArgumentNullException(nameof(reference)); + if (reader == null) + throw new ArgumentNullException(nameof(reader)); + if (!reader.IsAssembly) + return false; + var asm = reader.GetAssemblyDefinition(); + if (!string.Equals(reference.Name, reader.GetString(asm.Name), StringComparison.OrdinalIgnoreCase)) + return false; + if (!string.Equals(NormalizeCulture(reference.Culture), NormalizeCulture(reader.GetString(asm.Culture)), StringComparison.OrdinalIgnoreCase)) + return false; + var token = reference.PublicKeyToken is { Length: > 0 } bytes ? bytes.ToHexString(bytes.Length) : "null"; + return string.Equals(token, reader.GetPublicKeyToken(), StringComparison.OrdinalIgnoreCase); + + static string NormalizeCulture(string culture) + => string.IsNullOrEmpty(culture) || culture == "neutral" ? string.Empty : culture; + } + + /// + /// True when asks for a different version than the assembly + /// defined by . Unknown versions never count as a mismatch. + /// + public static bool IsVersionMismatch(this IAssemblyReference reference, MetadataReader reader) + { + if (reference == null) + throw new ArgumentNullException(nameof(reference)); + if (reader == null) + throw new ArgumentNullException(nameof(reader)); + return reference.Version != null && reader.IsAssembly && reference.Version != reader.GetAssemblyDefinition().Version; + } + public static string ToHexString(this IEnumerable bytes, int estimatedLength) { if (bytes == null) diff --git a/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs new file mode 100644 index 00000000000..f06a134ecef --- /dev/null +++ b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleDependentCodeAnalyzer.cs @@ -0,0 +1,68 @@ +// 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.Composition; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Dependencies; + +namespace ICSharpCode.ILSpyX.Analyzers.Builtin +{ + /// + /// For an assembly, lists the code in the other assemblies of the list that depends on it: every + /// type or member whose signature, base types, attributes or body uses a type or member of the + /// analyzed assembly. Results are grouped by referencing assembly (assemblies in list order). + /// + [ExportAnalyzer(Header = "Dependent Code", Order = 20)] + [Shared] + class ModuleDependentCodeAnalyzer : IAnalyzer + { + public bool Show(ISymbol? symbol) => symbol is IModule { MetadataFile.IsAssembly: true }; + + public IEnumerable Analyze(ISymbol analyzedSymbol, AnalyzerContext context) + { + ArgumentNullException.ThrowIfNull(analyzedSymbol); + ArgumentNullException.ThrowIfNull(context); + if (analyzedSymbol is not IModule { MetadataFile: { } target }) + throw new ArgumentException("A module with metadata is required.", nameof(analyzedSymbol)); + return AnalyzeCore(target, context); + } + + static IEnumerable AnalyzeCore(MetadataFile target, AnalyzerContext context) + { + var ct = context.CancellationToken; + var assemblies = context.AssemblyList.GetAllAssemblies().GetAwaiter().GetResult(); + foreach (var assembly in assemblies) + { + ct.ThrowIfCancellationRequested(); + var module = assembly.GetMetadataFileOrNull(); + if (module == null || module.IsMetadataOnly || ReferenceEquals(module, target)) + continue; + var references = DependentCodeScanner.FindReferencesTo(module, target); + if (references.Count == 0) + continue; + var typeSystem = context.GetOrCreateTypeSystem(module); + foreach (var symbol in DependentCodeScanner.FindDependentSymbols(module, typeSystem, references, ct)) + yield return symbol; + } + } + } +} diff --git a/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs new file mode 100644 index 00000000000..fde248ba408 --- /dev/null +++ b/ICSharpCode.ILSpyX/Analyzers/Builtin/ModuleReferencedByAnalyzer.cs @@ -0,0 +1,59 @@ +// 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.Composition; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Dependencies; + +namespace ICSharpCode.ILSpyX.Analyzers.Builtin +{ + /// + /// For an assembly, lists the assemblies of the list that reference it (by name and public key + /// token; the referenced version may differ). Each result is itself a module, so it can be + /// expanded to follow the chain of referencing assemblies further. + /// + [ExportAnalyzer(Header = "Referenced By", Order = 10)] + [Shared] + class ModuleReferencedByAnalyzer : IAnalyzer + { + public bool Show(ISymbol? symbol) => symbol is IModule { MetadataFile.IsAssembly: true }; + + public IEnumerable Analyze(ISymbol analyzedSymbol, AnalyzerContext context) + { + ArgumentNullException.ThrowIfNull(analyzedSymbol); + ArgumentNullException.ThrowIfNull(context); + if (analyzedSymbol is not IModule { MetadataFile: { } target }) + throw new ArgumentException("A module with metadata is required.", nameof(analyzedSymbol)); + return AnalyzeCore(target, context); + } + + static IEnumerable AnalyzeCore(MetadataFile target, AnalyzerContext context) + { + var assemblies = context.AssemblyList.GetAllAssemblies().GetAwaiter().GetResult(); + foreach (var referencing in ReferencedByFinder.FindReferencingAssemblies(assemblies, target, context.CancellationToken)) + { + context.CancellationToken.ThrowIfCancellationRequested(); + yield return context.GetOrCreateTypeSystem(referencing.Module).MainModule; + } + } + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs b/ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs new file mode 100644 index 00000000000..44020c966d4 --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/DependentCodeScanner.cs @@ -0,0 +1,437 @@ +// 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.Collections.Immutable; +using System.Linq; +using System.Reflection.Metadata; +using System.Threading; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.Disassembler; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX.Analyzers; + +namespace ICSharpCode.ILSpyX.Dependencies +{ + /// + /// Finds the code of one module that depends on another assembly: the types, methods, fields, + /// properties and events whose signatures, base types, interfaces, generic constraints, custom + /// attributes (including those on parameters, and the types named in their arguments), locals + /// or IL operands mention a type or member whose resolution scope is one of a given set of + /// assembly references. Works on raw metadata, so an unresolvable target still counts as a + /// dependency; only the underlying types of enums in attribute arguments are resolved. + /// + sealed class DependentCodeScanner : ISignatureTypeProvider + { + readonly MetadataFile module; + readonly MetadataReader metadata; + readonly HashSet targetReferences; + readonly List targetIdentities; + readonly Dictionary typeReferenceCache = new(); + readonly Dictionary typeSpecificationCache = new(); + + DependentCodeScanner(MetadataFile module, IEnumerable targetReferences) + { + this.module = module; + this.metadata = module.Metadata; + this.targetReferences = new HashSet(targetReferences); + this.targetIdentities = module.AssemblyReferences + .Where(r => this.targetReferences.Contains(r.Handle)) + .ToList(); + } + + /// + /// The assembly references of that denote + /// (same name, culture and public key token). + /// + public static IReadOnlyList FindReferencesTo(MetadataFile module, MetadataFile target) + { + return module.AssemblyReferences + .Where(r => r.IsReferenceTo(target.Metadata)) + .Select(r => r.Handle) + .ToList(); + } + + /// + /// Entities of 's main module (which must be built over + /// ) that depend on any of , + /// each reported once, in metadata order (types, then members, then attribute owners). + /// Accessors are reported as their owning property or event, parameters as their method. + /// + public static IEnumerable FindDependentSymbols(MetadataFile module, DecompilerTypeSystem typeSystem, + IReadOnlyCollection targetReferences, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(module); + ArgumentNullException.ThrowIfNull(typeSystem); + ArgumentNullException.ThrowIfNull(targetReferences); + if (targetReferences.Count == 0) + return Array.Empty(); + var scanner = new DependentCodeScanner(module, targetReferences); + return scanner.Scan(typeSystem.MainModule, typeSystem, cancellationToken).Distinct(); + } + + IEnumerable Scan(MetadataModule mainModule, DecompilerTypeSystem typeSystem, CancellationToken ct) + { + foreach (var h in metadata.TypeDefinitions) + { + ct.ThrowIfCancellationRequested(); + var td = metadata.GetTypeDefinition(h); + bool found = UsesTarget(td.BaseType); + foreach (var ih in td.GetInterfaceImplementations()) + found |= UsesTarget(metadata.GetInterfaceImplementation(ih).Interface); + found |= UsesTargetInConstraints(td.GetGenericParameters()); + if (found && mainModule.GetDefinition(h) is { } type) + yield return type; + } + + foreach (var h in metadata.MethodDefinitions) + { + ct.ThrowIfCancellationRequested(); + var md = metadata.GetMethodDefinition(h); + bool found = Safe(() => AnyInSignature(md.DecodeSignature(this, null))) + || UsesTargetInConstraints(md.GetGenericParameters()) + || UsesTargetInBody(md); + if (found && mainModule.GetDefinition(h) is { } method) + yield return method.AccessorOwner ?? method; + } + + foreach (var h in metadata.FieldDefinitions) + { + ct.ThrowIfCancellationRequested(); + var fd = metadata.GetFieldDefinition(h); + if (Safe(() => fd.DecodeSignature(this, null)) && mainModule.GetDefinition(h) is { } field) + yield return field; + } + + foreach (var h in metadata.PropertyDefinitions) + { + ct.ThrowIfCancellationRequested(); + var pd = metadata.GetPropertyDefinition(h); + if (Safe(() => AnyInSignature(pd.DecodeSignature(this, null))) && mainModule.GetDefinition(h) is { } property) + yield return property; + } + + foreach (var h in metadata.EventDefinitions) + { + ct.ThrowIfCancellationRequested(); + var ed = metadata.GetEventDefinition(h); + if (UsesTarget(ed.Type) && mainModule.GetDefinition(h) is { } ev) + yield return ev; + } + + var argumentDecoder = new AttributeArgumentDecoder(this, mainModule); + var referencedParameters = new HashSet(); + foreach (var h in metadata.CustomAttributes) + { + ct.ThrowIfCancellationRequested(); + var attribute = metadata.GetCustomAttribute(h); + if (!UsesTargetInAttributeConstructor(attribute.Constructor) && !UsesTargetInArguments(attribute, argumentDecoder)) + continue; + if (attribute.Parent.Kind == HandleKind.Parameter) + { + referencedParameters.Add((ParameterHandle)attribute.Parent); + continue; + } + var parent = AnalyzerHelpers.GetParentEntity(typeSystem, attribute); + if (parent != null) + yield return parent; + } + + if (referencedParameters.Count == 0) + yield break; + foreach (var h in metadata.MethodDefinitions) + { + ct.ThrowIfCancellationRequested(); + if (metadata.GetMethodDefinition(h).GetParameters().Any(referencedParameters.Contains) + && mainModule.GetDefinition(h) is { } method) + { + yield return method.AccessorOwner ?? method; + } + } + } + + /// + /// True when the attribute constructor belongs to the target, or is a + /// constructor of this module whose signature mentions the target (e.g. an enum parameter). + /// + bool UsesTargetInAttributeConstructor(EntityHandle ctor) + { + if (ctor.Kind != HandleKind.MethodDefinition) + return UsesTarget(ctor); + var md = metadata.GetMethodDefinition((MethodDefinitionHandle)ctor); + return Safe(() => AnyInSignature(md.DecodeSignature(this, null))); + } + + /// True when an argument of is or names a type of the target. + static bool UsesTargetInArguments(CustomAttribute attribute, AttributeArgumentDecoder decoder) + { + return Safe(() => { + var value = attribute.DecodeValue(decoder); + return value.FixedArguments.Any(IsTargetArgument) + || value.NamedArguments.Any(a => IsTargetArgument(new(a.Type, a.Value))); + }); + + static bool IsTargetArgument(CustomAttributeTypedArgument argument) + => argument.Type.UsesTarget + || argument.Value is AttributeArgumentType { UsesTarget: true } + || (argument.Value is ImmutableArray> elements + && elements.Any(IsTargetArgument)); + } + + /// True when a parsed serialized type name names a type of the target, or is built from one. + bool IsTargetTypeName(TypeName name) + { + if (name.IsArray || name.IsPointer || name.IsByRef) + return IsTargetTypeName(name.GetElementType()); + if (name.IsConstructedGenericType) + return IsTargetTypeName(name.GetGenericTypeDefinition()) || name.GetGenericArguments().Any(IsTargetTypeName); + if (name.AssemblyName is not { } assemblyName) + return false; + var parsed = assemblyName.ToAssemblyName(); + return targetIdentities.Any(target => + string.Equals(target.Name, parsed.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(NormalizeCulture(target.Culture), NormalizeCulture(parsed.CultureName), StringComparison.OrdinalIgnoreCase) + && (target.PublicKeyToken ?? []).AsSpan().SequenceEqual(parsed.GetPublicKeyToken() ?? [])); + + static string NormalizeCulture(string? culture) + => string.IsNullOrEmpty(culture) || culture == "neutral" ? string.Empty : culture; + } + + static bool Safe(Func check) + { + try + { + return check(); + } + catch (BadImageFormatException) + { + return false; + } + catch (EnumUnderlyingTypeResolveException) + { + return false; + } + } + + static bool AnyInSignature(MethodSignature signature) + => signature.ReturnType || signature.ParameterTypes.Any(p => p); + + bool UsesTargetInConstraints(GenericParameterHandleCollection parameters) + { + foreach (var h in parameters) + { + var gp = metadata.GetGenericParameter(h); + foreach (var ch in gp.GetConstraints()) + { + if (UsesTarget(metadata.GetGenericParameterConstraint(ch).Type)) + return true; + } + } + return false; + } + + bool UsesTargetInBody(MethodDefinition md) + { + if (md.RelativeVirtualAddress == 0) + return false; + MethodBodyBlock body; + try + { + body = module.GetMethodBody(md.RelativeVirtualAddress); + } + catch (BadImageFormatException) + { + return false; + } + if (!body.LocalSignature.IsNil && UsesTarget(body.LocalSignature)) + return true; + var reader = body.GetILReader(); + try + { + while (reader.RemainingBytes > 0) + { + var opCode = reader.DecodeOpCode(); + switch (opCode.GetOperandType()) + { + case OperandType.Field: + case OperandType.Method: + case OperandType.Sig: + case OperandType.Tok: + case OperandType.Type: + if (UsesTarget(MetadataTokenHelpers.EntityHandleOrNil(reader.ReadInt32()))) + return true; + break; + default: + reader.SkipOperand(opCode); + break; + } + } + } + catch (BadImageFormatException) + { + // A truncated or malformed body is scanned up to the point where it breaks. + } + return false; + } + + /// + /// True when (a type or member reference, a type or method + /// specification, or a standalone signature) mentions the target assembly. + /// + bool UsesTarget(EntityHandle handle) + { + if (handle.IsNil) + return false; + return Safe(() => { + switch (handle.Kind) + { + case HandleKind.TypeReference: + return IsTargetTypeReference((TypeReferenceHandle)handle); + case HandleKind.TypeSpecification: + return IsTargetTypeSpecification((TypeSpecificationHandle)handle); + case HandleKind.MemberReference: + var mr = metadata.GetMemberReference((MemberReferenceHandle)handle); + if (UsesTarget(mr.Parent)) + return true; + return mr.GetKind() switch { + MemberReferenceKind.Method => AnyInSignature(mr.DecodeMethodSignature(this, null)), + MemberReferenceKind.Field => mr.DecodeFieldSignature(this, null), + _ => false, + }; + case HandleKind.MethodSpecification: + var ms = metadata.GetMethodSpecification((MethodSpecificationHandle)handle); + return UsesTarget(ms.Method) || ms.DecodeSignature(this, null).Any(t => t); + case HandleKind.StandaloneSignature: + var ss = metadata.GetStandaloneSignature((StandaloneSignatureHandle)handle); + return ss.GetKind() switch { + StandaloneSignatureKind.Method => AnyInSignature(ss.DecodeMethodSignature(this, null)), + StandaloneSignatureKind.LocalVariables => ss.DecodeLocalSignature(this, null).Any(t => t), + _ => false, + }; + default: + // Definitions in this module and module references are not the target. + return false; + } + }); + } + + bool IsTargetTypeReference(TypeReferenceHandle handle) + { + if (typeReferenceCache.TryGetValue(handle, out bool cached)) + return cached; + var scope = metadata.GetTypeReference(handle).ResolutionScope; + bool result = scope.Kind switch { + HandleKind.AssemblyReference => targetReferences.Contains((AssemblyReferenceHandle)scope), + HandleKind.TypeReference => IsTargetTypeReference((TypeReferenceHandle)scope), + _ => false, + }; + typeReferenceCache[handle] = result; + return result; + } + + bool IsTargetTypeSpecification(TypeSpecificationHandle handle) + { + if (typeSpecificationCache.TryGetValue(handle, out bool cached)) + return cached; + // Guards against a (malformed) self-referencing specification while it is decoded. + typeSpecificationCache[handle] = false; + bool result = metadata.GetTypeSpecification(handle).DecodeSignature(this, null); + typeSpecificationCache[handle] = result; + return result; + } + + /// + /// An attribute argument type as seen by : whether it + /// mentions the target, the underlying type when it is an enum, and whether it is System.Type. + /// + readonly record struct AttributeArgumentType(bool UsesTarget, PrimitiveTypeCode EnumUnderlyingType = 0, bool IsSystemType = false); + + /// + /// Decodes custom attribute blobs into s. Target detection + /// stays on raw metadata; enum underlying types, which the blob format requires, are resolved + /// through the type system. + /// + sealed class AttributeArgumentDecoder(DependentCodeScanner scanner, MetadataModule mainModule) + : ICustomAttributeTypeProvider + { + public AttributeArgumentType GetPrimitiveType(PrimitiveTypeCode typeCode) => new(false); + public AttributeArgumentType GetSystemType() => new(false, IsSystemType: true); + public AttributeArgumentType GetSZArrayType(AttributeArgumentType elementType) => new(elementType.UsesTarget); + public bool IsSystemType(AttributeArgumentType type) => type.IsSystemType; + + public AttributeArgumentType GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + if (handle.IsEnum(reader, out PrimitiveTypeCode underlying)) + return new(false, underlying); + return new(false, IsSystemType: ((EntityHandle)handle).IsKnownType(reader, KnownTypeCode.Type)); + } + + public AttributeArgumentType GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + if (((EntityHandle)handle).IsKnownType(reader, KnownTypeCode.Type)) + return new(false, IsSystemType: true); + return new(scanner.IsTargetTypeReference(handle), EnumUnderlyingTypeOf(mainModule.ResolveType(handle, default))); + } + + public AttributeArgumentType GetTypeFromSerializedName(string name) + { + if (!TypeName.TryParse(name.AsSpan(), out var parsed)) + return new(false); + IType? type; + try + { + type = ReflectionHelper.ParseReflectionName(name, new SimpleTypeResolveContext(mainModule)); + } + catch (ReflectionNameParseException) + { + type = null; + } + return new(scanner.IsTargetTypeName(parsed), EnumUnderlyingTypeOf(type)); + } + + public PrimitiveTypeCode GetUnderlyingEnumType(AttributeArgumentType type) + => type.EnumUnderlyingType != 0 ? type.EnumUnderlyingType : throw new EnumUnderlyingTypeResolveException(); + + static PrimitiveTypeCode EnumUnderlyingTypeOf(IType? type) + => type?.GetDefinition()?.EnumUnderlyingType?.GetDefinition()?.KnownTypeCode.ToPrimitiveTypeCode() ?? 0; + } + + #region ISignatureTypeProvider + public bool GetArrayType(bool elementType, ArrayShape shape) => elementType; + public bool GetByReferenceType(bool elementType) => elementType; + public bool GetFunctionPointerType(MethodSignature signature) => AnyInSignature(signature); + public bool GetGenericInstantiation(bool genericType, ImmutableArray typeArguments) + => genericType || typeArguments.Any(t => t); + public bool GetGenericMethodParameter(object? genericContext, int index) => false; + public bool GetGenericTypeParameter(object? genericContext, int index) => false; + public bool GetModifiedType(bool modifier, bool unmodifiedType, bool isRequired) => modifier || unmodifiedType; + public bool GetPinnedType(bool elementType) => elementType; + public bool GetPointerType(bool elementType) => elementType; + public bool GetPrimitiveType(PrimitiveTypeCode typeCode) => false; + public bool GetSZArrayType(bool elementType) => elementType; + public bool GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => false; + public bool GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + => IsTargetTypeReference(handle); + public bool GetTypeFromSpecification(MetadataReader reader, object? genericContext, TypeSpecificationHandle handle, byte rawTypeKind) + => IsTargetTypeSpecification(handle); + #endregion + } +} diff --git a/ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs b/ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs new file mode 100644 index 00000000000..0cfc8037d51 --- /dev/null +++ b/ICSharpCode.ILSpyX/Dependencies/ReferencedByFinder.cs @@ -0,0 +1,99 @@ +// 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 +{ + /// + /// One loaded assembly that references a given target assembly, together with the reference + /// row that names it. + /// + public sealed class ReferencingAssembly + { + public ReferencingAssembly(LoadedAssembly assembly, MetadataFile module, AssemblyReference reference, bool isVersionMismatch) + { + Assembly = assembly ?? throw new ArgumentNullException(nameof(assembly)); + Module = module ?? throw new ArgumentNullException(nameof(module)); + Reference = reference ?? throw new ArgumentNullException(nameof(reference)); + IsVersionMismatch = isVersionMismatch; + } + + /// The referencing assembly. + public LoadedAssembly Assembly { get; } + + /// The metadata of . + public MetadataFile Module { get; } + + /// The assembly reference in that names the target. + public AssemblyReference Reference { get; } + + /// True when the reference asks for a different version than the target defines. + public bool IsVersionMismatch { get; } + } + + /// + /// Finds the assemblies of a list that reference a given assembly (the reverse direction of the + /// References folder). A reference matches by simple name and public key token; the version may + /// differ and is reported through . + /// + public static class ReferencedByFinder + { + /// + /// Returns every assembly in other than + /// itself that has an assembly reference matching , ordered by + /// short name. Assemblies that failed to load are skipped. + /// + public static IReadOnlyList FindReferencingAssemblies( + IEnumerable candidates, MetadataFile target, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(target); + var results = new List(); + if (!target.IsAssembly) + return results; + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var module = candidate.GetMetadataFileOrNull(); + if (module == null || ReferenceEquals(module, target) || !module.IsAssembly) + continue; + if (string.Equals(module.FileName, target.FileName, StringComparison.OrdinalIgnoreCase)) + continue; + foreach (var reference in module.AssemblyReferences) + { + if (reference.IsReferenceTo(target.Metadata)) + { + results.Add(new ReferencingAssembly(candidate, module, reference, + reference.IsVersionMismatch(target.Metadata))); + break; + } + } + } + return results + .OrderBy(r => r.Assembly.ShortName, StringComparer.OrdinalIgnoreCase) + .ThenBy(r => r.Assembly.FileName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index 2ec2be441f9..a8567ac8b07 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -58,10 +58,11 @@ public async Task Analyze_Entry_Is_Registered_With_The_Localised_Header() } [AvaloniaTest] - public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_AssemblyTreeNode() + public async Task Analyze_Entry_Is_Visible_For_Member_And_Assembly_Tree_Nodes() { // AnalyzeContextMenuEntry visibility contract: visible when every selected node is - // an IMemberTreeNode (types, methods, fields, properties, events), hidden otherwise. + // an IMemberTreeNode (types, methods, fields, properties, events) or an assembly + // (analyzed as a whole module), hidden otherwise. var (_, vm) = await TestHarness.BootAsync(); @@ -75,7 +76,7 @@ public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_ var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)assemblyNode } }) - .Should().BeFalse("AssemblyTreeNode isn't an IMemberTreeNode — Analyze must hide"); + .Should().BeTrue("an assembly is analyzed as a module (Referenced By, Dependent Code)"); entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) .Should().BeFalse("no selection means no entity to analyse"); @@ -149,7 +150,12 @@ public async Task Analyze_Command_CanExecute_Tracks_The_Assembly_Tree_Selection( var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); vm.AssemblyTreeModel.SelectNode(assemblyNode); - command.CanExecute(null).Should().BeFalse("assembly nodes are not analyzable members"); + command.CanExecute(null).Should().BeTrue("assembly nodes are analyzed as whole modules"); + + var referencesFolder = assemblyNode.Children.First(c => c.GetType().Name == "ReferenceFolderTreeNode"); + vm.AssemblyTreeModel.SelectNode(referencesFolder); + + command.CanExecute(null).Should().BeFalse("a references folder is neither a member nor an assembly"); } [AvaloniaTest] diff --git a/ILSpy.Tests/Dependencies/DependencyFixtures.cs b/ILSpy.Tests/Dependencies/DependencyFixtures.cs new file mode 100644 index 00000000000..b6bd6a1ca16 --- /dev/null +++ b/ILSpy.Tests/Dependencies/DependencyFixtures.cs @@ -0,0 +1,215 @@ +// 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; + +/// +/// Emits small library/consumer assembly pairs for the dependency features. The library defines +/// Api.Compute(int), Model, LibAttribute and the enum Mode; the consumer +/// uses them in a method body (), in a signature +/// (), as a base type (), as an attribute +/// on a parameter () and inside the arguments of its own +/// attribute (, , +/// ), and has one type that does not touch the library at all +/// (). +/// +static class DependencyFixtures +{ + public const string BodyUserType = "UsesInBody"; + public const string BodyUserMethod = "Run"; + public const string SignatureUserType = "UsesInSignature"; + public const string SignatureUserMethod = "Echo"; + public const string DerivedType = "DerivedModel"; + public const string IndependentType = "Independent"; + public const string IndependentMethod = "Twice"; + public const string ParameterAttributeUserType = "UsesOnParameter"; + public const string ParameterAttributeUserMethod = "Take"; + public const string TypeofArgumentUserType = "UsesInTypeofArgument"; + public const string BoxedEnumArgumentUserType = "UsesInBoxedArgument"; + public const string EnumArgumentUserType = "UsesInEnumArgument"; + + /// A fresh, empty temp directory. + public static string NewDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyDependencyFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + return dir; + } + + /// A unique simple assembly name with the given prefix. + public static string UniqueName(string prefix) => prefix + Guid.NewGuid().ToString("N").Substring(0, 8); + + /// Emits the library <directory>/<name>.dll and returns its path. + public static string EmitLibrary(string directory, string name, Version? version = null) + { + var assemblyName = new AssemblyName(name) { Version = 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 model = module.DefineType($"{name}.Model", TypeAttributes.Public | TypeAttributes.Class); + model.DefineDefaultConstructor(MethodAttributes.Public); + model.CreateType(); + + var attribute = module.DefineType($"{name}.LibAttribute", TypeAttributes.Public | TypeAttributes.Class, typeof(Attribute)); + attribute.DefineDefaultConstructor(MethodAttributes.Public); + attribute.CreateType(); + + var mode = module.DefineEnum($"{name}.Mode", TypeAttributes.Public, typeof(int)); + mode.DefineLiteral("A", 0); + mode.DefineLiteral("B", 1); + mode.CreateType(); + + var path = Path.Combine(directory, name + ".dll"); + ab.Save(path); + return path; + } + + /// + /// Emits the consumer <directory>/<name>.dll compiled against the library at + /// and returns its path. The library is read into a collectible + /// load context from memory, so its file is neither locked nor kept loaded. + /// + 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 modelType = library.GetType($"{libraryName}.Model", throwOnError: true)!; + var attributeType = library.GetType($"{libraryName}.LibAttribute", throwOnError: true)!; + var modeType = library.GetType($"{libraryName}.Mode", 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 signatureUser = module.DefineType($"{name}.{SignatureUserType}", TypeAttributes.Public | TypeAttributes.Class); + var echo = signatureUser.DefineMethod(SignatureUserMethod, MethodAttributes.Public | MethodAttributes.Static, modelType, [modelType]); + il = echo.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ret); + signatureUser.CreateType(); + + var derived = module.DefineType($"{name}.{DerivedType}", TypeAttributes.Public | TypeAttributes.Class, modelType); + derived.CreateType(); + + var independent = module.DefineType($"{name}.{IndependentType}", TypeAttributes.Public | TypeAttributes.Class); + var twice = independent.DefineMethod(IndependentMethod, MethodAttributes.Public | MethodAttributes.Static, typeof(int), [typeof(int)]); + il = twice.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Add); + il.Emit(OpCodes.Ret); + independent.CreateType(); + + var parameterUser = module.DefineType($"{name}.{ParameterAttributeUserType}", TypeAttributes.Public | TypeAttributes.Class); + var take = parameterUser.DefineMethod(ParameterAttributeUserMethod, MethodAttributes.Public | MethodAttributes.Static, typeof(void), [typeof(int)]); + take.DefineParameter(1, ParameterAttributes.None, "x") + .SetCustomAttribute(new CustomAttributeBuilder(attributeType.GetConstructor(Type.EmptyTypes)!, [])); + take.GetILGenerator().Emit(OpCodes.Ret); + parameterUser.CreateType(); + + // A local attribute whose constructors take a System.Type, an object or a library enum. + var local = module.DefineType($"{name}.LocalAttribute", TypeAttributes.Public | TypeAttributes.Class, typeof(Attribute)); + var baseCtor = typeof(Attribute).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, Type.EmptyTypes)!; + ConstructorBuilder DefineLocalCtor(Type parameterType) + { + var ctor = local.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, [parameterType]); + var ctorIL = ctor.GetILGenerator(); + ctorIL.Emit(OpCodes.Ldarg_0); + ctorIL.Emit(OpCodes.Call, baseCtor); + ctorIL.Emit(OpCodes.Ret); + return ctor; + } + var typeCtor = DefineLocalCtor(typeof(Type)); + var objectCtor = DefineLocalCtor(typeof(object)); + var enumCtor = DefineLocalCtor(modeType); + local.CreateType(); + + void DefineAttributedType(string typeName, ConstructorInfo ctor, object argument) + { + var type = module.DefineType($"{name}.{typeName}", TypeAttributes.Public | TypeAttributes.Class); + type.SetCustomAttribute(new CustomAttributeBuilder(ctor, [argument])); + type.CreateType(); + } + DefineAttributedType(TypeofArgumentUserType, typeCtor, modelType); + DefineAttributedType(BoxedEnumArgumentUserType, objectCtor, Enum.ToObject(modeType, 1)); + DefineAttributedType(EnumArgumentUserType, enumCtor, Enum.ToObject(modeType, 1)); + + var path = Path.Combine(directory, name + ".dll"); + ab.Save(path); + return path; + } + finally + { + context.Unload(); + } + } + + /// + /// Emits a library and a consumer next to each other in a fresh directory, so the consumer's + /// reference resolves from its own folder. + /// + 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); + } + + /// + /// Emits a consumer whose library reference cannot be resolved: the library is built in a + /// separate directory that is deleted afterwards. + /// + 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); + } +} diff --git a/ILSpy.Tests/Dependencies/DependencyUiTests.cs b/ILSpy.Tests/Dependencies/DependencyUiTests.cs new file mode 100644 index 00000000000..0ec967b807a --- /dev/null +++ b/ILSpy.Tests/Dependencies/DependencyUiTests.cs @@ -0,0 +1,158 @@ +// 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 Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ICSharpCode.ILSpy.Analyzers; +using ICSharpCode.ILSpy.Analyzers.TreeNodes; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Dependencies; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +[TestFixture] +public class DependencyUiTests +{ + static async Task<(MainWindowViewModel Vm, string LibraryName, string ConsumerName)> OpenPairAsync(Avalonia.Controls.Window? _ = null) + { + var (_, vm) = await TestHarness.BootAsync(); + var (libraryPath, consumerPath, libraryName, consumerName) = DependencyFixtures.EmitPair("Ui"); + await vm.OpenAssemblyAsync(libraryPath); + await vm.OpenAssemblyAsync(consumerPath); + return (vm, libraryName, consumerName); + } + + static TextViewContext Select(SharpTreeNode node) => new() { SelectedTreeNodes = [node] }; + + [AvaloniaTest] + public async Task Assembly_Node_Has_A_Referenced_By_Folder_Listing_Its_Consumers() + { + var (vm, libraryName, consumerName) = await OpenPairAsync(); + var library = vm.AssemblyTreeModel.FindNode(libraryName); + + var folder = library.GetChild(); + folder.Text.Should().Be(Resources.ReferencedBy); + var child = folder.GetChild(); + child.Assembly.ShortName.Should().Be(consumerName); + child.IsVersionMismatch.Should().BeFalse(); + ((string)child.Text).Should().StartWith(consumerName); + + // The consumer itself has no consumers: its folder is empty. + var consumer = vm.AssemblyTreeModel.FindNode(consumerName); + var consumerFolder = consumer.GetChild(); + consumerFolder.EnsureLazyChildren(); + consumerFolder.Children.Should().BeEmpty(); + } + + [AvaloniaTest] + public async Task Analyze_Accepts_An_Assembly_And_Shows_The_Module_Analyzers() + { + var (vm, libraryName, _) = await OpenPairAsync(); + var library = vm.AssemblyTreeModel.FindNode(libraryName); + var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.Analyze)); + + entry.IsVisible(Select(library)).Should().BeTrue(); + entry.IsEnabled(Select(library)).Should().BeTrue(); + entry.Execute(Select(library)); + + var analyzerVm = AppComposition.Current.GetExport(); + var moduleRow = analyzerVm.Root.Children.OfType().Single(); + moduleRow.Module.MetadataFile.Should().BeSameAs(library.LoadedAssembly.GetMetadataFileOrNull()); + moduleRow.EnsureLazyChildren(); + moduleRow.Children.OfType().Select(r => r.AnalyzerHeader) + .Should().Contain([ReferencedByContextMenuEntry.AnalyzerHeader, DependentCodeContextMenuEntry.AnalyzerHeader]); + + entry.Execute(Select(library)); + analyzerVm.Root.Children.OfType().Should().ContainSingle("re-analyzing reuses the row"); + } + + [AvaloniaTest] + public async Task Referenced_By_Context_Entry_Lists_The_Consumer_In_The_Analyzer_Pane() + { + var (vm, libraryName, consumerName) = await OpenPairAsync(); + var library = vm.AssemblyTreeModel.FindNode(libraryName); + var registry = AppComposition.Current.GetExport(); + registry.Entries.Single(e => e.Metadata.Header == nameof(Resources.ReferencedBy)).Metadata.Category + .Should().Be("Navigation"); + var entry = registry.GetEntry(nameof(Resources.ReferencedBy)); + + entry.IsVisible(Select(library)).Should().BeTrue(); + entry.Execute(Select(library)); + + var analyzerVm = AppComposition.Current.GetExport(); + var row = (AnalyzerSearchTreeNode)analyzerVm.SelectedItems.Single(); + row.AnalyzerHeader.Should().Be(ReferencedByContextMenuEntry.AnalyzerHeader); + await Waiters.WaitForAsync(() => !row.IsLoading && row.Children.OfType().Any(), + description: "Referenced By results"); + row.Children.OfType().Single().Module.AssemblyName.Should().Be(consumerName); + } + + [AvaloniaTest] + public async Task Module_Entries_Are_Disabled_For_An_Unresolved_Assembly_Reference() + { + var (_, vm) = await TestHarness.BootAsync(); + var (consumerPath, missingName) = DependencyFixtures.EmitConsumerWithMissingReference("Ui"); + var consumer = await vm.OpenAssemblyAsync(consumerPath); + var consumerNode = vm.AssemblyTreeModel.FindNode(consumer.ShortName); + consumerNode.EnsureLazyChildren(); + var folder = consumerNode.Children.OfType().Single(); + folder.EnsureLazyChildren(); + var reference = folder.Children.OfType() + .Single(r => r.AssemblyReference.Name == missingName); + var registry = AppComposition.Current.GetExport(); + + foreach (var header in new[] { nameof(Resources.ReferencedBy), nameof(Resources.DependentCode) }) + { + var entry = registry.GetEntry(header); + entry.IsVisible(Select(reference)).Should().BeTrue(); + entry.IsEnabled(Select(reference)).Should().BeFalse($"{header} has no module to analyze"); + } + } + + [AvaloniaTest] + public async Task Dependent_Code_Context_Entry_Lists_The_Using_Members() + { + var (vm, libraryName, consumerName) = await OpenPairAsync(); + var library = vm.AssemblyTreeModel.FindNode(libraryName); + var entry = AppComposition.Current.GetExport().GetEntry(nameof(Resources.DependentCode)); + + entry.IsVisible(Select(library)).Should().BeTrue(); + entry.Execute(Select(library)); + + var analyzerVm = AppComposition.Current.GetExport(); + var row = (AnalyzerSearchTreeNode)analyzerVm.SelectedItems.Single(); + row.AnalyzerHeader.Should().Be(DependentCodeContextMenuEntry.AnalyzerHeader); + await Waiters.WaitForAsync(() => !row.IsLoading && row.Children.OfType().Any(), + description: "Dependent Code results"); + var members = row.Children.OfType().Select(n => n.Member?.FullName).ToList(); + members.Should().Contain($"{consumerName}.{DependencyFixtures.BodyUserType}.{DependencyFixtures.BodyUserMethod}"); + members.Should().NotContain($"{consumerName}.{DependencyFixtures.IndependentType}.{DependencyFixtures.IndependentMethod}"); + } +} diff --git a/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs b/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs new file mode 100644 index 00000000000..3b6df721876 --- /dev/null +++ b/ILSpy.Tests/Dependencies/ModuleAnalyzersTests.cs @@ -0,0 +1,128 @@ +// 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; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Analyzers; +using ICSharpCode.ILSpyX.Analyzers.Builtin; + +using ICSharpCode.ILSpy.Languages; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Dependencies; + +[TestFixture] +public class ModuleAnalyzersTests +{ + AssemblyList list = null!; + LoadedAssembly library = null!; + LoadedAssembly consumer = null!; + string consumerName = null!; + + [OneTimeSetUp] + public async Task Setup() + { + var (libraryPath, consumerPath, _, name) = DependencyFixtures.EmitPair("Analyze"); + consumerName = name; + list = new AssemblyList(); + library = list.OpenAssembly(libraryPath); + consumer = list.OpenAssembly(consumerPath); + await library.GetLoadResultAsync(); + await consumer.GetLoadResultAsync(); + // An assembly that has nothing to do with the library must not contribute results. + await list.OpenAssembly(FixtureAssembly.Emit(DependencyFixtures.UniqueName("Bystander"))).GetLoadResultAsync(); + } + + AnalyzerContext CreateContext(CancellationToken ct = default) + => new() { AssemblyList = list, Language = new CSharpLanguage(), CancellationToken = ct }; + + IModule LibraryModule() + => new DecompilerTypeSystem(library.GetMetadataFileOrNull()!, library.GetAssemblyResolver()).MainModule; + + [Test] + public void Module_Analyzers_Show_Only_For_Assemblies() + { + var module = LibraryModule(); + var type = module.TopLevelTypeDefinitions.First(t => t.Name == "Api"); + + new ModuleDependentCodeAnalyzer().Show(module).Should().BeTrue(); + new ModuleDependentCodeAnalyzer().Show(type).Should().BeFalse(); + new ModuleDependentCodeAnalyzer().Show(null).Should().BeFalse(); + new ModuleReferencedByAnalyzer().Show(module).Should().BeTrue(); + new ModuleReferencedByAnalyzer().Show(type).Should().BeFalse(); + } + + [Test] + public void Dependent_Code_Lists_Body_Signature_And_Base_Type_Users_Only() + { + var results = new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext()).ToList(); + + var names = results.OfType().Select(e => e.FullName).ToList(); + names.Should().Contain($"{consumerName}.{DependencyFixtures.BodyUserType}.{DependencyFixtures.BodyUserMethod}"); + names.Should().Contain($"{consumerName}.{DependencyFixtures.SignatureUserType}.{DependencyFixtures.SignatureUserMethod}"); + names.Should().Contain($"{consumerName}.{DependencyFixtures.DerivedType}"); + names.Should().NotContain(n => n.Contains(DependencyFixtures.IndependentType, StringComparison.Ordinal)); + results.OfType().Should().OnlyContain(e => e.ParentModule!.MetadataFile == consumer.GetMetadataFileOrNull(), + "only assemblies that reference the library contribute"); + names.Should().OnlyHaveUniqueItems(); + } + + [Test] + public void Dependent_Code_Lists_Attribute_Users_Through_Parameters_And_Arguments() + { + var names = new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext()) + .OfType().Select(e => e.FullName).ToList(); + + names.Should().Contain($"{consumerName}.{DependencyFixtures.ParameterAttributeUserType}.{DependencyFixtures.ParameterAttributeUserMethod}", + "a library attribute on a parameter is reported as its method"); + names.Should().Contain($"{consumerName}.{DependencyFixtures.TypeofArgumentUserType}", + "typeof(library type) in an attribute argument is a dependency"); + names.Should().Contain($"{consumerName}.{DependencyFixtures.BoxedEnumArgumentUserType}", + "a boxed library enum in an attribute argument is a dependency"); + names.Should().Contain($"{consumerName}.{DependencyFixtures.EnumArgumentUserType}", + "a library enum in an attribute argument is a dependency"); + } + + [Test] + public void Dependent_Code_Honours_Cancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => new ModuleDependentCodeAnalyzer().Analyze(LibraryModule(), CreateContext(cts.Token)).ToList(); + + act.Should().Throw(); + } + + [Test] + public void Referenced_By_Lists_The_Consumer_Module() + { + var results = new ModuleReferencedByAnalyzer().Analyze(LibraryModule(), CreateContext()).ToList(); + + results.Should().ContainSingle().Which.Should().BeAssignableTo() + .Which.MetadataFile.Should().BeSameAs(consumer.GetMetadataFileOrNull()); + } +} diff --git a/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs b/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs new file mode 100644 index 00000000000..6e6353782c6 --- /dev/null +++ b/ILSpy.Tests/Dependencies/ReferencedByFinderTests.cs @@ -0,0 +1,101 @@ +// 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.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 ReferencedByFinderTests +{ + static async Task OpenAsync(AssemblyList list, string path) + { + var loaded = list.OpenAssembly(path); + await loaded.GetLoadResultAsync(); + return loaded; + } + + [Test] + public async Task Finds_The_Consumer_Of_A_Library_And_Nothing_Else() + { + var (libraryPath, consumerPath, _, _) = DependencyFixtures.EmitPair("RefBy"); + var other = FixtureAssembly.Emit(DependencyFixtures.UniqueName("Unrelated")); + var list = new AssemblyList(); + var library = await OpenAsync(list, libraryPath); + var consumer = await OpenAsync(list, consumerPath); + await OpenAsync(list, other); + + var result = ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), library.GetMetadataFileOrNull()!); + + result.Should().ContainSingle(); + result[0].Assembly.Should().BeSameAs(consumer); + result[0].Reference.Name.Should().Be(library.ShortName); + result[0].IsVersionMismatch.Should().BeFalse(); + } + + [Test] + public async Task A_Different_Referenced_Version_Still_Matches_And_Is_Reported() + { + var dir = DependencyFixtures.NewDirectory(); + var libraryName = DependencyFixtures.UniqueName("RefByLib"); + var libraryPath = DependencyFixtures.EmitLibrary(dir, libraryName, new Version(1, 0, 0, 0)); + var consumerPath = DependencyFixtures.EmitConsumer(dir, DependencyFixtures.UniqueName("RefByApp"), libraryPath); + DependencyFixtures.EmitLibrary(dir, libraryName, new Version(3, 1, 0, 0)); + var list = new AssemblyList(); + var library = await OpenAsync(list, libraryPath); + await OpenAsync(list, consumerPath); + + var result = ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), library.GetMetadataFileOrNull()!); + + result.Should().ContainSingle().Which.IsVersionMismatch.Should().BeTrue(); + } + + [Test] + public void Matching_Requires_The_Same_Culture_And_Public_Key_Token() + { + // System.Runtime is strong-named; a reference with the right name but no token is a different assembly. + var runtimePath = Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll"); + using var runtime = new PEFile(runtimePath); + string token = runtime.Metadata.GetPublicKeyToken(); + + token.Should().NotBe("null"); + AssemblyNameReference.Parse($"System.Runtime, Version=1.0.0.0, Culture=neutral, PublicKeyToken={token}") + .IsReferenceTo(runtime.Metadata) + .Should().BeTrue("name and token match; the version is not part of the identity"); + AssemblyNameReference.Parse("System.Runtime, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null") + .IsReferenceTo(runtime.Metadata) + .Should().BeFalse(); + AssemblyNameReference.Parse($"System.Runtime2, Version=1.0.0.0, Culture=neutral, PublicKeyToken={token}") + .IsReferenceTo(runtime.Metadata) + .Should().BeFalse(); + AssemblyNameReference.Parse($"System.Runtime, Version=1.0.0.0, Culture=de-DE, PublicKeyToken={token}") + .IsReferenceTo(runtime.Metadata) + .Should().BeFalse("the culture is part of the assembly identity"); + } +} diff --git a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs index 49861418d40..c01e31e3065 100644 --- a/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs +++ b/ILSpy/Analyzers/AnalyzeContextMenuEntry.cs @@ -69,8 +69,9 @@ public static bool IsVisibleForContext(TextViewContext context) if (context.SelectedTreeNodes is { Length: > 0 } nodes) { // Top-level analyzer rows are already analysed (Remove is the entry for those); - // result rows underneath promote their entity to a new top-level row. - return nodes.All(n => n is IMemberTreeNode + // result rows underneath promote their entity to a new top-level row. Assembly + // nodes analyse the whole module. + return nodes.All(n => (n is IMemberTreeNode || n is AssemblyTreeNode) && n is not AnalyzerEntityTreeNode { Parent.IsRoot: true }); } // Right-clicking a resolved symbol in the decompiled code: the reference carries the entity. @@ -80,7 +81,7 @@ public static bool IsVisibleForContext(TextViewContext context) public static bool IsEnabledForContext(TextViewContext context) { if (context.SelectedTreeNodes is { Length: > 0 } nodes) - return nodes.OfType().All(n => IsAnalysable(n.Member)); + return nodes.All(n => SymbolOf(n) is { } symbol && IsAnalysable(symbol)); return context.Reference?.Reference is IEntity entity && IsAnalysable(entity); } @@ -100,28 +101,44 @@ public static bool Analyze(TextViewContext context, AnalyzerTreeViewModel analyz return false; } - // The analysable entities for this invocation: a tree-node selection (assembly/analyzer tree), + // The analysable symbols for this invocation: a tree-node selection (assembly/analyzer tree), // or the single resolved entity under a right-clicked code reference (decompiler text view). - static System.Collections.Generic.List MembersToAnalyse(TextViewContext context) + static System.Collections.Generic.List MembersToAnalyse(TextViewContext context) { if (context.SelectedTreeNodes is { Length: > 0 } nodes) { - return nodes.OfType() - .Select(n => n.Member) + return nodes.Select(SymbolOf) .Where(IsAnalysable) .Select(m => m!) .ToList(); } if (context.Reference?.Reference is IEntity entity && IsAnalysable(entity)) - return new System.Collections.Generic.List { entity }; - return new System.Collections.Generic.List(); + return new System.Collections.Generic.List { entity }; + return new System.Collections.Generic.List(); } + /// + /// The symbol a selected node stands for: the member of a member row, the module of an + /// assembly node or of a module row in the analyzer pane. + /// + internal static ISymbol? SymbolOf(ICSharpCode.ILSpyX.TreeView.SharpTreeNode node) => node switch { + ICSharpCode.ILSpy.Analyzers.TreeNodes.AnalyzedModuleTreeNode moduleNode => moduleNode.Module, + IMemberTreeNode memberNode => memberNode.Member, + AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true } assemblyNode + => assemblyNode.LoadedAssembly.GetTypeSystemOrNull()?.MainModule, + _ => null, + }; + /// /// Const fields are textual literals at every use-site rather than entities the - /// analyser can match against — exclude them so the entry stays disabled. + /// analyser can match against, so they are excluded and the entry stays disabled. Modules + /// are analysable when they carry metadata. /// - static bool IsAnalysable(IEntity? entity) => entity is not null and not IField { IsConst: true }; + static bool IsAnalysable(ISymbol? symbol) => symbol switch { + IModule module => module.MetadataFile != null, + IEntity entity => entity is not IField { IsConst: true }, + _ => false, + }; } [Export] diff --git a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs index cc796902bcc..05745b8f98a 100644 --- a/ILSpy/Analyzers/AnalyzerTreeViewModel.cs +++ b/ILSpy/Analyzers/AnalyzerTreeViewModel.cs @@ -84,6 +84,41 @@ public AnalyzerEntityTreeNode Analyze(IEntity entity) return node; } + /// + /// Adds a whole module (assembly) to the analyzer pane, reusing the existing row for the same + /// loaded file, and selects it. Its children are the analyzers that apply to modules + /// ("Referenced By", "Dependent Code", ...). + /// + public AnalyzerEntityTreeNode Analyze(IModule module) + { + ArgumentNullException.ThrowIfNull(module); + var existing = Root.Children + .OfType() + .FirstOrDefault(n => n.Module.MetadataFile != null + && ReferenceEquals(n.Module.MetadataFile, module.MetadataFile)); + if (existing != null) + { + SyncSelection(existing); + return existing; + } + var node = new AnalyzedModuleTreeNode(module, source: null); + Root.Children.Add(node); + node.IsExpanded = true; + SyncSelection(node); + return node; + } + + /// Dispatches to or . + public AnalyzerEntityTreeNode Analyze(ISymbol symbol) + { + return symbol switch { + IEntity entity => Analyze(entity), + IModule module => Analyze(module), + _ => throw new ArgumentOutOfRangeException(nameof(symbol), + $"Symbol {symbol?.GetType().FullName} is not supported by the analyzer pane.") + }; + } + static bool IsSameEntity(IEntity? a, IEntity b) { // Entities reaching the pane come from different type systems (the assembly tree's, diff --git a/ILSpy/Dependencies/DependencyCommands.cs b/ILSpy/Dependencies/DependencyCommands.cs new file mode 100644 index 00000000000..6fc4a50fbf5 --- /dev/null +++ b/ILSpy/Dependencies/DependencyCommands.cs @@ -0,0 +1,130 @@ +// 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.Composition; +using System.Linq; + +using ICSharpCode.Decompiler.TypeSystem; + +using ICSharpCode.ILSpy.Analyzers; +using ICSharpCode.ILSpy.Docking; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TreeNodes; + +namespace ICSharpCode.ILSpy.Dependencies +{ + /// Shared plumbing of the dependency context-menu entries. + static class DependencyMenuHelpers + { + /// Context-menu category of the dependency entries. + public const string NavigationCategory = "Navigation"; + + /// + /// The module a single selected node stands for: an assembly node's own module, or the + /// assembly an assembly-reference node resolves to. Null when there is none. + /// + public static IModule? GetModule(TextViewContext context) + { + if (context.SelectedTreeNodes is not [var node]) + return null; + switch (node) + { + case AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true } assemblyNode: + return assemblyNode.LoadedAssembly.GetTypeSystemOrNull()?.MainModule; + case AssemblyReferenceTreeNode referenceNode: + var owner = referenceNode.AncestorsAndSelf().OfType().FirstOrDefault(); + if (owner == null) + return null; + var resolved = owner.LoadedAssembly.GetAssemblyResolver().Resolve(referenceNode.AssemblyReference); + if (resolved == null) + return null; + var loaded = owner.LoadedAssembly.AssemblyList.FindAssembly(resolved.FileName); + return loaded?.GetTypeSystemOrNull()?.MainModule; + default: + return null; + } + } + + public static bool IsModuleSelection(TextViewContext context) + => context.SelectedTreeNodes is [AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }] + or [AssemblyReferenceTreeNode]; + + /// + /// Adds to the analyzer pane, expands the analyzer row with the + /// given header, selects it and brings the pane to the front. + /// + public static AnalyzerSearchTreeNode? AnalyzeWith(IModule module, string analyzerHeader, + AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) + { + var moduleNode = analyzerTreeViewModel.Analyze(module); + moduleNode.EnsureLazyChildren(); + var row = moduleNode.Children.OfType() + .FirstOrDefault(r => r.AnalyzerHeader == analyzerHeader); + if (row != null) + { + row.IsExpanded = true; + analyzerTreeViewModel.SelectedItems.Clear(); + analyzerTreeViewModel.SelectedItems.Add(row); + } + dockWorkspace.ShowToolPane(AnalyzerTreeViewModel.PaneContentId); + return row; + } + } + + /// Right-click an assembly (or an assembly reference) -> "Referenced By" in the analyzer pane. + [ExportContextMenuEntry(Header = nameof(Resources.ReferencedBy), Category = DependencyMenuHelpers.NavigationCategory, Order = 600)] + [Shared] + [method: ImportingConstructor] + public sealed class ReferencedByContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) : IContextMenuEntry + { + /// Header of the module analyzer this entry opens. + public const string AnalyzerHeader = "Referenced By"; + + public bool IsVisible(TextViewContext context) => DependencyMenuHelpers.IsModuleSelection(context); + + // An assembly reference that does not resolve to a loaded assembly has nothing to analyze. + public bool IsEnabled(TextViewContext context) => DependencyMenuHelpers.GetModule(context) != null; + + public void Execute(TextViewContext context) + { + if (DependencyMenuHelpers.GetModule(context) is { } module) + DependencyMenuHelpers.AnalyzeWith(module, AnalyzerHeader, analyzerTreeViewModel, dockWorkspace); + } + } + + /// Right-click an assembly (or an assembly reference) -> "Dependent Code" in the analyzer pane. + [ExportContextMenuEntry(Header = nameof(Resources.DependentCode), Category = DependencyMenuHelpers.NavigationCategory, Order = 610)] + [Shared] + [method: ImportingConstructor] + public sealed class DependentCodeContextMenuEntry(AnalyzerTreeViewModel analyzerTreeViewModel, DockWorkspace dockWorkspace) : IContextMenuEntry + { + /// Header of the module analyzer this entry opens. + public const string AnalyzerHeader = "Dependent Code"; + + public bool IsVisible(TextViewContext context) => DependencyMenuHelpers.IsModuleSelection(context); + + // An assembly reference that does not resolve to a loaded assembly has nothing to analyze. + public bool IsEnabled(TextViewContext context) => DependencyMenuHelpers.GetModule(context) != null; + + public void Execute(TextViewContext context) + { + if (DependencyMenuHelpers.GetModule(context) is { } module) + DependencyMenuHelpers.AnalyzeWith(module, AnalyzerHeader, analyzerTreeViewModel, dockWorkspace); + } + } +} diff --git a/ILSpy/Dependencies/ReferencedByTreeNodes.cs b/ILSpy/Dependencies/ReferencedByTreeNodes.cs new file mode 100644 index 00000000000..1320a815edd --- /dev/null +++ b/ILSpy/Dependencies/ReferencedByTreeNodes.cs @@ -0,0 +1,173 @@ +// 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 ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Dependencies; +using ICSharpCode.ILSpyX.TreeView.PlatformAbstractions; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TreeNodes; + +namespace ICSharpCode.ILSpy.Dependencies +{ + /// + /// "Referenced By" folder under an assembly node: the reverse of the References folder. Lists + /// the assemblies of the current list that reference this assembly (matched by name and public + /// key token), each of which expands to its own referencing assemblies. + /// + public sealed class ReferencedByFolderTreeNode : ILSpyTreeNode + { + readonly MetadataFile module; + readonly AssemblyTreeNode parentAssembly; + + public ReferencedByFolderTreeNode(MetadataFile module, AssemblyTreeNode parentAssembly) + { + this.module = module ?? throw new ArgumentNullException(nameof(module)); + this.parentAssembly = parentAssembly ?? throw new ArgumentNullException(nameof(parentAssembly)); + LazyLoading = true; + } + + public MetadataFile Module => module; + + public override object Text => Resources.ReferencedBy; + + public override object? NavigationText => $"{Text} ({module.Name})"; + + public override object Icon => Images.ReferenceFolder; + + protected override void LoadChildren() + { + foreach (var child in ReferencingAssemblyTreeNode.CreateChildren(parentAssembly.LoadedAssembly.AssemblyList, module, this)) + Children.Add(child); + } + + public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) + { + EnsureLazyChildren(); + output.WriteLine($"Assemblies in the list that reference {module.FullName}:"); + output.WriteLine(); + if (Children.Count == 0) + output.WriteLine("(none)"); + foreach (var node in Children.OfType()) + node.WriteSummary(output); + } + } + + /// + /// One referencing assembly under a (or, recursively, + /// under another referencing assembly). Activating it selects that assembly in the tree. + /// + public sealed class ReferencingAssemblyTreeNode : ILSpyTreeNode + { + readonly ReferencingAssembly referencing; + + ReferencingAssemblyTreeNode(ReferencingAssembly referencing) + { + this.referencing = referencing; + LazyLoading = true; + } + + public LoadedAssembly Assembly => referencing.Assembly; + + public AssemblyReference Reference => referencing.Reference; + + public bool IsVersionMismatch => referencing.IsVersionMismatch; + + public override object Text { + get { + var name = ILAmbience.EscapeName(referencing.Assembly.ShortName); + var metadata = referencing.Module.Metadata; + var version = metadata.IsAssembly ? metadata.GetAssemblyDefinition().Version : null; + var text = version != null ? $"{name} ({version})" : name; + if (referencing.IsVersionMismatch) + text += $" - references v{referencing.Reference.Version}"; + return text; + } + } + + public override object? NavigationText => $"{Text} ({Resources.ReferencedBy})"; + + public override object Icon => referencing.IsVersionMismatch ? Images.AssemblyWarning : Images.Assembly; + + public override object? ToolTip => referencing.Assembly.FileName; + + /// + /// One node per assembly of that references , + /// skipping assemblies already on the path from up to the folder so + /// that reference cycles do not recurse forever. + /// + internal static System.Collections.Generic.IEnumerable CreateChildren( + AssemblyList list, MetadataFile target, ILSpyTreeNode parent) + { + var onPath = parent.AncestorsAndSelf() + .Select(n => n switch { + ReferencingAssemblyTreeNode r => r.referencing.Module, + ReferencedByFolderTreeNode f => f.Module, + _ => null, + }) + .Where(m => m != null) + .ToHashSet(); + foreach (var referencing in ReferencedByFinder.FindReferencingAssemblies(list.GetAssemblies(), target)) + { + if (!onPath.Contains(referencing.Module)) + yield return new ReferencingAssemblyTreeNode(referencing); + } + } + + protected override void LoadChildren() + { + foreach (var child in CreateChildren(referencing.Assembly.AssemblyList, referencing.Module, this)) + Children.Add(child); + } + + public override void ActivateItem(IPlatformRoutedEventArgs e) + { + var model = AppComposition.TryGetExport(); + if (model?.Root is not AssemblyListTreeNode listNode) + return; + var node = listNode.FindAssemblyNode(referencing.Assembly); + if (node == null) + return; + model.SelectedItem = node; + e.Handled = true; + } + + internal void WriteSummary(ITextOutput output) + { + output.WriteLine($"{Text}"); + output.Indent(); + output.WriteLine("File: " + referencing.Assembly.FileName); + output.WriteLine("Reference: " + referencing.Reference.FullName); + output.Unindent(); + } + + public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) + { + WriteSummary(output); + } + } +} diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index a276ef2f82b..d6d2c027a41 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -1938,6 +1938,15 @@ public static string Dependencies { } } + /// + /// Looks up a localized string similar to Dependent Code. + /// + public static string DependentCode { + get { + return ResourceManager.GetString("DependentCode", resourceCulture); + } + } + /// /// Looks up a localized string similar to Derived Types. /// @@ -2915,6 +2924,15 @@ public static string R_ename { } } + /// + /// Looks up a localized string similar to Referenced By. + /// + public static string ReferencedBy { + get { + return ResourceManager.GetString("ReferencedBy", resourceCulture); + } + } + /// /// Looks up a localized string similar to Referenced Types. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 94e510454b3..cc0059520a9 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -675,6 +675,9 @@ Are you sure you want to continue? Dependencies + + Dependent Code + Derived Types @@ -1010,6 +1013,9 @@ If this does not solve the problem and your system supports long paths, you can Reference Name + + Referenced By + Referenced Types diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index f2e8bc57607..4fd383f7c34 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -456,6 +456,8 @@ protected override void LoadChildren() } Children.Add(new ReferenceFolderTreeNode(module, this)); + if (module.IsAssembly) + Children.Add(new ICSharpCode.ILSpy.Dependencies.ReferencedByFolderTreeNode(module, this)); if (module.Resources.Any()) Children.Add(new ResourceListTreeNode(module));