diff --git a/.gitignore b/.gitignore index e2cd5ecd2684..84a4e7bf3425 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,8 @@ obj/ *.binlog -sdk.sln.DotSettings.user +# User specific files +*.user # Debian and python stuff *.dsc diff --git a/CODEOWNERS b/CODEOWNERS index 767ae68495a7..584e649fd8ba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -53,3 +53,7 @@ /src/Tests/dotnet-watch.Tests/ @captainsafia, @pranavkm, @mkArtakMSFT /src/Tests/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/ @captainsafia, @pranavkm, @mkArtakMSFT /src/BuiltInTools/ @captainsafia, @pranavkm, @mkArtakMSFT + +# Compatibility tools owned by runtime team +/src/Compatibility/ @Anipik, @safern, @ericstj +/src/Tests/Microsoft.DotNet.ApiCompatibility/ @Anipik, @safern, @ericstj diff --git a/sdk.sln b/sdk.sln index 88ec33d89eba..d0a04273e520 100644 --- a/sdk.sln +++ b/sdk.sln @@ -341,6 +341,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.NativeWrap EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.NET.Sdk.Razor.SourceGenerators.Tests", "src\Tests\Microsoft.NET.Sdk.Razor.SourceGenerators.Tests\Microsoft.NET.Sdk.Razor.SourceGenerators.Tests.csproj", "{A71FC21D-D90A-49B5-9B5A-AD4776287B55}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compatibility", "Compatibility", "{AF683E5C-421E-4DE0-ADD7-9841E5D12BFA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.ApiCompatibility", "src\Compatibility\Microsoft.DotNet.ApiCompatibility\Microsoft.DotNet.ApiCompatibility.csproj", "{3F5A028C-C51B-434A-8C10-37680CD2635C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiCompatibility.Tests", "src\Tests\Microsoft.DotNet.ApiCompatibility.Tests\Microsoft.DotNet.ApiCompatibility.Tests.csproj", "{24F084ED-35BB-401E-89F5-63E5E22C3B3B}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Win32.Msi", "src\Microsoft.Win32.Msi\Microsoft.Win32.Msi.csproj", "{3D002392-6308-41DF-8BD5-224CCC5B049F}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Win32.Msi.Tests", "src\Tests\Microsoft.Win32.Msi.Tests\Microsoft.Win32.Msi.Tests.csproj", "{80932949-B8B2-4163-B325-76F8FDBE3897}" @@ -621,6 +626,14 @@ Global {A71FC21D-D90A-49B5-9B5A-AD4776287B55}.Debug|Any CPU.Build.0 = Debug|Any CPU {A71FC21D-D90A-49B5-9B5A-AD4776287B55}.Release|Any CPU.ActiveCfg = Release|Any CPU {A71FC21D-D90A-49B5-9B5A-AD4776287B55}.Release|Any CPU.Build.0 = Release|Any CPU + {3F5A028C-C51B-434A-8C10-37680CD2635C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F5A028C-C51B-434A-8C10-37680CD2635C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F5A028C-C51B-434A-8C10-37680CD2635C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F5A028C-C51B-434A-8C10-37680CD2635C}.Release|Any CPU.Build.0 = Release|Any CPU + {24F084ED-35BB-401E-89F5-63E5E22C3B3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24F084ED-35BB-401E-89F5-63E5E22C3B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24F084ED-35BB-401E-89F5-63E5E22C3B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24F084ED-35BB-401E-89F5-63E5E22C3B3B}.Release|Any CPU.Build.0 = Release|Any CPU {3D002392-6308-41DF-8BD5-224CCC5B049F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3D002392-6308-41DF-8BD5-224CCC5B049F}.Debug|Any CPU.Build.0 = Debug|Any CPU {3D002392-6308-41DF-8BD5-224CCC5B049F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -746,6 +759,9 @@ Global {1BBFA19C-03F0-4D27-9D0D-0F8172642107} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91} {E97E9E7F-11B4-42F7-8B55-D0451F5E82A0} = {8F22FBD6-BDC8-431E-8402-B7460D3A9724} {A71FC21D-D90A-49B5-9B5A-AD4776287B55} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} + {AF683E5C-421E-4DE0-ADD7-9841E5D12BFA} = {22AB674F-ED91-4FBC-BFEE-8A1E82F9F05E} + {3F5A028C-C51B-434A-8C10-37680CD2635C} = {AF683E5C-421E-4DE0-ADD7-9841E5D12BFA} + {24F084ED-35BB-401E-89F5-63E5E22C3B3B} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} {3D002392-6308-41DF-8BD5-224CCC5B049F} = {22AB674F-ED91-4FBC-BFEE-8A1E82F9F05E} {80932949-B8B2-4163-B325-76F8FDBE3897} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} {EEF4C7DD-CDC9-44B6-8B4F-725647D54ED8} = {580D1AE7-AA8F-4912-8B76-105594E00B3B} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/CompatDifference.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/CompatDifference.cs new file mode 100644 index 000000000000..c693dc74339f --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/CompatDifference.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Class representing a difference of compatibility, containing detailed information about it. + /// + public class CompatDifference : IDiagnostic, IEquatable + { + /// + /// The Diagnostic ID for this difference. + /// + public string DiagnosticId { get; } + + /// + /// The . + /// + public DifferenceType Type { get; } + + /// + /// A diagnostic message for the difference. + /// + public virtual string Message { get; } + + /// + /// A unique ID in order to identify the API that the difference was raised for. + /// + public string ReferenceId { get; } + + private CompatDifference() { } + + /// + /// Instantiate a new object representing the compatibility difference. + /// + /// representing the diagnostic ID. + /// message describing the difference. + /// to describe the type of the difference. + /// for which the difference is associated to. + public CompatDifference(string diagnosticId, string message, DifferenceType type, ISymbol member) + : this(diagnosticId, message, type, member?.GetDocumentationCommentId()) + { + } + + /// + /// Instantiate a new object representing the compatibility difference. + /// + /// representing the diagnostic ID. + /// message describing the difference. + /// to describe the type of the difference. + /// containing the member ID for which the difference is associated to. + public CompatDifference(string diagnosticId, string message, DifferenceType type, string memberId) + { + DiagnosticId = diagnosticId ?? throw new ArgumentNullException(nameof(diagnosticId)); + Message = message ?? throw new ArgumentNullException(nameof(message)); + Type = type; + ReferenceId = memberId ?? throw new ArgumentNullException(nameof(memberId)); + } + + /// + /// Evaluates whether the current object is equal to another . + /// + /// to compare against. + /// True if equals, False if different. + public bool Equals(CompatDifference other) => + other != null && + Type == other.Type && + DiagnosticId.Equals(other.DiagnosticId, StringComparison.OrdinalIgnoreCase) && + ReferenceId.Equals(other.ReferenceId, StringComparison.OrdinalIgnoreCase) && + Message.Equals(other.Message, StringComparison.OrdinalIgnoreCase); + + /// + /// Gets the hashcode that reperesents this instance. + /// + /// Unique based on the properties' values of the instance. + public override int GetHashCode() => + HashCode.Combine(ReferenceId, DiagnosticId, Message, Type); + + /// + /// Gets a representation of the difference. + /// + /// describing the difference. + public override string ToString() => $"{DiagnosticId} : {Message}"; + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/DifferenceType.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/DifferenceType.cs new file mode 100644 index 000000000000..30042793526f --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/DifferenceType.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Enum representing the different type of differences available. + /// + public enum DifferenceType + { + Changed, + Added, + Removed + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Filtering/ISymbolFilter.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Filtering/ISymbolFilter.cs new file mode 100644 index 000000000000..6b06a0d05ff9 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Filtering/ISymbolFilter.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Provides a mechanism to filter when building the . + /// + public interface ISymbolFilter + { + /// + /// Determines whether the should be included. + /// + /// to evaluate. + /// True to include the or false to filter it out. + bool Include(ISymbol symbol); + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Filtering/SymbolAccessibilityBasedFilter.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Filtering/SymbolAccessibilityBasedFilter.cs new file mode 100644 index 000000000000..983e49593038 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Filtering/SymbolAccessibilityBasedFilter.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + internal class SymbolAccessibilityBasedFilter : ISymbolFilter + { + private readonly bool _includeInternalSymbols; + + internal SymbolAccessibilityBasedFilter(bool includeInternalSymbols) + { + _includeInternalSymbols = includeInternalSymbols; + } + + public bool Include(ISymbol symbol) => + symbol.DeclaredAccessibility == Accessibility.Public || + symbol.DeclaredAccessibility == Accessibility.Protected || + symbol.DeclaredAccessibility == Accessibility.ProtectedOrInternal || + (_includeInternalSymbols && symbol.DeclaredAccessibility != Accessibility.Private); + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IDiagnostic.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IDiagnostic.cs new file mode 100644 index 000000000000..c8e3540f1c00 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IDiagnostic.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Interface that describes a diagnostic. + /// + public interface IDiagnostic + { + /// + /// String representing the diagnostic ID. + /// + string DiagnosticId { get; } + + /// + /// String representing the ID for the object that the diagnostic was created for. + /// + string ReferenceId { get; } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IRuleRunner.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IRuleRunner.cs new file mode 100644 index 000000000000..4203622d2829 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IRuleRunner.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information.s + +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Interface for rule drivers to implement in order to be used returned by the + /// + public interface IRuleRunner + { + /// + /// Runs the registered rules on the mapper. + /// + /// The underlying type on the mapper. + /// The mapper to run the rules on. + /// + IEnumerable Run(ElementMapper mapper); + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IRuleRunnerFactory.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IRuleRunnerFactory.cs new file mode 100644 index 000000000000..eca973e74494 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/IRuleRunnerFactory.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information.s + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// The factory to create the driver that the differ should use based on the . + /// + public interface IRuleRunnerFactory + { + IRuleRunner GetRuleRunner(); + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/MapperVisitor.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/MapperVisitor.cs new file mode 100644 index 000000000000..f6305f08aa6e --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/MapperVisitor.cs @@ -0,0 +1,121 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Class that implements a visitor pattern to visit the tree for a given mapper. + /// + public class MapperVisitor + { + /// + /// Visits the tree for the given . + /// + /// Underlying type for the objects that the mapper holds. + /// to visit. + public void Visit(ElementMapper mapper) + { + if (mapper is AssemblySetMapper assemblySetMapper) + { + Visit(assemblySetMapper); + } + else if (mapper is AssemblyMapper assemblyMapper) + { + Visit(assemblyMapper); + } + else if (mapper is NamespaceMapper nsMapper) + { + Visit(nsMapper); + } + else if (mapper is TypeMapper typeMapper) + { + Visit(typeMapper); + } + else if (mapper is MemberMapper memberMapper) + { + Visit(memberMapper); + } + } + + /// + /// Visits the and visits each in the mapper. + /// + /// The to visit. + public virtual void Visit(AssemblySetMapper mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + foreach (AssemblyMapper assembly in mapper.GetAssemblies()) + { + Visit(assembly); + } + } + + /// + /// Visits the and visits each in the mapper. + /// + /// The to visit. + public virtual void Visit(AssemblyMapper mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + foreach (NamespaceMapper nsMapper in mapper.GetNamespaces()) + { + Visit(nsMapper); + } + } + + /// + /// Visits the and visits each in the mapper. + /// + /// The to visit. + public virtual void Visit(NamespaceMapper mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + foreach (TypeMapper type in mapper.GetTypes()) + { + Visit(type); + } + } + + /// + /// Visits the and visits the nested types and members in the mapper. + /// + /// The to visit. + public virtual void Visit(TypeMapper mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + foreach (TypeMapper type in mapper.GetNestedTypes()) + { + Visit(type); + } + + foreach (MemberMapper member in mapper.GetMembers()) + { + Visit(member); + } + } + + /// + /// Visits the . + /// + /// The to visit. + public virtual void Visit(MemberMapper mapper) { } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/AssemblyMapper.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/AssemblyMapper.cs new file mode 100644 index 000000000000..c547c96902a0 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/AssemblyMapper.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Object that represents a mapping between two objects. + /// This also holds a list of to represent the mapping of namespaces in between + /// and . + /// + public class AssemblyMapper : ElementMapper + { + private Dictionary _namespaces; + + /// + /// Instantiates an object with the provided . + /// + /// The settings used to diff the elements in the mapper. + public AssemblyMapper(ComparingSettings settings) : base(settings) { } + + /// + /// Gets the mappers for the namespaces contained in and + /// + /// The list of . + public IEnumerable GetNamespaces() + { + if (_namespaces == null) + { + _namespaces = new Dictionary(Settings.EqualityComparer); + Dictionary> typeForwards; + if (Left != null) + { + typeForwards = ResolveTypeForwards(Left, Settings.EqualityComparer); + AddOrCreateMappers(Left.GlobalNamespace, 0); + } + + if (Right != null) + { + typeForwards = ResolveTypeForwards(Right, Settings.EqualityComparer); + AddOrCreateMappers(Right.GlobalNamespace, 1); + } + + void AddOrCreateMappers(INamespaceSymbol ns, int index) + { + Stack stack = new(); + stack.Push(ns); + while (stack.Count > 0) + { + INamespaceSymbol symbol = stack.Pop(); + if (typeForwards.TryGetValue(symbol, out List forwardedTypes) || symbol.GetTypeMembers().Length > 0) + { + if (!_namespaces.TryGetValue(symbol, out NamespaceMapper mapper)) + { + mapper = new NamespaceMapper(Settings); + _namespaces.Add(symbol, mapper); + } + + mapper.AddElement(symbol, index); + mapper.AddForwardedTypes(forwardedTypes ?? new List(), index); + } + + foreach (INamespaceSymbol child in symbol.GetNamespaceMembers()) + stack.Push(child); + } + } + + static Dictionary> ResolveTypeForwards(IAssemblySymbol assembly, IEqualityComparer comparer) + { + Dictionary> typeForwards = new(comparer); + foreach (INamedTypeSymbol symbol in assembly.GetForwardedTypes()) + { + if (symbol.TypeKind != TypeKind.Error) + { + if (!typeForwards.TryGetValue(symbol.ContainingNamespace, out List types)) + { + types = new List(); + typeForwards.Add(symbol.ContainingNamespace, types); + } + + types.Add(symbol); + } + else + { + // TODO: Log Warning; + } + } + + return typeForwards; + } + } + + return _namespaces.Values; + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/AssemblySetMapper.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/AssemblySetMapper.cs new file mode 100644 index 000000000000..e34a877506ab --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/AssemblySetMapper.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Object that represents a mapping between two lists of . + /// + public class AssemblySetMapper : ElementMapper> + { + private Dictionary _assemblies; + + /// + /// Instantiates an object with the provided . + /// + /// The settings used to diff the elements in the mapper. + public AssemblySetMapper(ComparingSettings settings) : base(settings) { } + + /// + /// Gets the assembly mappers built from the provided lists of . + /// The list of representing the underlying assemblies. + public IEnumerable GetAssemblies() + { + if (_assemblies == null) + { + _assemblies = new Dictionary(Settings.EqualityComparer); + + if (Left != null) + { + AddOrCreateMappers(Left, 0); + } + + if (Right != null) + { + AddOrCreateMappers(Right, 1); + } + + void AddOrCreateMappers(IEnumerable elements, int index) + { + foreach (IAssemblySymbol assembly in elements) + { + if (!_assemblies.TryGetValue(assembly, out AssemblyMapper mapper)) + { + mapper = new AssemblyMapper(Settings); + _assemblies.Add(assembly, mapper); + } + mapper.AddElement(assembly, index); + } + } + } + + return _assemblies.Values; + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/ElementMapper.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/ElementMapper.cs new file mode 100644 index 000000000000..d64251109ea8 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/ElementMapper.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Class that represents a mapping in between two objects of type . + /// + public class ElementMapper + { + private IEnumerable _differences; + + /// + /// Property representing the Left hand side of the mapping. + /// + public T Left { get; private set; } + + /// + /// Property representing the Right hand side of the mapping. + /// + public T Right { get; private set; } + + /// + /// The used to diff and . + /// + public ComparingSettings Settings { get; } + + /// + /// Instantiates an object with the provided . + /// + /// The settings used to diff the elements in the mapper. + public ElementMapper(ComparingSettings settings) + { + Settings = settings; + } + + /// + /// Adds an element to the mapping given the index, 0 (Left) or 1 (Right). + /// + /// The element to add to the mapping. + /// Index representing the side of the mapping, 0 (Left) or 1 (Right). + public virtual void AddElement(T element, int index) + { + if ((uint)index > 1) + throw new ArgumentOutOfRangeException(nameof(index), "index should either be 0 or 1"); + + if (index == 0) + { + Left = element; + } + else + { + Right = element; + } + } + + /// + /// Runs the rules found by the rule driver on the element mapper and returns a list of differences. + /// + /// The list of . + public IEnumerable GetDifferences() + { + return _differences ??= Settings.RuleRunnerFactory.GetRuleRunner().Run(this); + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/MemberMapper.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/MemberMapper.cs new file mode 100644 index 000000000000..92fc0f79fc46 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/MemberMapper.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Object that represents a mapping between two objects. + /// + public class MemberMapper : ElementMapper + { + /// + /// Instantiates an object with the provided . + /// + /// The settings used to diff the elements in the mapper. + public MemberMapper(ComparingSettings settings) : base(settings) { } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/NamespaceMapper.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/NamespaceMapper.cs new file mode 100644 index 000000000000..6ff25603e0be --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/NamespaceMapper.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Object that represents a mapping between two objects. + /// This also holds a list of to represent the mapping of types in between + /// and . + /// + public class NamespaceMapper : ElementMapper + { + private Dictionary _types; + private IEnumerable _leftForwardedTypes; + private IEnumerable _rightForwardedTypes; + + /// + /// Instantiates an object with the provided . + /// + /// The settings used to diff the elements in the mapper. + public NamespaceMapper(ComparingSettings settings) : base(settings) { } + + /// + /// Gets all the representing the types defined in the namespace including the typeforwards. + /// + /// The mapper representing the types in the namespace + public IEnumerable GetTypes() + { + if (_types == null) + { + _types = new Dictionary(Settings.EqualityComparer); + IEnumerable types; + + if (Left != null) + { + types = Left.GetTypeMembers().AddRange(_leftForwardedTypes); + AddOrCreateMappers(0); + } + + if (Right != null) + { + types = Right.GetTypeMembers().AddRange(_rightForwardedTypes); + AddOrCreateMappers(1); + } + + void AddOrCreateMappers(int index) + { + if (types == null) + return; + + foreach (var type in types) + { + if (Settings.Filter.Include(type)) + { + if (!_types.TryGetValue(type, out TypeMapper mapper)) + { + mapper = new TypeMapper(Settings); + _types.Add(type, mapper); + } + + mapper.AddElement(type, index); + } + } + } + } + + return _types.Values; + } + + /// + /// Adds forwarded types to the mapper to the index specified in the mapper. + /// + /// List containing the that represents the forwarded types. + /// Index to add the forwarded types into, 0 or 1. + public void AddForwardedTypes(IEnumerable forwardedTypes, int index) + { + if ((uint)index > 1) + throw new ArgumentOutOfRangeException(nameof(index), $"Value must be 0 or 1"); + + if (index == 0) + { + _leftForwardedTypes = forwardedTypes; + } + else + { + _rightForwardedTypes = forwardedTypes; + } + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/TypeMapper.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/TypeMapper.cs new file mode 100644 index 000000000000..634052ff029b --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Mappers/TypeMapper.cs @@ -0,0 +1,110 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Object that represents a mapping between two objects. + /// This also holds the nested types as a list of and the members defined within the type + /// as a list of + /// + public class TypeMapper : ElementMapper + { + private Dictionary _nestedTypes; + private Dictionary _members; + + /// + /// Instantiates an object with the provided . + /// + /// The settings used to diff the elements in the mapper. + public TypeMapper(ComparingSettings settings) : base(settings) { } + + /// + /// Indicates whether we have a complete mapper and if the members should be diffed. + /// + public bool ShouldDiffMembers => Left != null && Right != null; + + /// + /// Gets the nested types within the mapped types. + /// + /// The list of representing the nested types. + public IEnumerable GetNestedTypes() + { + if (_nestedTypes == null) + { + _nestedTypes = new Dictionary(Settings.EqualityComparer); + + if (Left != null) + { + AddOrCreateMappers(Left.GetTypeMembers(), 0); + } + + if (Right != null) + { + AddOrCreateMappers(Right.GetTypeMembers(), 1); + } + + void AddOrCreateMappers(IEnumerable symbols, int index) + { + foreach (var nestedType in symbols) + { + if (Settings.Filter.Include(nestedType)) + { + if (!_nestedTypes.TryGetValue(nestedType, out TypeMapper mapper)) + { + mapper = new TypeMapper(Settings); + _nestedTypes.Add(nestedType, mapper); + } + mapper.AddElement(nestedType, index); + } + } + } + } + + return _nestedTypes.Values; + } + + /// + /// Gets the members defined in this type. + /// + /// The list of representing the members. + public IEnumerable GetMembers() + { + if (_members == null) + { + _members = new Dictionary(Settings.EqualityComparer); + + if (Left != null) + { + AddOrCreateMappers(Left.GetMembers(), 0); + } + + if (Right != null) + { + AddOrCreateMappers(Right.GetMembers(), 1); + } + + void AddOrCreateMappers(IEnumerable symbols, int index) + { + foreach (var member in symbols) + { + if (Settings.Filter.Include(member) && member is not ITypeSymbol) + { + if (!_members.TryGetValue(member, out MemberMapper mapper)) + { + mapper = new MemberMapper(Settings); + _members.Add(member, mapper); + } + mapper.AddElement(member, index); + } + } + } + } + + return _members.Values; + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Rule.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Rule.cs new file mode 100644 index 000000000000..579ce07b12a4 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Abstractions/Rule.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Abstractions +{ + /// + /// Base class for Rules to use in order to be discovered and invoked by the + /// + public abstract class Rule + { + /// + /// Entrypoint to evaluate an + /// + /// The to evaluate. + /// The list of to add any differences to. + public virtual void Run(AssemblyMapper mapper, IList differences) + { + } + + /// + /// Entrypoint to evaluate an + /// + /// The to evaluate. + /// The list of to add any differences to. + public virtual void Run(TypeMapper mapper, IList differences) + { + } + + /// + /// Entrypoint to evaluate an + /// + /// The to evaluate. + /// The list of to add any differences to. + public virtual void Run(MemberMapper mapper, IList differences) + { + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/ApiComparer.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/ApiComparer.cs new file mode 100644 index 000000000000..88f20828d31a --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/ApiComparer.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility +{ + /// + /// Class that performs api comparison based on ISymbol inputs. + /// + public class ApiComparer + { + private readonly ComparingSettings _settings; + + /// + /// Instantiate an object with the default . + /// + public ApiComparer() : this(new ComparingSettings()) { } + + /// + /// Instantiate an object with the provided + /// + /// Settings to use for comparison. + public ApiComparer(ComparingSettings settings) + { + _settings = settings; + } + + /// + /// Instantiate an object that includes comparing internal symbols. + /// + /// Indicates whether internal symbols should be included or not. + public ApiComparer(bool includeInternalSymbols) + { + _settings = new ComparingSettings(filter: new SymbolAccessibilityBasedFilter(includeInternalSymbols)); + } + + /// + /// Comma separated list to ignore diagnostic IDs. + /// + public string NoWarn { get; set; } = string.Empty; + + /// + /// Array of diagnosticId and memberId to ignore those differences. + /// + public (string diagnosticId, string memberId)[] IgnoredDifferences { get; set; } + + /// + /// Get's the differences when comparing Left vs Right based on the settings when instanciating the object. + /// It compares two lists of symbols. + /// + /// Left symbols to compare against. + /// Right symbols to compare against. + /// List of found differences. + public IEnumerable GetDifferences(IEnumerable left, IEnumerable right) + { + AssemblySetMapper mapper = new(_settings); + mapper.AddElement(left, 0); + mapper.AddElement(right, 1); + + DiferenceVisitor visitor = new(noWarn: NoWarn, ignoredDifferences: IgnoredDifferences); + visitor.Visit(mapper); + return visitor.Differences; + } + + /// + /// Get's the differences when comparing Left vs Right based on the settings when instanciating the object. + /// It compares two symbols. + /// + /// Left symbol to compare against. + /// Right symbol to compare against. + /// List of found differences. + public IEnumerable GetDifferences(IAssemblySymbol left, IAssemblySymbol right) + { + AssemblyMapper mapper = new(_settings); + mapper.AddElement(left, 0); + mapper.AddElement(right, 1); + + DiferenceVisitor visitor = new(noWarn: NoWarn, ignoredDifferences: IgnoredDifferences); + visitor.Visit(mapper); + return visitor.Differences; + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/AssemblySymbolLoader.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/AssemblySymbolLoader.cs new file mode 100644 index 000000000000..1b78c1aaab7f --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/AssemblySymbolLoader.cs @@ -0,0 +1,377 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace Microsoft.DotNet.ApiCompatibility +{ + /// + /// Class that loads objects from source files, binaries or directories containing binaries. + /// + public class AssemblySymbolLoader + { + private readonly HashSet _referenceSearchPaths = new(); + private readonly List _warnings = new(); + private readonly Dictionary _loadedAssemblies; + private readonly bool _resolveReferences; + private CSharpCompilation _cSharpCompilation; + + /// + /// Instanciate an object with the desired setting to resolve assembly references or not. + /// + /// Indicates whether it should try to resolve assembly references when loading or not. + public AssemblySymbolLoader(bool resolveAssemblyReferences = false) + { + _loadedAssemblies = new Dictionary(); + var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable); + _cSharpCompilation = CSharpCompilation.Create($"AssemblyLoader_{DateTime.Now:MM_dd_yy_HH_mm_ss_FFF}", options: compilationOptions); + _resolveReferences = resolveAssemblyReferences; + } + + /// + /// Adds a set of paths to the search directories to resolve references from. + /// This is only used when the setting to resolve assembly references is set to true. + /// + /// Comma separated list of paths to register as search directories. + public void AddReferenceSearchDirectories(string paths) => AddReferenceSearchDirectories(SplitPaths(paths)); + + /// + /// Adds a set of paths to the search directories to resolve references from. + /// This is only used when the setting to resolve assembly references is set to true. + /// + /// The list of paths to register as search directories. + public void AddReferenceSearchDirectories(IEnumerable paths) + { + if (paths == null) + { + throw new ArgumentNullException(nameof(paths)); + } + + foreach (string path in paths) + _referenceSearchPaths.Add(path); + } + + /// + /// Indicates if the used to resolve binaries has any roslyn diagnostics. + /// Might be useful when loading an assembly from source files. + /// + /// List of diagnostics. + /// True if there are any diagnostics, false otherwise. + public bool HasRoslynDiagnostics(out IEnumerable diagnostics) + { + diagnostics = _cSharpCompilation.GetDiagnostics(); + return diagnostics.Any(); + } + + /// + /// Indicates if the loader emitted any warnings that might affect the assembly resolution. + /// + /// List of warnings. + /// True if there are any warnings, false otherwise. + public bool HasLoadWarnings(out IEnumerable warnings) + { + warnings = _warnings; + return _warnings.Count > 0; + } + + private static string[] SplitPaths(string paths) => + paths == null ? null : paths.Split(new char[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); + + /// + /// Loads a list of assemblies and gets its corresponding from the specified paths. + /// + /// Comma separated list of paths to load binaries from. Can be full paths to binaries or a directory. + /// The list of resolved . + public IEnumerable LoadAssemblies(string paths) => LoadAssemblies(SplitPaths(paths)); + + /// + /// Loads a list of assemblies and gets its corresponding from the specified paths. + /// + /// List of paths to load binaries from. Can be full paths to binaries or a directory. + /// The list of resolved . + public IEnumerable LoadAssemblies(IEnumerable paths) + { + if (paths == null) + { + throw new ArgumentNullException(nameof(paths)); + } + + IEnumerable assembliesToReturn = LoadFromPaths(paths); + + List result = new(); + foreach (MetadataReference metadataReference in assembliesToReturn) + { + ISymbol symbol = _cSharpCompilation.GetAssemblyOrModuleSymbol(metadataReference); + if (symbol is IAssemblySymbol assemblySymbol) + { + result.Add(assemblySymbol); + } + } + + return result; + } + + /// + /// Loads an assembly from the provided path. + /// + /// The full path to the assembly. + /// representing the loaded assembly. + public IAssemblySymbol LoadAssembly(string path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + MetadataReference metadataReference = CreateOrGetMetadataReferenceFromPath(path); + return (IAssemblySymbol)_cSharpCompilation.GetAssemblyOrModuleSymbol(metadataReference); + } + + /// + /// Loads an assembly using the provided name from a given . + /// + /// The name to use to resolve the assembly. + /// The stream to read the metadata from. + /// respresenting the given . If an + /// assembly with the same was already loaded, the previously loaded assembly is returned. + public IAssemblySymbol LoadAssembly(string name, Stream stream) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (stream == null) + { + throw new ArgumentNullException(nameof(stream)); + } + + if (stream.Position >= stream.Length) + { + throw new ArgumentException("Stream position is greater than it's length, so there are no contents available to read", nameof(stream)); + } + + if (!_loadedAssemblies.TryGetValue(name, out MetadataReference metadataReference)) + { + metadataReference = CreateAndAddReferenceToCompilation(name, stream); + } + + return (IAssemblySymbol)_cSharpCompilation.GetAssemblyOrModuleSymbol(metadataReference); + } + + /// + /// Loads an containing the metadata from the provided source files and given assembly name. + /// + /// The file paths to use as syntax trees to create the . + /// The name of the . + /// Paths to use as references if we want all the references to be included in the metadata. + /// The containing the metadata from the provided source files. + public IAssemblySymbol LoadAssemblyFromSourceFiles(IEnumerable filePaths, string assemblyName, IEnumerable referencePaths) + { + if (filePaths == null || filePaths.Count() == 0) + { + throw new ArgumentNullException(nameof(filePaths), $"Should not be null and contain at least one element"); + } + + if (string.IsNullOrEmpty(assemblyName)) + { + throw new ArgumentNullException(nameof(assemblyName), $"Should provide a valid assembly name"); + } + + _cSharpCompilation = _cSharpCompilation.WithAssemblyName(assemblyName); + + List syntaxTrees = new(); + foreach (string filePath in filePaths) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"File '{filePath}' does not exist."); + } + + syntaxTrees.Add(CSharpSyntaxTree.ParseText(File.ReadAllText(filePath))); + } + + _cSharpCompilation = _cSharpCompilation.AddSyntaxTrees(syntaxTrees); + + LoadFromPaths(referencePaths); + return _cSharpCompilation.Assembly; + } + + /// + /// Loads a list of matching assemblies given the "from" list that we should try and load it's matching assembly from the given paths. + /// + /// List of to search for. + /// List of search paths. + /// Indicates if we should validate that the identity of the resolved assembly is the same. + /// Indicates if a warning should be added to the warning list when a matching assembly is not found. + /// The list of matching assemblies represented as . + public IEnumerable LoadMatchingAssemblies(IEnumerable fromAssemblies, IEnumerable searchPaths, bool validateMatchingIdentity = true, bool warnOnMissingAssemblies = true) + { + if (fromAssemblies == null) + { + throw new ArgumentNullException(nameof(fromAssemblies)); + } + + if (searchPaths == null) + { + throw new ArgumentNullException(nameof(searchPaths)); + } + + List matchingAssemblies = new(); + foreach (IAssemblySymbol assembly in fromAssemblies) + { + bool found = false; + string name = $"{assembly.Name}.dll"; + foreach (string directory in searchPaths) + { + if (!Directory.Exists(directory)) + { + throw new FileNotFoundException($"Matching assembly search directory '{directory}' does not exist", nameof(searchPaths)); + } + + string possiblePath = Path.Combine(directory, name); + if (File.Exists(possiblePath)) + { + MetadataReference reference = CreateOrGetMetadataReferenceFromPath(possiblePath); + ISymbol symbol = _cSharpCompilation.GetAssemblyOrModuleSymbol(reference); + if (symbol is IAssemblySymbol matchingAssembly) + { + if (validateMatchingIdentity && !matchingAssembly.Identity.Equals(assembly.Identity)) + { + _cSharpCompilation = _cSharpCompilation.RemoveReferences(new[] { reference }); + _loadedAssemblies.Remove(name); + continue; + } + + matchingAssemblies.Add(matchingAssembly); + found = true; + break; + } + } + } + + if (warnOnMissingAssemblies && !found) + { + string assemblyInfo = validateMatchingIdentity ? assembly.Identity.GetDisplayName() : assembly.Name; + _warnings.Add($"Could not find matching assembly: '{assemblyInfo}' in any of the search directories."); + } + } + + return matchingAssemblies; + } + + private IEnumerable LoadFromPaths(IEnumerable paths) + { + List result = new(); + foreach (string path in paths) + { + string resolvedPath = Environment.ExpandEnvironmentVariables(path); + string directory = null; + if (Directory.Exists(resolvedPath)) + { + directory = resolvedPath; + result.AddRange(LoadAssembliesFromDirectory(resolvedPath)); + } + else if (File.Exists(resolvedPath)) + { + directory = Path.GetDirectoryName(resolvedPath); + result.Add(CreateOrGetMetadataReferenceFromPath(resolvedPath)); + } + else + { + _warnings.Add($"Could not find the provided path '{resolvedPath}' to load binaries from."); + } + + if (_resolveReferences && !string.IsNullOrEmpty(directory)) + _referenceSearchPaths.Add(directory); + } + + return result; + } + + private IEnumerable LoadAssembliesFromDirectory(string directory) + { + foreach (string assembly in Directory.EnumerateFiles(directory, "*.dll")) + { + yield return CreateOrGetMetadataReferenceFromPath(assembly); + } + } + + private MetadataReference CreateOrGetMetadataReferenceFromPath(string path) + { + // Roslyn doesn't support having two assemblies as references with the same identity and then getting the symbol for it. + string name = Path.GetFileName(path); + if (!_loadedAssemblies.TryGetValue(name, out MetadataReference metadataReference)) + { + using FileStream stream = File.OpenRead(path); + metadataReference = CreateAndAddReferenceToCompilation(name, stream); + } + + return metadataReference; + } + + private MetadataReference CreateAndAddReferenceToCompilation(string name, Stream fileStream) + { + // If we need to resolve references we can't reuse the same stream after creating the metadata + // reference from it as Roslyn closes it. So instead we use PEReader and get the bytes + // and create the metadata reference from that. + using PEReader reader = new(fileStream); + + if (!reader.HasMetadata) + { + throw new ArgumentException($"Provided stream for assembly {name} doesn't have any metadata to read from"); + } + + PEMemoryBlock image = reader.GetEntireImage(); + MetadataReference metadataReference = MetadataReference.CreateFromImage(image.GetContent()); + _loadedAssemblies.Add(name, metadataReference); + _cSharpCompilation = _cSharpCompilation.AddReferences(new MetadataReference[] { metadataReference }); + + if (_resolveReferences) + { + ResolveReferences(reader); + } + + + return metadataReference; + } + + private void ResolveReferences(PEReader peReader) + { + MetadataReader reader = peReader.GetMetadataReader(); + foreach (AssemblyReferenceHandle handle in reader.AssemblyReferences) + { + AssemblyReference reference = reader.GetAssemblyReference(handle); + string name = $"{reader.GetString(reference.Name)}.dll"; + bool found = _loadedAssemblies.TryGetValue(name, out MetadataReference _); + if (!found) + { + foreach (string directory in _referenceSearchPaths) + { + string potentialPath = Path.Combine(directory, name); + if (File.Exists(potentialPath)) + { + // TODO: add version check and add a warning if it doesn't match? + using FileStream resolvedStream = File.OpenRead(potentialPath); + CreateAndAddReferenceToCompilation(name, resolvedStream); + found = true; + break; + } + } + } + + if (!found) + { + _warnings.Add($"Could not resolve reference '{name}' in any of the provided search directories."); + } + } + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/ComparingSettings.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/ComparingSettings.cs new file mode 100644 index 000000000000..d218ec6e4f43 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/ComparingSettings.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using Microsoft.DotNet.ApiCompatibility.Rules; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility +{ + /// + /// Class that contains all the settings used to filter metadata, compare symbols and run rules. + /// + public class ComparingSettings + { + /// + /// The factory to get the . + /// + public IRuleRunnerFactory RuleRunnerFactory { get; } + + /// + /// The metadata filter to use when creating the . + /// + public ISymbolFilter Filter { get; } + + /// + /// The comparer to map metadata. + /// + public IEqualityComparer EqualityComparer { get; } + + /// + /// Instantiate an object with the desired settings. + /// + /// The factory to create a + /// The symbol filter. + /// The comparer to map metadata. + public ComparingSettings(IRuleRunnerFactory ruleRunnerFactory = null, ISymbolFilter filter = null, IEqualityComparer equalityComparer = null) + { + RuleRunnerFactory = ruleRunnerFactory ?? new RuleRunnerFactory(); + Filter = filter ?? new SymbolAccessibilityBasedFilter(includeInternalSymbols: false); + EqualityComparer = equalityComparer ?? new DefaultSymbolsEqualityComparer(); + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DefaultSymbolsEqualityComparer.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DefaultSymbolsEqualityComparer.cs new file mode 100644 index 000000000000..3d6af65d7507 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DefaultSymbolsEqualityComparer.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility +{ + internal class DefaultSymbolsEqualityComparer : IEqualityComparer + { + public bool Equals(ISymbol x, ISymbol y) => + string.Equals(GetKey(x), GetKey(y), StringComparison.OrdinalIgnoreCase); + + public int GetHashCode(ISymbol obj) => + GetKey(obj).GetHashCode(); + + private static string GetKey(ISymbol symbol) => + symbol switch + { + IMethodSymbol => symbol.ToDisplayString(), + IFieldSymbol => symbol.ToDisplayString(), + IPropertySymbol => symbol.ToDisplayString(), + IEventSymbol => symbol.ToDisplayString(), + ITypeSymbol => symbol.ToDisplayString(), + _ => symbol.Name, + }; + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiagnosticBag.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiagnosticBag.cs new file mode 100644 index 000000000000..9450fafcff26 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiagnosticBag.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility +{ + /// + /// This is a bag that contains a list of and filters them out based on the + /// noWarn and ignoredDifferences settings when they are added to the bag. + /// + /// Type to represent the diagnostics. + public class DiagnosticBag where T : IDiagnostic + { + private readonly Dictionary> _ignore; + private readonly HashSet _noWarn; + + private readonly List _differences = new(); + + /// + /// Instantiate an diagnostic bag with the provided settings to ignore diagnostics. + /// + /// Comma separated list of diagnostic IDs to ignore. + /// An array of differences to ignore based on diagnostic ID and reference ID. + public DiagnosticBag(string noWarn, (string diagnosticId, string referenceId)[] ignoredDifferences) + { + _noWarn = new HashSet(noWarn?.Split(';')); + _ignore = new Dictionary>(); + + foreach ((string diagnosticId, string referenceId) in ignoredDifferences) + { + if (!_ignore.TryGetValue(diagnosticId, out HashSet members)) + { + members = new HashSet(); + _ignore.Add(diagnosticId, members); + } + + members.Add(referenceId); + } + } + + /// + /// Adds the differences to the diagnostic bag if they are not found in the exclusion settings. + /// + /// The differences to add. + public void AddRange(IEnumerable differences) + { + foreach (T difference in differences) + Add(difference); + } + + /// + /// Adds a difference to the diagnostic bag if they are not found in the exclusion settings. + /// + /// The difference to add. + public void Add(T difference) + { + if (_noWarn.Contains(difference.DiagnosticId)) + return; + + if (_ignore.TryGetValue(difference.DiagnosticId, out HashSet members)) + { + if (members.Contains(difference.ReferenceId)) + { + return; + } + } + + _differences.Add(difference); + } + + /// + /// A list of differences contained in the diagnostic bag. + /// + public IEnumerable Differences => _differences; + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs new file mode 100644 index 000000000000..9114576645f4 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiagnosticIds.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.DotNet.ApiCompatibility +{ + /// + /// Class containing the strings representing the Diagnostic IDs that can be returned in the compatibility differences. + /// + public static class DiagnosticIds + { + public const string TypeMustExist = "CP0001"; + public const string MemberMustExist = "CP0002"; + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiferenceVisitor.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiferenceVisitor.cs new file mode 100644 index 000000000000..c725ecd24df1 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/DiferenceVisitor.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using System; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility +{ + /// + /// The visitor that traverses the mappers' tree and gets it's differences in a . + /// + public class DiferenceVisitor : MapperVisitor + { + private readonly DiagnosticBag _differenceBag; + + /// + /// Instantiates the visitor with the desired settings. + /// + /// A comma separated list of diagnostic IDs to ignore. + /// A list of tuples to ignore diagnostic IDs by symbol. + public DiferenceVisitor(string noWarn = null, (string diagnosticId, string symbolId)[] ignoredDifferences = null) + { + _differenceBag = new DiagnosticBag(noWarn ?? string.Empty, ignoredDifferences ?? Array.Empty<(string, string)>()); + } + + /// + /// Visits an and adds it's differences to the . + /// + /// The mapper to visit. + public override void Visit(AssemblyMapper assembly) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + _differenceBag.AddRange(assembly.GetDifferences()); + base.Visit(assembly); + } + + /// + /// Visits an and adds it's differences to the . + /// + /// The mapper to visit. + public override void Visit(TypeMapper type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + _differenceBag.AddRange(type.GetDifferences()); + + if (type.ShouldDiffMembers) + { + base.Visit(type); + } + } + + /// + /// Visits an and adds it's differences to the . + /// + /// The mapper to visit. + public override void Visit(MemberMapper member) + { + if (member == null) + { + throw new ArgumentNullException(nameof(member)); + } + + _differenceBag.AddRange(member.GetDifferences()); + } + + /// + /// The differences that the has at this point. + /// + public IEnumerable Differences => _differenceBag.Differences; + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Extensions/TypeSymbolExtensions.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Extensions/TypeSymbolExtensions.cs new file mode 100644 index 000000000000..095c0e4432a6 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Extensions/TypeSymbolExtensions.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Extensions +{ + internal static class TypeSymbolExtensions + { + internal static IEnumerable GetAllBaseTypes(this ITypeSymbol type) + { + if (type.TypeKind == TypeKind.Interface) + { + foreach (ITypeSymbol @interface in type.Interfaces) + { + yield return @interface; + foreach (ITypeSymbol baseInterface in @interface.GetAllBaseTypes()) + yield return baseInterface; + } + } + else if (type.BaseType != null) + { + yield return type.BaseType; + foreach (ITypeSymbol baseType in type.BaseType.GetAllBaseTypes()) + yield return baseType; + } + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Microsoft.DotNet.ApiCompatibility.csproj b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Microsoft.DotNet.ApiCompatibility.csproj new file mode 100644 index 000000000000..ce1ea9aa3968 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Microsoft.DotNet.ApiCompatibility.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + true + true + Open + 0.1.0 + + RS1024;$(NoWarn) + + + + + + + + diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/MembersMustExist.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/MembersMustExist.cs new file mode 100644 index 000000000000..ad310a7b9fe4 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/MembersMustExist.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using Microsoft.DotNet.ApiCompatibility.Extensions; +using System.Collections.Generic; + +namespace Microsoft.DotNet.ApiCompatibility.Rules +{ + /// + /// Rule that evaluates whether a member exists on Left and Right. + /// If the member doesn't exist on Right but it does on Left, it adds a to the list of differences. + /// + public class MembersMustExist : Rule + { + /// + /// Evaluates whether a type exists on both sides of the . + /// + /// The to evaluate. + /// The list of to add differences to. + public override void Run(TypeMapper mapper, IList differences) + { + ITypeSymbol left = mapper.Left; + if (left != null && mapper.Right == null) + differences.Add(new CompatDifference(DiagnosticIds.TypeMustExist, $"Type '{left.ToDisplayString()}' exists on the left but not on the right", DifferenceType.Removed, left)); + } + + /// + /// Evaluates whether member (Field, Property, Method, Constructor, Event) exists on both sides of the . + /// + /// The to evaluate. + /// The list of to add differences to. + public override void Run(MemberMapper mapper, IList differences) + { + ISymbol left = mapper.Left; + if (left != null && mapper.Right == null) + { + // Events and properties are handled via their accessors. + if (left.Kind == SymbolKind.Property || left.Kind == SymbolKind.Event) + return; + + if (left is IMethodSymbol method) + { + // Will be handled by a different rule + if (method.MethodKind == MethodKind.ExplicitInterfaceImplementation) + return; + + // If method is an override or hides a base type definition removing it from right is compatible. + if (method.IsOverride || FindMatchingOnBaseType(method)) + return; + } + + differences.Add(new CompatDifference(DiagnosticIds.MemberMustExist, $"Member '{left.ToDisplayString()}' exists on the left but not on the right", DifferenceType.Removed, left)); + } + } + + private bool FindMatchingOnBaseType(IMethodSymbol method) + { + foreach (ITypeSymbol type in method.ContainingType.GetAllBaseTypes()) + { + foreach (ISymbol symbol in type.GetMembers()) + { + if (symbol is IMethodSymbol candidate && IsMatchingMethod(method, candidate)) + return true; + } + } + + return false; + } + + private bool IsMatchingMethod(IMethodSymbol method, IMethodSymbol candidate) + { + if (method.Name == candidate.Name) + { + if (ParametersMatch(method, candidate) && ReturnTypesMatch(method, candidate)) + { + if (method.TypeParameters.Length == candidate.TypeParameters.Length) + return true; + } + } + + return false; + } + + private bool ReturnTypesMatch(IMethodSymbol method, IMethodSymbol candidate) => + method.ReturnType.ToDisplayString() == candidate.ReturnType.ToDisplayString(); + + private bool ParametersMatch(IMethodSymbol method, IMethodSymbol candidate) + { + if (method.Parameters.Length != candidate.Parameters.Length) + return false; + + for (int i = 0; i < method.Parameters.Length; i++) + { + if (method.Parameters[i].Type.ToDisplayString() != method.Parameters[i].Type.ToDisplayString()) + return false; + } + + return true; + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs new file mode 100644 index 000000000000..c354949516c2 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunner.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information.s + +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.DotNet.ApiCompatibility.Rules +{ + internal class RuleRunner : IRuleRunner + { + private readonly Rule[] _rules; + + internal RuleRunner() + { + _rules = GetRules(); + } + + public IEnumerable Run(ElementMapper mapper) + { + List differences = new(); + if (mapper is AssemblyMapper am) + { + Run(am, differences); + } + if (mapper is TypeMapper tm) + { + Run(tm, differences); + } + if (mapper is MemberMapper mm) + { + Run(mm, differences); + } + + return differences; + } + + private void Run(AssemblyMapper mapper, List differences) + { + foreach (Rule rule in _rules) + { + rule.Run(mapper, differences); + } + } + + private void Run(TypeMapper mapper, List differences) + { + foreach (Rule rule in _rules) + { + rule.Run(mapper, differences); + } + } + + private void Run(MemberMapper mapper, List differences) + { + foreach (Rule rule in _rules) + { + rule.Run(mapper, differences); + } + } + + private Rule[] GetRules() + { + List rules = new(); + foreach (Type type in GetType().Assembly.GetTypes()) + { + if (!type.IsAbstract && typeof(Rule).IsAssignableFrom(type)) + rules.Add((Rule)Activator.CreateInstance(type)); + } + + return rules.ToArray(); + } + } +} diff --git a/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunnerFactory.cs b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunnerFactory.cs new file mode 100644 index 000000000000..1c3b87008252 --- /dev/null +++ b/src/Compatibility/Microsoft.DotNet.ApiCompatibility/Rules/RuleRunnerFactory.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information.s + +using Microsoft.DotNet.ApiCompatibility.Abstractions; + +namespace Microsoft.DotNet.ApiCompatibility.Rules +{ + public class RuleRunnerFactory : IRuleRunnerFactory + { + private RuleRunner _driver; + public IRuleRunner GetRuleRunner() + { + if (_driver == null) + _driver = new RuleRunner(); + + return _driver; + } + } +} diff --git a/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/AssemblySymbolLoaderTests.cs b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/AssemblySymbolLoaderTests.cs new file mode 100644 index 000000000000..7e062ee58b7b --- /dev/null +++ b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/AssemblySymbolLoaderTests.cs @@ -0,0 +1,356 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.NET.TestFramework; +using Microsoft.NET.TestFramework.Assertions; +using Microsoft.NET.TestFramework.Commands; +using Microsoft.NET.TestFramework.ProjectConstruction; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.ApiCompatibility.Tests +{ + public class AssemblySymbolLoaderTests : SdkTest + { + public AssemblySymbolLoaderTests(ITestOutputHelper log) : base(log) { } + + private const string SimpleAssemblySourceContents = @" +namespace MyNamespace +{ + public class MyClass + { + } +} +"; + + // Since we use typeof(string).Assembly.Location to resolve references + // We need to target a framework compatible with what the test is being + // built for so that we resolve the references correctly. +#if NETCOREAPP + private const string TargetFrameworks = "net5.0"; +#else + private const string TargetFrameworks = "net471"; +#endif + + private class TestAssetInfo + { + public TestAsset TestAsset { get; set; } + public string OutputDirectory { get; set; } + } + + // We use the same asset in multiple tests. + // Creating a TestAsset and building it for each test + // creates a lot of overhead, using the cache + // speeds up test execution ~3x in this test assembly. + // Tests within the same class run serially in xunit, so + // it is fine to reuse the same asset. + private class TestAssetCache + { + public static TestAssetCache Instance = new TestAssetCache(); + + private ConcurrentDictionary Dictionary = new(); + + private TestAssetInfo _asset = null; + + public TestAssetInfo GetSimpleAsset(TestAssetsManager manager) + => _asset ?? InitAsset(manager); + + private TestAssetInfo InitAsset(TestAssetsManager manager) + { + Interlocked.CompareExchange(ref _asset, GetAsset(manager), null); + return _asset; + } + + private TestAssetInfo GetAsset(TestAssetsManager manager) + { + TestProject project = new("SimpleAsset") + { + TargetFrameworks = TargetFrameworks, + IsExe = false + }; + + project.SourceFiles.Add("MyClass.cs", SimpleAssemblySourceContents); + + TestAsset testAsset = manager.CreateTestProject(project); + BuildTestAsset(testAsset, out string outDir) + .Should() + .Pass(); + + return new TestAssetInfo() + { + TestAsset = testAsset, + OutputDirectory = outDir, + }; + } + } + + private TestAssetInfo GetSimpleTestAsset() => TestAssetCache.Instance.GetSimpleAsset(_testAssetsManager); + + [Fact] + public void LoadAssembly_Throws() + { + AssemblySymbolLoader loader = new(); + Assert.Throws(() => loader.LoadAssembly(Guid.NewGuid().ToString("N").Substring(0, 8))); + Assert.Throws("path", () => loader.LoadAssembly(null)); + Assert.Throws("stream", () => loader.LoadAssembly("Assembly", null)); + Assert.Throws("name", () => loader.LoadAssembly(null, new MemoryStream())); + } + + [Fact] + public void LoadAssemblies_Throws() + { + AssemblySymbolLoader loader = new(); + Assert.Throws("paths", () => loader.LoadAssemblies((string)null)); + Assert.Throws("paths", () => loader.LoadAssemblies((IEnumerable)null)); + } + + [Fact] + public void LoadAssemblyFromSourceFiles_Throws() + { + AssemblySymbolLoader loader = new(); + IEnumerable paths = new[] { Guid.NewGuid().ToString("N") }; + Assert.Throws(() => loader.LoadAssemblyFromSourceFiles(paths, "assembly1", Array.Empty())); + Assert.Throws("filePaths", () => loader.LoadAssemblyFromSourceFiles(null, "assembly1", Array.Empty())); + Assert.Throws("filePaths", () => loader.LoadAssemblyFromSourceFiles(Array.Empty(), "assembly1", Array.Empty())); + Assert.Throws("assemblyName", () => loader.LoadAssemblyFromSourceFiles(paths, null, Array.Empty())); + } + + [Fact] + public void LoadMatchingAssemblies_Throws() + { + AssemblySymbolLoader loader = new(); + IEnumerable paths = new[] { Guid.NewGuid().ToString("N") }; + IAssemblySymbol assembly = SymbolFactory.GetAssemblyFromSyntax("namespace MyNamespace { class Foo { } }"); + + Assert.Throws(() => loader.LoadMatchingAssemblies(new[] { assembly }, paths)); + Assert.Throws("fromAssemblies", () => loader.LoadMatchingAssemblies(null, paths)); + Assert.Throws("searchPaths", () => loader.LoadMatchingAssemblies(Array.Empty(), null)); + } + + [Fact] + public void LoadMatchingAssembliesWarns() + { + IAssemblySymbol assembly = SymbolFactory.GetAssemblyFromSyntax("namespace MyNamespace { class Foo { } }"); + IEnumerable paths = new[] { AppContext.BaseDirectory }; + + AssemblySymbolLoader loader = new(); + IEnumerable symbols = loader.LoadMatchingAssemblies(new[] { assembly }, paths); + Assert.Empty(symbols); + Assert.True(loader.HasLoadWarnings(out IEnumerable warnings)); + + IEnumerable expected = new[] + { + $"Could not find matching assembly: '{assembly.Identity.GetDisplayName()}' in any of the search directories." + }; + + Assert.Equal(expected, warnings, StringComparer.Ordinal); + } + + [Fact] + public void LoadMatchingAssembliesSameIdentitySucceeds() + { + string assemblyName = nameof(LoadMatchingAssembliesSameIdentitySucceeds); + IAssemblySymbol fromAssembly = SymbolFactory.GetAssemblyFromSyntax(SimpleAssemblySourceContents, assemblyName: assemblyName); + + TestProject testProject = new(assemblyName) + { + TargetFrameworks = "net5.0", + IsExe = false, + }; + + testProject.SourceFiles.Add("MyClass.cs", SimpleAssemblySourceContents); + testProject.AdditionalProperties.Add("AssemblyVersion", "0.0.0.0"); + TestAsset testAsset = _testAssetsManager.CreateTestProject(testProject); + + BuildTestAsset(testAsset, out string outputDirectory) + .Should() + .Pass(); + + AssemblySymbolLoader loader = new(); + IEnumerable matchingAssemblies = loader.LoadMatchingAssemblies(new[] { fromAssembly }, new[] { outputDirectory }); + + Assert.Single(matchingAssemblies); + Assert.False(loader.HasLoadWarnings(out var _)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LoadMatchingAssemblies_DifferentIdentity(bool validateIdentities) + { + var assetInfo = GetSimpleTestAsset(); + IAssemblySymbol fromAssembly = SymbolFactory.GetAssemblyFromSyntax(SimpleAssemblySourceContents, assemblyName: assetInfo.TestAsset.TestProject.Name); + + AssemblySymbolLoader loader = new(); + IEnumerable matchingAssemblies = loader.LoadMatchingAssemblies(new[] { fromAssembly }, new[] { assetInfo.OutputDirectory }, validateMatchingIdentity: validateIdentities); + + if (validateIdentities) + { + Assert.Empty(matchingAssemblies); + Assert.True(loader.HasLoadWarnings(out IEnumerable warnings)); + + IEnumerable expected = new[] + { + $"Could not find matching assembly: '{fromAssembly.Identity.GetDisplayName()}' in any of the search directories." + }; + + Assert.Equal(expected, warnings, StringComparer.Ordinal); + } + else + { + Assert.Single(matchingAssemblies); + Assert.False(loader.HasLoadWarnings(out var _)); + Assert.NotEqual(fromAssembly.Identity, matchingAssemblies.FirstOrDefault().Identity); + } + } + + [Fact] + public void LoadsSimpleAssemblyFromDirectory() + { + var assetInfo = GetSimpleTestAsset(); + AssemblySymbolLoader loader = new(); + IEnumerable symbols = loader.LoadAssemblies(assetInfo.OutputDirectory); + Assert.Single(symbols); + + IEnumerable types = symbols.FirstOrDefault() + .GlobalNamespace + .GetNamespaceMembers() + .FirstOrDefault() + .GetTypeMembers(); + + Assert.Single(types); + Assert.Equal("MyNamespace.MyClass", types.FirstOrDefault().ToDisplayString()); + } + + [Fact] + public void LoadSimpleAssemblyFullPath() + { + var assetInfo = GetSimpleTestAsset(); + AssemblySymbolLoader loader = new(); + IAssemblySymbol symbol = loader.LoadAssembly(Path.Combine(assetInfo.OutputDirectory, assetInfo.TestAsset.TestProject.Name + ".dll")); + + IEnumerable types = symbol.GlobalNamespace + .GetNamespaceMembers() + .FirstOrDefault() + .GetTypeMembers(); + + Assert.Single(types); + Assert.Equal("MyNamespace.MyClass", types.FirstOrDefault().ToDisplayString()); + } + + [Fact] + public void LoadsMultipleAssembliesFromDirectory() + { + TestProject first = new("LoadsMultipleAssembliesFromDirectory_First") + { + TargetFrameworks = TargetFrameworks, + IsExe = false + }; + + TestProject second = new("LoadsMultipleAssembliesFromDirectory_Second") + { + TargetFrameworks = TargetFrameworks, + IsExe = false + }; + + first.ReferencedProjects.Add(second); + TestAsset testAsset = _testAssetsManager.CreateTestProject(first); + + BuildTestAsset(testAsset, out string outputDirectory) + .Should() + .Pass(); + + AssemblySymbolLoader loader = new(); + IEnumerable symbols = loader.LoadAssemblies(outputDirectory); + + Assert.Equal(2, symbols.Count()); + + IEnumerable expected = new[] { "LoadsMultipleAssembliesFromDirectory_First", "LoadsMultipleAssembliesFromDirectory_Second" }; + IEnumerable actual = symbols.Select(a => a.Name).OrderBy(a => a, StringComparer.Ordinal); + + Assert.Equal(expected, actual, StringComparer.Ordinal); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LoadAssemblyResolveReferences_WarnsWhenEnabled(bool resolveReferences) + { + var assetInfo = GetSimpleTestAsset(); + AssemblySymbolLoader loader = new(resolveAssemblyReferences: resolveReferences); + loader.LoadAssembly(Path.Combine(assetInfo.OutputDirectory, assetInfo.TestAsset.TestProject.Name + ".dll")); + + if (resolveReferences) + { + Assert.True(loader.HasLoadWarnings(out IEnumerable warnings)); + + string expectedReference = "System.Runtime.dll"; + + if (TargetFrameworks.StartsWith("net4", StringComparison.OrdinalIgnoreCase)) + { + expectedReference = "mscorlib.dll"; + } + + IEnumerable expected = new[] + { + $"Could not resolve reference '{expectedReference}' in any of the provided search directories." + }; + + Assert.Equal(expected, warnings, StringComparer.Ordinal); + } + else + { + Assert.False(loader.HasLoadWarnings(out IEnumerable warnings)); + Assert.Empty(warnings); + } + } + + [Fact] + public void LoadAssembliesShouldResolveReferencesNoWarnings() + { + var assetInfo = GetSimpleTestAsset(); + AssemblySymbolLoader loader = new(resolveAssemblyReferences: true); + loader.AddReferenceSearchDirectories(Path.GetDirectoryName(typeof(string).Assembly.Location)); + loader.LoadAssembly(Path.Combine(assetInfo.OutputDirectory, assetInfo.TestAsset.TestProject.Name + ".dll")); + + Assert.False(loader.HasLoadWarnings(out IEnumerable warnings)); + Assert.Empty(warnings); + } + + [Fact] + public void LoadAssemblyFromStreamNoWarns() + { + var assetInfo = GetSimpleTestAsset(); + TestProject testProject = assetInfo.TestAsset.TestProject; + AssemblySymbolLoader loader = new(); + using FileStream stream = File.OpenRead(Path.Combine(assetInfo.OutputDirectory, testProject.Name + ".dll")); + IAssemblySymbol symbol = loader.LoadAssembly(testProject.Name, stream); + + Assert.False(loader.HasLoadWarnings(out var _)); + Assert.Equal(testProject.Name, symbol.Name, StringComparer.Ordinal); + + IEnumerable types = symbol.GlobalNamespace + .GetNamespaceMembers() + .FirstOrDefault() + .GetTypeMembers(); + + Assert.Single(types); + Assert.Equal("MyNamespace.MyClass", types.FirstOrDefault().ToDisplayString()); + } + + private static CommandResult BuildTestAsset(TestAsset testAsset, out string outputDirectory) + { + BuildCommand buildCommand = new(testAsset); + outputDirectory = buildCommand.GetOutputDirectory(testAsset.TestProject.TargetFrameworks).FullName; + return buildCommand.Execute(); + } + } +} diff --git a/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Microsoft.DotNet.ApiCompatibility.Tests.csproj b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Microsoft.DotNet.ApiCompatibility.Tests.csproj new file mode 100644 index 000000000000..dbf5425a530e --- /dev/null +++ b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Microsoft.DotNet.ApiCompatibility.Tests.csproj @@ -0,0 +1,18 @@ + + + + false + net472;$(ToolsetTargetFramework) + $(ToolsetTargetFramework) + Exe + + true + true + + + + + + + + diff --git a/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Rules/MembersMustExistTests.cs b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Rules/MembersMustExistTests.cs new file mode 100644 index 000000000000..245daef6f1e9 --- /dev/null +++ b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Rules/MembersMustExistTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.DotNet.ApiCompatibility.Tests.Rules +{ + public class MembersMustExistTests + { + [Fact] + public static void MissingMembersAreReported() + { + string leftSyntax = @" +namespace CompatTests +{ + public class First + { + public string Parameterless() { } + public void ShouldReportMethod(string a, string b) { } + public string ShouldReportMissingProperty { get; } + public string this[int index] { get; } + public event EventHandler ShouldReportMissingEvent; + public int ReportMissingField = 0; + } + + public delegate void EventHandler(object sender EventArgs e); +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First + { + public string Parameterless() { } + } + public delegate void EventHandler(object sender EventArgs e); +} +"; + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + ApiComparer differ = new(); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.ShouldReportMethod(string, string)' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.ShouldReportMethod(System.String,System.String)"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.ShouldReportMissingProperty.get' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.get_ShouldReportMissingProperty"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.this[int].get' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.get_Item(System.Int32)"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.ShouldReportMissingEvent.add' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.add_ShouldReportMissingEvent(CompatTests.EventHandler)"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.ShouldReportMissingEvent.remove' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.remove_ShouldReportMissingEvent(CompatTests.EventHandler)"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.ReportMissingField' exists on the left but not on the right", DifferenceType.Removed, "F:CompatTests.First.ReportMissingField"), + }; + + Assert.Equal(expected, differences); + } + + [Fact] + public static void HiddenMemberInLeftIsNotReported() + { + string leftSyntax = @" +namespace CompatTests +{ + public class FirstBase + { + public void MyMethod() { } + public string MyMethodWithParams(string a, int b, FirstBase c) { } + public T MyGenericMethod(string name, T2 a, T3 b) { } + public virtual string MyVirtualMethod() { } + } + public class Second : FirstBase + { + public new void MyMethod() { } + public new string MyMethodWithParams(string a, int b, FirstBase c) { } + public new T MyGenericMethod(string name, T2 a, T3 b) { } + public override string MyVirtualMethod() { } + } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class FirstBase + { + public void MyMethod() { } + public string MyMethodWithParams(string a, int b, FirstBase c) { } + public T MyGenericMethod(string name, T2 a, T3 b) { } + public virtual string MyVirtualMethod() { } + } + public class Second : FirstBase { } +} +"; + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + ApiComparer differ = new(); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + Assert.Empty(differences); + } + + [Fact] + public static void NoDifferencesWithNoWarn() + { + string leftSyntax = @" +namespace CompatTests +{ + public class First + { + public void MissingMember() { } + public int MissingProperty { get; } + public int MissingField; + } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First + { + } +} +"; + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + ApiComparer differ = new(); + differ.NoWarn = DiagnosticIds.MemberMustExist; + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + Assert.Empty(differences); + } + + [Fact] + public static void MultipleOverridesAreReported() + { + string leftSyntax = @" +namespace CompatTests +{ + public class First + { + public string MultipleOverrides() { } + public string MultipleOverrides(string a) { } + public string MultipleOverrides(string a, string b) { } + public string MultipleOverrides(string a, int b, string c) { } + public string MultipleOverrides(string a, int b, int c) { } + } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First + { + public string MultipleOverrides() { } + public string MultipleOverrides(string a) { } + public string MultipleOverrides(string a, int b, int c) { } + } +} +"; + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + ApiComparer differ = new(); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.MultipleOverrides(string, string)' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.MultipleOverrides(System.String,System.String)"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.MultipleOverrides(string, int, string)' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.MultipleOverrides(System.String,System.Int32,System.String)"), + }; + + Assert.Equal(expected, differences); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void IncludeInternalsIsRespectedForMembers_IndividualAssemblies(bool includeInternals) + { + string leftSyntax = @" +namespace CompatTests +{ + public class First + { + public string MultipleOverrides() { } + public string MultipleOverrides(string a) { } + public string MultipleOverrides(string a, string b) { } + public string MultipleOverrides(string a, int b, string c) { } + internal string MultipleOverrides(string a, int b, int c) { } + internal int InternalProperty { get; set; } + } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First + { + public string MultipleOverrides() { } + public string MultipleOverrides(string a) { } + public string MultipleOverrides(string a, string b) { } + public string MultipleOverrides(string a, int b, string c) { } + internal int InternalProperty { get; } + } +} +"; + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax, assemblyName: "DifferentName"); + ApiComparer differ = new(includeInternalSymbols: includeInternals); + IEnumerable differences = differ.GetDifferences(left, right); + + if (includeInternals) + { + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.MultipleOverrides(string, int, int)' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.MultipleOverrides(System.String,System.Int32,System.Int32)"), + new CompatDifference(DiagnosticIds.MemberMustExist, "Member 'CompatTests.First.InternalProperty.set' exists on the left but not on the right", DifferenceType.Removed, "M:CompatTests.First.set_InternalProperty(System.Int32)"), + }; + + Assert.Equal(expected, differences); + } + else + { + Assert.Empty(differences); + } + } + } +} diff --git a/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Rules/TypeMustExistTests.cs b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Rules/TypeMustExistTests.cs new file mode 100644 index 000000000000..4092071fab57 --- /dev/null +++ b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/Rules/TypeMustExistTests.cs @@ -0,0 +1,280 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.DotNet.ApiCompatibility.Abstractions; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.DotNet.ApiCompatibility.Tests +{ + public class TypeMustExistTests + { + [Theory] + [InlineData("")] + [InlineData("CP002")] + public void MissingPublicTypesInRightAreReported(string noWarn) + { + string leftSyntax = @" + +namespace CompatTests +{ + public class First { } + public class Second { } + public record MyRecord(string a, string b); + public struct MyStruct { } + public delegate void MyDelegate(object a); + public enum MyEnum { } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First { } +} +"; + + ApiComparer differ = new(); + differ.NoWarn = noWarn; + bool enableNullable = false; + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax, enableNullable); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax, enableNullable); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.Second' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.Second"), + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.MyRecord' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.MyRecord"), + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.MyStruct' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.MyStruct"), + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.MyDelegate' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.MyDelegate"), + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.MyEnum' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.MyEnum"), + }; + + Assert.Equal(expected, differences); + } + + [Fact] + public void MissingTypeFromTypeForwardIsReported() + { + string forwardedTypeSyntax = @" +namespace CompatTests +{ + public class ForwardedTestType { } +} +"; + string leftSyntax = @" +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(CompatTests.ForwardedTestType))] +namespace CompatTests +{ + public class First { } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First { } +} +"; + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntaxWithReferences(leftSyntax, new[] { forwardedTypeSyntax }, includeDefaultReferences: true); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + ApiComparer differ = new(); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.ForwardedTestType' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.ForwardedTestType") + }; + + Assert.Equal(expected, differences); + } + + [Fact] + public void TypeForwardExistsOnBoth() + { + string forwardedTypeSyntax = @" +namespace CompatTests +{ + public class ForwardedTestType { } +} +"; + string syntax = @" +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(CompatTests.ForwardedTestType))] +namespace CompatTests +{ + public class First { } +} +"; + IEnumerable references = new[] { forwardedTypeSyntax }; + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntaxWithReferences(syntax, references, includeDefaultReferences: true); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntaxWithReferences(syntax, references, includeDefaultReferences: true); + ApiComparer differ = new(); + Assert.Empty(differ.GetDifferences(new[] { left }, new[] { right })); + } + + [Fact] + public void NoDifferencesReportedWithNoWarn() + { + string leftSyntax = @" + +namespace CompatTests +{ + public class First { } + public class Second { } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First { } +} +"; + + ApiComparer differ = new(); + differ.NoWarn = DiagnosticIds.TypeMustExist; + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + Assert.Empty(differ.GetDifferences(new[] { left }, new[] { right })); + } + + [Fact] + public void DifferenceIsIgnoredForMember() + { + string leftSyntax = @" + +namespace CompatTests +{ + public record First(string a, string b); + public class Second { } + public class Third { } + public class Fourth { } + public enum MyEnum { } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public record First(string a, string b); +} +"; + + (string, string)[] ignoredDifferences = new[] + { + (DiagnosticIds.TypeMustExist, "T:CompatTests.Second"), + (DiagnosticIds.TypeMustExist, "T:CompatTests.MyEnum"), + }; + + ApiComparer differ = new(); + differ.IgnoredDifferences = ignoredDifferences; + + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.Third' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.Third"), + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.Fourth' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.Fourth") + }; + + Assert.Equal(expected, differences); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InternalTypesAreIgnoredWhenSpecified(bool includeInternalSymbols) + { + string leftSyntax = @" + +namespace CompatTests +{ + public class First { } + internal class InternalType { } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First { } +} +"; + + ApiComparer differ = new(includeInternalSymbols); + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + if (!includeInternalSymbols) + { + Assert.Empty(differences); + } + else + { + CompatDifference[] expected = new[] + { + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.InternalType' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.InternalType") + }; + + Assert.Equal(expected, differences); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public static void MissingNestedTypeIsReported(bool includeInternalSymbols) + { + string leftSyntax = @" + +namespace CompatTests +{ + public class First + { + public class FirstNested + { + public class SecondNested { } + } + internal class InternalNested + { + internal class DoubleNested { } + } + } +} +"; + + string rightSyntax = @" +namespace CompatTests +{ + public class First + { + internal class InternalNested { } + } +} +"; + + ApiComparer differ = new(includeInternalSymbols); + IAssemblySymbol left = SymbolFactory.GetAssemblyFromSyntax(leftSyntax); + IAssemblySymbol right = SymbolFactory.GetAssemblyFromSyntax(rightSyntax); + IEnumerable differences = differ.GetDifferences(new[] { left }, new[] { right }); + + List expected = new() + { + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.First.FirstNested' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.First.FirstNested"), + }; + + if (includeInternalSymbols) + { + expected.Add( + new CompatDifference(DiagnosticIds.TypeMustExist, $"Type 'CompatTests.First.InternalNested.DoubleNested' exists on the left but not on the right", DifferenceType.Removed, "T:CompatTests.First.InternalNested.DoubleNested") + ); + } + + Assert.Equal(expected, differences); + } + } +} diff --git a/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/SymbolFactory.cs b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/SymbolFactory.cs new file mode 100644 index 000000000000..513efd53661c --- /dev/null +++ b/src/Tests/Microsoft.DotNet.ApiCompatibility.Tests/SymbolFactory.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Microsoft.DotNet.ApiCompatibility.Tests +{ + internal static class SymbolFactory + { + internal static IAssemblySymbol GetAssemblyFromSyntax(string syntax, bool enableNullable = false, bool includeDefaultReferences = false, [CallerMemberName] string assemblyName = "") + { + CSharpCompilation compilation = CreateCSharpCompilationFromSyntax(syntax, assemblyName, enableNullable, includeDefaultReferences); + return compilation.Assembly; + } + + internal static IAssemblySymbol GetAssemblyFromSyntaxWithReferences(string syntax, IEnumerable referencesSyntax, bool enableNullable = false, bool includeDefaultReferences = false, [CallerMemberName] string assemblyName = "") + { + CSharpCompilation compilation = CreateCSharpCompilationFromSyntax(syntax, assemblyName, enableNullable, includeDefaultReferences); + CSharpCompilation compilationWithReferences = CreateCSharpCompilationFromSyntax(referencesSyntax, $"{assemblyName}_reference", enableNullable, includeDefaultReferences); + + compilation = compilation.AddReferences(compilationWithReferences.ToMetadataReference()); + return compilation.Assembly; + } + + private static CSharpCompilation CreateCSharpCompilationFromSyntax(string syntax, string name, bool enableNullable, bool includeDefaultReferences) + { + CSharpCompilation compilation = CreateCSharpCompilation(name, enableNullable, includeDefaultReferences); + return compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(syntax)); + } + + private static CSharpCompilation CreateCSharpCompilationFromSyntax(IEnumerable syntax, string name, bool enableNullable, bool includeDefaultReferences) + { + CSharpCompilation compilation = CreateCSharpCompilation(name, enableNullable, includeDefaultReferences); + IEnumerable syntaxTrees = syntax.Select(s => CSharpSyntaxTree.ParseText(s)); + return compilation.AddSyntaxTrees(syntaxTrees); + } + + private static CSharpCompilation CreateCSharpCompilation(string name, bool enableNullable, bool includeDefaultReferences) + { + var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: enableNullable ? NullableContextOptions.Enable : NullableContextOptions.Disable); + + return CSharpCompilation.Create(name, options: compilationOptions, references: includeDefaultReferences ? DefaultReferences : null); + } + + private static IEnumerable DefaultReferences { get; } = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + }; + } +}