diff --git a/.editorconfig b/.editorconfig index 0c33dcb86480..19907117e95f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -123,6 +123,9 @@ dotnet_analyzer_diagnostic.category-reliability.severity = warning # CA1834: Use 'StringBuilder.Append(char)' dotnet_diagnostic.CA1834.severity = none +# RS2008 Ignore analyzer release tracking +dotnet_diagnostic.RS2008.severity = none + [external**] dotnet_analyzer_diagnostic.severity = none generated_code = true diff --git a/eng/Versions.props b/eng/Versions.props index fdbfbba4ce5f..e44cef3c62c3 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -22,6 +22,8 @@ 15.4.8 6.0.0-beta.20525.1 5.0.0-beta.20471.1 + 3.7.0 + 1.0.1-beta1.* diff --git a/illink.sln b/illink.sln index d26f2f3f7655..74a5b0227654 100644 --- a/illink.sln +++ b/illink.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30524.135 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mono.Linker", "src\linker\Mono.Linker.csproj", "{DD28E2B1-057B-4B4D-A04D-B2EBD9E76E46}" EndProject @@ -25,6 +25,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ref", "ref", "{03EB085F-3E2 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mono.Linker", "src\linker\ref\Mono.Linker.csproj", "{57BE47DF-DCDF-44EE-B77F-F8E8AD069076}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ILLink.RoslynAnalyzer", "src\ILLink.RoslynAnalyzer\ILLink.RoslynAnalyzer.csproj", "{F1A44A78-34EE-408B-8285-9A26F0E7D4F2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ILLink.RoslynAnalyzer.Tests", "test\ILLink.RoslynAnalyzer.Tests\ILLink.RoslynAnalyzer.Tests.csproj", "{90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -131,6 +135,30 @@ Global {57BE47DF-DCDF-44EE-B77F-F8E8AD069076}.Release|x64.Build.0 = Release|Any CPU {57BE47DF-DCDF-44EE-B77F-F8E8AD069076}.Release|x86.ActiveCfg = Release|Any CPU {57BE47DF-DCDF-44EE-B77F-F8E8AD069076}.Release|x86.Build.0 = Release|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Debug|x64.Build.0 = Debug|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Debug|x86.Build.0 = Debug|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Release|Any CPU.Build.0 = Release|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Release|x64.ActiveCfg = Release|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Release|x64.Build.0 = Release|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Release|x86.ActiveCfg = Release|Any CPU + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2}.Release|x86.Build.0 = Release|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Debug|x64.ActiveCfg = Debug|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Debug|x64.Build.0 = Debug|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Debug|x86.ActiveCfg = Debug|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Debug|x86.Build.0 = Debug|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Release|Any CPU.Build.0 = Release|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Release|x64.ActiveCfg = Release|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Release|x64.Build.0 = Release|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Release|x86.ActiveCfg = Release|Any CPU + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,6 +170,8 @@ Global {96182221-C5C4-436D-9BE0-EC499F9BAF17} = {AA0569FB-73E9-4B42-9A19-714BB1229DAE} {5A27FA80-0E28-4243-88DF-EC8A22C8BFD0} = {C2969923-7BAA-4FE4-853C-F670B0D3C6C8} {57BE47DF-DCDF-44EE-B77F-F8E8AD069076} = {03EB085F-3E2E-4A68-A7DF-951ADF59A0CC} + {F1A44A78-34EE-408B-8285-9A26F0E7D4F2} = {AA0569FB-73E9-4B42-9A19-714BB1229DAE} + {90D64CE4-C891-4B98-AF59-EE9B04BA1CBE} = {C2969923-7BAA-4FE4-853C-F670B0D3C6C8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E43A3901-42B0-48CA-BB36-5CD40A99A6EE} diff --git a/src/ILLink.RoslynAnalyzer/ILLink.RoslynAnalyzer.csproj b/src/ILLink.RoslynAnalyzer/ILLink.RoslynAnalyzer.csproj new file mode 100644 index 000000000000..fd79369e8496 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/ILLink.RoslynAnalyzer.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + 8 + enable + false + + + + + + + + diff --git a/src/ILLink.RoslynAnalyzer/OperationExtensions.cs b/src/ILLink.RoslynAnalyzer/OperationExtensions.cs new file mode 100644 index 000000000000..6498e5682daf --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/OperationExtensions.cs @@ -0,0 +1,251 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FlowAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace ILLink.RoslynAnalyzer +{ + // Copied from https://github.com/dotnet/roslyn/blob/9c6d864baca08d7572871701ab583cec18279426/src/Compilers/Core/Portable/Operations/OperationExtensions.cs + internal static partial class OperationExtensions + { + /// + /// Returns the for the given operation. + /// This extension can be removed once https://github.com/dotnet/roslyn/issues/25057 is implemented. + /// + public static ValueUsageInfo GetValueUsageInfo (this IOperation operation, ISymbol containingSymbol) + { + /* + | code | Read | Write | ReadableRef | WritableRef | NonReadWriteRef | + | x.Prop = 1 | | ✔️ | | | | + | x.Prop += 1 | ✔️ | ✔️ | | | | + | x.Prop++ | ✔️ | ✔️ | | | | + | Foo(x.Prop) | ✔️ | | | | | + | Foo(x.Prop), | | | ✔️ | | | + where void Foo(in T v) + | Foo(out x.Prop) | | | | ✔️ | | + | Foo(ref x.Prop) | | | ✔️ | ✔️ | | + | nameof(x) | | | | | ✔️ | ️ + | sizeof(x) | | | | | ✔️ | ️ + | typeof(x) | | | | | ✔️ | ️ + | out var x | | ✔️ | | | | ️ + | case X x: | | ✔️ | | | | ️ + | obj is X x | | ✔️ | | | | + | ref var x = | | | ✔️ | ✔️ | | + | ref readonly var x = | | | ✔️ | | | + + */ + if (operation is ILocalReferenceOperation localReference && + localReference.IsDeclaration && + !localReference.IsImplicit) // Workaround for https://github.com/dotnet/roslyn/issues/30753 + { + // Declaration expression is a definition (write) for the declared local. + return ValueUsageInfo.Write; + } else if (operation is IDeclarationPatternOperation) { + while (operation.Parent is IBinaryPatternOperation || + operation.Parent is INegatedPatternOperation || + operation.Parent is IRelationalPatternOperation) { + operation = operation.Parent; + } + + switch (operation.Parent) { + case IPatternCaseClauseOperation _: + // A declaration pattern within a pattern case clause is a + // write for the declared local. + // For example, 'x' is defined and assigned the value from 'obj' below: + // switch (obj) + // { + // case X x: + // + return ValueUsageInfo.Write; + + case IRecursivePatternOperation _: + // A declaration pattern within a recursive pattern is a + // write for the declared local. + // For example, 'x' is defined and assigned the value from 'obj' below: + // (obj) switch + // { + // (X x) => ... + // }; + // + return ValueUsageInfo.Write; + + case ISwitchExpressionArmOperation _: + // A declaration pattern within a switch expression arm is a + // write for the declared local. + // For example, 'x' is defined and assigned the value from 'obj' below: + // obj switch + // { + // X x => ... + // + return ValueUsageInfo.Write; + + case IIsPatternOperation _: + // A declaration pattern within an is pattern is a + // write for the declared local. + // For example, 'x' is defined and assigned the value from 'obj' below: + // if (obj is X x) + // + return ValueUsageInfo.Write; + + case IPropertySubpatternOperation _: + // A declaration pattern within a property sub-pattern is a + // write for the declared local. + // For example, 'x' is defined and assigned the value from 'obj.Property' below: + // if (obj is { Property : int x }) + // + return ValueUsageInfo.Write; + + default: + Debug.Fail ("Unhandled declaration pattern context"); + + // Conservatively assume read/write. + return ValueUsageInfo.ReadWrite; + } + } + + if (operation.Parent is IAssignmentOperation assignmentOperation && + assignmentOperation.Target == operation) { + return operation.Parent.IsAnyCompoundAssignment () + ? ValueUsageInfo.ReadWrite + : ValueUsageInfo.Write; + } else if (operation.Parent is IIncrementOrDecrementOperation) { + return ValueUsageInfo.ReadWrite; + } else if (operation.Parent is IParenthesizedOperation parenthesizedOperation) { + // Note: IParenthesizedOperation is specific to VB, where the parens cause a copy, so this cannot be classified as a write. + Debug.Assert (parenthesizedOperation.Language == LanguageNames.VisualBasic); + + return parenthesizedOperation.GetValueUsageInfo (containingSymbol) & + ~(ValueUsageInfo.Write | ValueUsageInfo.Reference); + } else if (operation.Parent is INameOfOperation || + operation.Parent is ITypeOfOperation || + operation.Parent is ISizeOfOperation) { + return ValueUsageInfo.Name; + } else if (operation.Parent is IArgumentOperation argumentOperation) { + switch (argumentOperation.Parameter.RefKind) { + case RefKind.RefReadOnly: + return ValueUsageInfo.ReadableReference; + + case RefKind.Out: + return ValueUsageInfo.WritableReference; + + case RefKind.Ref: + return ValueUsageInfo.ReadableWritableReference; + + default: + return ValueUsageInfo.Read; + } + } else if (operation.Parent is IReturnOperation returnOperation) { + return returnOperation.GetRefKind (containingSymbol) switch + { + RefKind.RefReadOnly => ValueUsageInfo.ReadableReference, + RefKind.Ref => ValueUsageInfo.ReadableWritableReference, + _ => ValueUsageInfo.Read, + }; + } else if (operation.Parent is IConditionalOperation conditionalOperation) { + if (operation == conditionalOperation.WhenTrue + || operation == conditionalOperation.WhenFalse) { + return GetValueUsageInfo (conditionalOperation, containingSymbol); + } else { + return ValueUsageInfo.Read; + } + } else if (operation.Parent is IReDimClauseOperation reDimClauseOperation && + reDimClauseOperation.Operand == operation) { + return (reDimClauseOperation.Parent as IReDimOperation)?.Preserve == true + ? ValueUsageInfo.ReadWrite + : ValueUsageInfo.Write; + } else if (operation.Parent is IDeclarationExpressionOperation declarationExpression) { + return declarationExpression.GetValueUsageInfo (containingSymbol); + } else if (operation.IsInLeftOfDeconstructionAssignment (out _)) { + return ValueUsageInfo.Write; + } else if (operation.Parent is IVariableInitializerOperation variableInitializerOperation) { + if (variableInitializerOperation.Parent is IVariableDeclaratorOperation variableDeclaratorOperation) { + switch (variableDeclaratorOperation.Symbol.RefKind) { + case RefKind.Ref: + return ValueUsageInfo.ReadableWritableReference; + + case RefKind.RefReadOnly: + return ValueUsageInfo.ReadableReference; + } + } + } + + return ValueUsageInfo.Read; + } + + public static RefKind GetRefKind (this IReturnOperation operation, ISymbol containingSymbol) + { + var containingMethod = TryGetContainingAnonymousFunctionOrLocalFunction (operation) ?? (containingSymbol as IMethodSymbol); + return containingMethod?.RefKind ?? RefKind.None; + } + + public static IMethodSymbol? TryGetContainingAnonymousFunctionOrLocalFunction (this IOperation? operation) + { + operation = operation?.Parent; + while (operation != null) { + switch (operation.Kind) { + case OperationKind.AnonymousFunction: + return ((IAnonymousFunctionOperation) operation).Symbol; + + case OperationKind.LocalFunction: + return ((ILocalFunctionOperation) operation).Symbol; + } + + operation = operation.Parent; + } + + return null; + } + + public static bool IsInLeftOfDeconstructionAssignment (this IOperation operation, out IDeconstructionAssignmentOperation? deconstructionAssignment) + { + deconstructionAssignment = null; + + var previousOperation = operation; + operation = operation.Parent; + + while (operation != null) { + switch (operation.Kind) { + case OperationKind.DeconstructionAssignment: + deconstructionAssignment = (IDeconstructionAssignmentOperation) operation; + return deconstructionAssignment.Target == previousOperation; + + case OperationKind.Tuple: + case OperationKind.Conversion: + case OperationKind.Parenthesized: + previousOperation = operation; + operation = operation.Parent; + continue; + + default: + return false; + } + } + + return false; + } + + /// + /// Retursn true if the given operation is a regular compound assignment, + /// i.e. such as a += b, + /// or a special null coalescing compoud assignment, i.e. + /// such as a ??= b. + /// + public static bool IsAnyCompoundAssignment (this IOperation operation) + { + switch (operation) { + case ICompoundAssignmentOperation _: + case ICoalesceAssignmentOperation _: + return true; + + default: + return false; + } + } + } +} diff --git a/src/ILLink.RoslynAnalyzer/RequiresUnreferencedCodeAnalyzer.cs b/src/ILLink.RoslynAnalyzer/RequiresUnreferencedCodeAnalyzer.cs new file mode 100644 index 000000000000..cdb23970e84d --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/RequiresUnreferencedCodeAnalyzer.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace ILLink.RoslynAnalyzer +{ + [DiagnosticAnalyzer (LanguageNames.CSharp)] + public class RequiresUnreferencedCodeAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "IL2026"; + + private static readonly LocalizableString s_title = new LocalizableResourceString ( + nameof (RequiresUnreferencedCodeAnalyzer) + "Title", + Resources.ResourceManager, + typeof (Resources)); + private static readonly LocalizableString s_messageFormat = new LocalizableResourceString ( + nameof (RequiresUnreferencedCodeAnalyzer) + "Message", + Resources.ResourceManager, + typeof (Resources)); + private const string s_category = "Trimming"; + + private static readonly DiagnosticDescriptor s_rule = new DiagnosticDescriptor ( + DiagnosticId, + s_title, + s_messageFormat, + s_category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create (s_rule); + + public override void Initialize (AnalysisContext context) + { + context.EnableConcurrentExecution (); + context.ConfigureGeneratedCodeAnalysis (GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterCompilationStartAction (context => { + var compilation = context.Compilation; + + context.RegisterOperationAction (operationContext => { + var call = (IInvocationOperation) operationContext.Operation; + CheckMethodOrCtorCall (operationContext, call.TargetMethod, call.Syntax.GetLocation ()); + }, OperationKind.Invocation); + + context.RegisterOperationAction (operationContext => { + var call = (IObjectCreationOperation) operationContext.Operation; + CheckMethodOrCtorCall (operationContext, call.Constructor, call.Syntax.GetLocation ()); + }, OperationKind.ObjectCreation); + + context.RegisterOperationAction (operationContext => { + var propAccess = (IPropertyReferenceOperation) operationContext.Operation; + var prop = propAccess.Property; + var usageInfo = propAccess.GetValueUsageInfo (prop); + if (usageInfo.HasFlag (ValueUsageInfo.Read) && prop.GetMethod != null) { + CheckMethodOrCtorCall ( + operationContext, + prop.GetMethod, + propAccess.Syntax.GetLocation ()); + } + if (usageInfo.HasFlag (ValueUsageInfo.Write) && prop.SetMethod != null) { + CheckMethodOrCtorCall ( + operationContext, + prop.SetMethod, + propAccess.Syntax.GetLocation ()); + } + }, OperationKind.PropertyReference); + + void CheckMethodOrCtorCall ( + OperationAnalysisContext operationContext, + IMethodSymbol method, + Location location) + { + var attributes = method.GetAttributes (); + + foreach (var attr in attributes) { + if (attr.AttributeClass is { } attrClass && + IsNamedType (attrClass, "System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute") && + attr.ConstructorArguments.Length == 1 && + attr.ConstructorArguments[0] is { Type: { SpecialType: SpecialType.System_String } } ctorArg) { + operationContext.ReportDiagnostic (Diagnostic.Create ( + s_rule, + location, + method, + (string) ctorArg.Value!)); + } + } + } + }); + } + + /// + /// Returns true if has the same name as + /// + internal static bool IsNamedType (INamedTypeSymbol type, string typeName) + { + var roSpan = typeName.AsSpan (); + INamespaceOrTypeSymbol? currentType = type; + while (roSpan.Length > 0) { + var dot = roSpan.LastIndexOf ('.'); + var currentName = dot < 0 ? roSpan : roSpan.Slice (dot + 1); + if (currentType is null || + !currentName.Equals (currentType.Name.AsSpan (), StringComparison.Ordinal)) { + return false; + } + currentType = (INamespaceOrTypeSymbol?) currentType.ContainingType ?? currentType.ContainingNamespace; + roSpan = roSpan.Slice (0, dot > 0 ? dot : 0); + } + + return true; + } + } +} diff --git a/src/ILLink.RoslynAnalyzer/Resources.resx b/src/ILLink.RoslynAnalyzer/Resources.resx new file mode 100644 index 000000000000..7b279fa4d9a6 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + RequiresUnreferencedCodeAnalyzer + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/ValueUsageInfo.cs b/src/ILLink.RoslynAnalyzer/ValueUsageInfo.cs new file mode 100644 index 000000000000..d7bb1a2771b8 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/ValueUsageInfo.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +// Copied from Roslyn +namespace ILLink.RoslynAnalyzer +{ + [Flags] + internal enum ValueUsageInfo + { + /// + /// Represents default value indicating no usage. + /// + None = 0x0, + + /// + /// Represents a value read. + /// For example, reading the value of a local/field/parameter. + /// + Read = 0x1, + + /// + /// Represents a value write. + /// For example, assigning a value to a local/field/parameter. + /// + Write = 0x2, + + /// + /// Represents a reference being taken for the symbol. + /// For example, passing an argument to an "in", "ref" or "out" parameter. + /// + Reference = 0x4, + + /// + /// Represents a name-only reference that neither reads nor writes the underlying value. + /// For example, 'nameof(x)' or reference to a symbol 'x' in a documentation comment + /// does not read or write the underlying value stored in 'x'. + /// + Name = 0x8, + + /// + /// Represents a value read and/or write. + /// For example, an increment or compound assignment operation. + /// + ReadWrite = Read | Write, + + /// + /// Represents a readable reference being taken to the value. + /// For example, passing an argument to an "in" or "ref readonly" parameter. + /// + ReadableReference = Read | Reference, + + /// + /// Represents a readable reference being taken to the value. + /// For example, passing an argument to an "out" parameter. + /// + WritableReference = Write | Reference, + + /// + /// Represents a value read or write. + /// For example, passing an argument to a "ref" parameter. + /// + ReadableWritableReference = Read | Write | Reference + } + + internal static class ValueUsageInfoExtensions + { + public static bool IsReadFrom (this ValueUsageInfo valueUsageInfo) + => (valueUsageInfo & ValueUsageInfo.Read) != 0; + + public static bool IsWrittenTo (this ValueUsageInfo valueUsageInfo) + => (valueUsageInfo & ValueUsageInfo.Write) != 0; + + public static bool IsNameOnly (this ValueUsageInfo valueUsageInfo) + => (valueUsageInfo & ValueUsageInfo.Name) != 0; + + public static bool IsReference (this ValueUsageInfo valueUsageInfo) + => (valueUsageInfo & ValueUsageInfo.Reference) != 0; + } +} diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.cs.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.cs.xlf new file mode 100644 index 000000000000..75a240d8e302 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.cs.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.de.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.de.xlf new file mode 100644 index 000000000000..c06e59183685 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.de.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.es.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.es.xlf new file mode 100644 index 000000000000..ba4f5b3917b8 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.es.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.fr.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.fr.xlf new file mode 100644 index 000000000000..ae016acd2695 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.fr.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.it.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.it.xlf new file mode 100644 index 000000000000..a197d1497e4e --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.it.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.ja.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.ja.xlf new file mode 100644 index 000000000000..c8d8a3de1ecb --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.ja.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.ko.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.ko.xlf new file mode 100644 index 000000000000..480613dd57c9 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.ko.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.pl.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.pl.xlf new file mode 100644 index 000000000000..4c39d8f02905 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.pl.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.pt-BR.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.pt-BR.xlf new file mode 100644 index 000000000000..a969de028011 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.pt-BR.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.ru.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.ru.xlf new file mode 100644 index 000000000000..91013bccf0e9 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.ru.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.tr.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.tr.xlf new file mode 100644 index 000000000000..94d2d2c9f5af --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.tr.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.zh-Hans.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.zh-Hans.xlf new file mode 100644 index 000000000000..7d95dcaf38db --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.zh-Hans.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/src/ILLink.RoslynAnalyzer/xlf/Resources.zh-Hant.xlf b/src/ILLink.RoslynAnalyzer/xlf/Resources.zh-Hant.xlf new file mode 100644 index 000000000000..8264c1e1df72 --- /dev/null +++ b/src/ILLink.RoslynAnalyzer/xlf/Resources.zh-Hant.xlf @@ -0,0 +1,17 @@ + + + + + + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + Calling '{0}' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. {1}. + + + + RequiresUnreferencedCodeAnalyzer + RequiresUnreferencedCodeAnalyzer + + + + + \ No newline at end of file diff --git a/test/ILLink.RoslynAnalyzer.Tests/AnalyzerTests.cs b/test/ILLink.RoslynAnalyzer.Tests/AnalyzerTests.cs new file mode 100644 index 000000000000..04d87a85419e --- /dev/null +++ b/test/ILLink.RoslynAnalyzer.Tests/AnalyzerTests.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; +using Xunit; +using VerifyCS = ILLink.RoslynAnalyzer.Tests.CSharpAnalyzerVerifier< + ILLink.RoslynAnalyzer.RequiresUnreferencedCodeAnalyzer>; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public class AnalyzerTests + { + [Fact] + public Task SimpleDiagnostic () + { + var src = @" +using System.Diagnostics.CodeAnalysis; + +class C +{ + [RequiresUnreferencedCodeAttribute(""message"")] + int M1() => 0; + int M2() => M1(); +}"; + return VerifyCS.VerifyAnalyzerAsync (src, + // (8,17): warning IL2026: Calling 'System.Int32 C::M1()' which has `RequiresUnreferencedCodeAttribute` can break functionality when trimming application code. message. + VerifyCS.Diagnostic ().WithSpan (8, 17, 8, 21).WithArguments ("C.M1()", "message") + ); + } + } +} diff --git a/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj b/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj new file mode 100644 index 000000000000..07aa8f3d710a --- /dev/null +++ b/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj @@ -0,0 +1,17 @@ + + + + enable + latest + net5.0 + $(DefineConstants);ILLINK + + + + + + + + + + diff --git a/test/ILLink.RoslynAnalyzer.Tests/LinkerTestCases.cs b/test/ILLink.RoslynAnalyzer.Tests/LinkerTestCases.cs new file mode 100644 index 000000000000..fe21ab18d3b3 --- /dev/null +++ b/test/ILLink.RoslynAnalyzer.Tests/LinkerTestCases.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + /// + /// Test cases stored in files + /// + public class LinkerTestCases : TestCaseUtils + { + [Theory] + [MemberData (nameof (GetTestData), parameters: nameof (RequiresCapability))] + public void RequiresCapability (MethodDeclarationSyntax m, List attrs) + { + switch (m.Identifier.ValueText) { + case "RequiresAndCallsOtherRequiresMethods": + case "TestRequiresWithMessageAndUrlOnMethod": + // Test failures because analyzer support is not complete + // Skip for now + return; + } + + RunTest (m, attrs); + } + } +} diff --git a/test/ILLink.RoslynAnalyzer.Tests/TestCaseUtils.cs b/test/ILLink.RoslynAnalyzer.Tests/TestCaseUtils.cs new file mode 100644 index 000000000000..8deb3f7d3f7f --- /dev/null +++ b/test/ILLink.RoslynAnalyzer.Tests/TestCaseUtils.cs @@ -0,0 +1,198 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public class TestCaseUtils + { + public static IEnumerable GetTestData (string testSuiteName) + { + var testFile = File.ReadAllText (s_testFiles[testSuiteName][0]); + + var root = CSharpSyntaxTree.ParseText (testFile).GetRoot (); + + var attributes = root.DescendantNodes () + .OfType () + .Where (a => IsWellKnown (a)); + + var methodsXattributes = root.DescendantNodes () + .OfType () + .Select (m => (m!, m.AttributeLists.SelectMany ( + al => al.Attributes.Where (a => IsWellKnown (a))) + .ToList ())) + .Where (mXattrs => mXattrs.Item2.Count > 0) + .Distinct () + .ToList (); + + foreach (var (m, attrs) in methodsXattributes) { + yield return new object[] { m, attrs }; + } + + static bool IsWellKnown (AttributeSyntax attr) + { + switch (attr.Name.ToString ()) { + case "LogContains": + case "LogDoesNotContain": + return true; + } + return false; + } + } + + internal static void RunTest (MethodDeclarationSyntax m, List attrs) + { + var comp = CSharpAnalyzerVerifier.CreateCompilation (m.SyntaxTree).Result; + var diags = comp.GetAnalyzerDiagnosticsAsync ().Result; + + var filtered = diags.Where (d => d.Location.SourceSpan.IntersectsWith (m.Span)) + .Select (d => d.GetMessage ()); + foreach (var attr in attrs) { + switch (attr.Name.ToString ()) { + case "LogContains": { + var arg = Assert.Single (attr.ArgumentList!.Arguments); + var text = GetStringFromExpr (arg.Expression); + // If the text starts with `warning IL...` then it probably follows the pattern + // 'warning : :' + // We don't want to repeat the location in the error message for the analyzer, so + // it's better to just trim here. We've already filtered by diagnostic location so + // the text location shouldn't matter + if (text.StartsWith ("warning IL")) { + var firstColon = text.IndexOf (": "); + if (firstColon > 0) { + var secondColon = text.IndexOf (": ", firstColon + 1); + if (secondColon > 0) { + text = text.Substring (secondColon + 2); + } + } + } + bool found = false; + foreach (var d in filtered) { + if (d.Contains (text)) { + found = true; + break; + } + } + if (!found) { + var diagStrings = string.Join (Environment.NewLine, filtered); + Assert.True (false, $@"Could not find text: +{text} +In diagnostics: +{diagStrings}"); + } + } + break; + case "LogDoesNotContain": { + var arg = Assert.Single (attr.ArgumentList!.Arguments); + var text = GetStringFromExpr (arg.Expression); + foreach (var d in filtered) { + Assert.DoesNotContain (text, d); + } + } + break; + } + } + + // Accepts string literal expressions or binary expressions concatenating strings + static string GetStringFromExpr (ExpressionSyntax expr) + { + switch (expr.Kind ()) { + case SyntaxKind.StringLiteralExpression: + var strLiteral = (LiteralExpressionSyntax) expr; + var token = strLiteral.Token; + Assert.Equal (SyntaxKind.StringLiteralToken, token.Kind ()); + return token.ValueText; + case SyntaxKind.AddExpression: + var addExpr = (BinaryExpressionSyntax) expr; + return GetStringFromExpr (addExpr.Left) + GetStringFromExpr (addExpr.Right); + default: + Assert.True (false, "Unsupported expr kind " + expr.Kind ()); + return null!; + } + } + } + + private static readonly ImmutableDictionary> s_testFiles = GetTestFilesByDirName (); + + private static ImmutableDictionary> GetTestFilesByDirName () + { + var builder = ImmutableDictionary.CreateBuilder> (); + + foreach (var file in GetTestFiles ()) { + var dirName = Path.GetFileName (Path.GetDirectoryName (file))!; + if (builder.TryGetValue (dirName, out var sources)) { + sources.Add (file); + } else { + sources = new List () { file }; + builder[dirName] = sources; + } + } + + return builder.ToImmutable (); + } + + private static IEnumerable GetTestFiles () + { + GetDirectoryPaths (out var rootSourceDir, out _); + + foreach (var subDir in Directory.EnumerateDirectories (rootSourceDir, "*", SearchOption.AllDirectories)) { + var subDirName = Path.GetFileName (subDir); + switch (subDirName) { + case "bin": + case "obj": + case "Properties": + case "Dependencies": + case "Individual": + continue; + } + + foreach (var file in Directory.EnumerateFiles (subDir, "*.cs")) { + yield return file; + } + } + } + + internal static void GetDirectoryPaths (out string rootSourceDirectory, out string testAssemblyPath, [CallerFilePath] string thisFile = null) + { + +#if DEBUG + var configDirectoryName = "Debug"; +#else + var configDirectoryName = "Release"; +#endif + +#if NET5_0 + var tfm = "net5.0"; +#elif NET471 + var tfm = "net471"; +#else + var tfm = ""; +#endif + +#if ILLINK + // Deterministic builds sanitize source paths, so CallerFilePathAttribute gives an incorrect path. + // Instead, get the testcase dll based on the working directory of the test runner. + + // working directory is artifacts/bin/Mono.Linker.Tests// + var artifactsBinDir = Path.Combine (Directory.GetCurrentDirectory (), "..", "..", ".."); + rootSourceDirectory = Path.GetFullPath (Path.Combine (artifactsBinDir, "..", "..", "test", "Mono.Linker.Tests.Cases")); + testAssemblyPath = Path.GetFullPath (Path.Combine (artifactsBinDir, "ILLink.RoslynAnalyzer.Tests", configDirectoryName, tfm)); +#else + var thisDirectory = Path.GetDirectoryName (thisFile); + rootSourceDirectory = Path.GetFullPath (Path.Combine (thisDirectory, "..", "..", "Mono.Linker.Tests.Cases")); + testCaseAssemblyPath = Path.GetFullPath (Path.Combine (rootSourceDirectory, "bin", configDirectoryName, tfm)); +#endif // ILLINK + } + } +} diff --git a/test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs new file mode 100644 index 000000000000..91b28b1aaed1 --- /dev/null +++ b/test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs @@ -0,0 +1,741 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public static partial class CSharpAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + { + /// + public static DiagnosticResult Diagnostic () + => CSharpAnalyzerVerifier.Diagnostic (); + + /// + public static DiagnosticResult Diagnostic (string diagnosticId) + => CSharpAnalyzerVerifier.Diagnostic (diagnosticId); + + /// + public static DiagnosticResult Diagnostic (DiagnosticDescriptor descriptor) + => CSharpAnalyzerVerifier.Diagnostic (descriptor); + + public static Task CreateCompilation ( + string src, + (string, string)[]? globalAnalyzerOptions = null) + => CreateCompilation (CSharpSyntaxTree.ParseText (src), globalAnalyzerOptions); + + public static async Task CreateCompilation ( + SyntaxTree src, + (string, string)[]? globalAnalyzerOptions = null) + { + TestCaseUtils.GetDirectoryPaths (out _, out string testAssemblyPath); + var expectationsPath = Path.Combine (testAssemblyPath, "Mono.Linker.Tests.Cases.Expectations.dll"); + + var mdRef = MetadataReference.CreateFromFile (expectationsPath); + + var comp = CSharpCompilation.Create ( + assemblyName: Guid.NewGuid ().ToString ("N"), + syntaxTrees: new SyntaxTree[] { src }, + references: (await ReferenceAssemblies.Net.Net50.ResolveAsync (null, default)).Add (mdRef), + new CSharpCompilationOptions (OutputKind.DynamicallyLinkedLibrary)); + + var analyzerOptions = new AnalyzerOptions ( + ImmutableArray.Empty, + new SimpleAnalyzerOptions (globalAnalyzerOptions)); + + var compWithAnalyzerOptions = new CompilationWithAnalyzersOptions ( + analyzerOptions, + (_1, _2, _3) => { }, + concurrentAnalysis: true, + logAnalyzerExecutionTime: false); + + var analyzers = ImmutableArray.Create (new TAnalyzer ()); + return new CompilationWithAnalyzers ( + comp, + analyzers, + compWithAnalyzerOptions); + } + + /// + public static async Task VerifyAnalyzerAsync (string src, params DiagnosticResult[] expected) + { + var diags = await (await CreateCompilation (src)).GetAllDiagnosticsAsync (); + + var analyzers = ImmutableArray.Create (new TAnalyzer ()); + VerifyDiagnosticResults (diags, analyzers, expected, DefaultVerifier); + } + + private static IVerifier DefaultVerifier = new DefaultVerifier (); + + /// + /// Gets the default full name of the first source file added for a test. + /// + private static string DefaultFilePath => ""; + + /// + /// Gets or sets the timeout to use when matching expected and actual diagnostics. The default value is 2 + /// seconds. + /// + private static TimeSpan MatchDiagnosticsTimeout => TimeSpan.FromSeconds (2); + + + /// + /// Checks each of the actual s found and compares them with the corresponding + /// in the array of expected results. s are considered + /// equal only if the , , + /// , and of the + /// match the actual . + /// + /// The s found by the compiler after running the analyzer + /// on the source code. + /// The analyzers that have been run on the sources. + /// A collection of s describing the expected + /// diagnostics for the sources. + /// The verifier to use for test assertions. + internal static void VerifyDiagnosticResults (IEnumerable actualResults, ImmutableArray analyzers, DiagnosticResult[] expectedResults, IVerifier verifier) + { + var matchedDiagnostics = MatchDiagnostics (actualResults.ToArray (), expectedResults); + verifier.Equal (actualResults.Count (), matchedDiagnostics.Count (x => x.actual is object), $"{nameof (MatchDiagnostics)} failed to include all actual diagnostics in the result"); + verifier.Equal (expectedResults.Length, matchedDiagnostics.Count (x => x.expected is object), $"{nameof (MatchDiagnostics)} failed to include all expected diagnostics in the result"); + + actualResults = matchedDiagnostics.Select (x => x.actual).WhereNotNull (); + expectedResults = matchedDiagnostics.Where (x => x.expected is object).Select (x => x.expected.GetValueOrDefault ()).ToArray (); + + var expectedCount = expectedResults.Length; + var actualCount = actualResults.Count (); + + var diagnosticsOutput = actualResults.Any () ? FormatDiagnostics (analyzers, DefaultFilePath, actualResults.ToArray ()) : " NONE."; + var message = $"Mismatch between number of diagnostics returned, expected \"{expectedCount}\" actual \"{actualCount}\"\r\n\r\nDiagnostics:\r\n{diagnosticsOutput}\r\n"; + verifier.Equal (expectedCount, actualCount, message); + + for (var i = 0; i < expectedResults.Length; i++) { + var actual = actualResults.ElementAt (i); + var expected = expectedResults[i]; + + if (!expected.HasLocation) { + message = FormatVerifierMessage (analyzers, actual, expected, "Expected a project diagnostic with no location:"); + verifier.Equal (Location.None, actual.Location, message); + } else { + VerifyDiagnosticLocation (analyzers, actual, expected, actual.Location, expected.Spans[0], verifier); + if (!expected.Options.HasFlag (DiagnosticOptions.IgnoreAdditionalLocations)) { + var additionalLocations = actual.AdditionalLocations.ToArray (); + + message = FormatVerifierMessage (analyzers, actual, expected, $"Expected {expected.Spans.Length - 1} additional locations but got {additionalLocations.Length} for Diagnostic:"); + verifier.Equal (expected.Spans.Length - 1, additionalLocations.Length, message); + + for (var j = 0; j < additionalLocations.Length; ++j) { + VerifyDiagnosticLocation (analyzers, actual, expected, additionalLocations[j], expected.Spans[j + 1], verifier); + } + } + } + + message = FormatVerifierMessage (analyzers, actual, expected, $"Expected diagnostic id to be \"{expected.Id}\" was \"{actual.Id}\""); + verifier.Equal (expected.Id, actual.Id, message); + + if (!expected.Options.HasFlag (DiagnosticOptions.IgnoreSeverity)) { + message = FormatVerifierMessage (analyzers, actual, expected, $"Expected diagnostic severity to be \"{expected.Severity}\" was \"{actual.Severity}\""); + verifier.Equal (expected.Severity, actual.Severity, message); + } + + if (expected.Message != null) { + message = FormatVerifierMessage (analyzers, actual, expected, $"Expected diagnostic message to be \"{expected.Message}\" was \"{actual.GetMessage ()}\""); + verifier.Equal (expected.Message, actual.GetMessage (), message); + } else if (expected.MessageArguments?.Length > 0) { + message = FormatVerifierMessage (analyzers, actual, expected, $"Expected diagnostic message arguments to match"); + verifier.SequenceEqual ( + expected.MessageArguments.Select (argument => argument?.ToString () ?? string.Empty), + GetArguments (actual).Select (argument => argument?.ToString () ?? string.Empty), + StringComparer.Ordinal, + message); + } + } + } + + internal static string FormatVerifierMessage (ImmutableArray analyzers, Diagnostic actual, DiagnosticResult expected, string message) + { + return $"{message}{Environment.NewLine}" + + $"{Environment.NewLine}" + + $"Expected diagnostic:{Environment.NewLine}" + + $" {FormatDiagnostics (analyzers, DefaultFilePath, expected)}{Environment.NewLine}" + + $"Actual diagnostic:{Environment.NewLine}" + + $" {FormatDiagnostics (analyzers, DefaultFilePath, actual)}{Environment.NewLine}"; + } + + /// + /// Helper method to that checks the location of a + /// and compares it with the location described by a + /// . + /// + /// The analyzer that have been run on the sources. + /// The diagnostic that was found in the code. + /// The expected diagnostic. + /// The location of the diagnostic found in the code. + /// The describing the expected location of the + /// diagnostic. + /// The verifier to use for test assertions. + private static void VerifyDiagnosticLocation (ImmutableArray analyzers, Diagnostic diagnostic, DiagnosticResult expectedDiagnostic, Location actual, DiagnosticLocation expected, IVerifier verifier) + { + var actualSpan = actual.GetLineSpan (); + + var assert = actualSpan.Path == expected.Span.Path || (actualSpan.Path?.Contains ("Test0.") == true && expected.Span.Path.Contains ("Test.")); + + var message = FormatVerifierMessage (analyzers, diagnostic, expectedDiagnostic, $"Expected diagnostic to be in file \"{expected.Span.Path}\" was actually in file \"{actualSpan.Path}\""); + verifier.True (assert, message); + + VerifyLinePosition (analyzers, diagnostic, expectedDiagnostic, actualSpan.StartLinePosition, expected.Span.StartLinePosition, "start", verifier); + if (!expected.Options.HasFlag (DiagnosticLocationOptions.IgnoreLength)) { + VerifyLinePosition (analyzers, diagnostic, expectedDiagnostic, actualSpan.EndLinePosition, expected.Span.EndLinePosition, "end", verifier); + } + } + + private static void VerifyLinePosition (ImmutableArray analyzers, Diagnostic diagnostic, DiagnosticResult expectedDiagnostic, LinePosition actualLinePosition, LinePosition expectedLinePosition, string positionText, IVerifier verifier) + { + var message = FormatVerifierMessage (analyzers, diagnostic, expectedDiagnostic, $"Expected diagnostic to {positionText} on line \"{expectedLinePosition.Line + 1}\" was actually on line \"{actualLinePosition.Line + 1}\""); + verifier.Equal ( + expectedLinePosition.Line, + actualLinePosition.Line, + message); + + message = FormatVerifierMessage (analyzers, diagnostic, expectedDiagnostic, $"Expected diagnostic to {positionText} at column \"{expectedLinePosition.Character + 1}\" was actually at column \"{actualLinePosition.Character + 1}\""); + verifier.Equal ( + expectedLinePosition.Character, + actualLinePosition.Character, + message); + } + + /// + /// Helper method to format a into an easily readable string. + /// + /// The analyzers that this verifier tests. + /// The default file path for diagnostics. + /// A collection of s to be formatted. + /// The formatted as a string. + private static string FormatDiagnostics (ImmutableArray analyzers, string defaultFilePath, params DiagnosticResult[] diagnostics) + { + var builder = new StringBuilder (); + for (var i = 0; i < diagnostics.Length; ++i) { + var diagnosticsId = diagnostics[i].Id; + + builder.Append ("// ").AppendLine (diagnostics[i].ToString ()); + + var applicableAnalyzer = analyzers.FirstOrDefault (a => a.SupportedDiagnostics.Any (dd => dd.Id == diagnosticsId)); + if (applicableAnalyzer != null) { + var analyzerType = applicableAnalyzer.GetType (); + var rule = diagnostics[i].HasLocation && applicableAnalyzer.SupportedDiagnostics.Length == 1 ? string.Empty : $"{analyzerType.Name}.{diagnosticsId}"; + + if (!diagnostics[i].HasLocation) { + builder.Append ($"new DiagnosticResult({rule})"); + } else { + builder.Append ($"VerifyCS.Diagnostic({rule})"); + } + } else { + builder.Append ( + diagnostics[i].Severity switch + { + DiagnosticSeverity.Error => $"{nameof (DiagnosticResult)}.{nameof (DiagnosticResult.CompilerError)}(\"{diagnostics[i].Id}\")", + DiagnosticSeverity.Warning => $"{nameof (DiagnosticResult)}.{nameof (DiagnosticResult.CompilerWarning)}(\"{diagnostics[i].Id}\")", + var severity => $"new {nameof (DiagnosticResult)}(\"{diagnostics[i].Id}\", {nameof (DiagnosticSeverity)}.{severity})", + }); + } + + if (!diagnostics[i].HasLocation) { + // No additional location data needed + } else { + foreach (var span in diagnostics[i].Spans) { + AppendLocation (span); + if (diagnostics[i].Options.HasFlag (DiagnosticOptions.IgnoreAdditionalLocations)) { + break; + } + } + } + + var arguments = diagnostics[i].MessageArguments; + if (arguments?.Length > 0) { + builder.Append ($".{nameof (DiagnosticResult.WithArguments)}("); + builder.Append (string.Join (", ", arguments.Select (a => "\"" + a?.ToString () + "\""))); + builder.Append (")"); + } + + builder.AppendLine (","); + } + + return builder.ToString (); + + // Local functions + void AppendLocation (DiagnosticLocation location) + { + var pathString = location.Span.Path == defaultFilePath ? string.Empty : $"\"{location.Span.Path}\", "; + var linePosition = location.Span.StartLinePosition; + + if (location.Options.HasFlag (DiagnosticLocationOptions.IgnoreLength)) { + builder.Append ($".WithLocation({pathString}{linePosition.Line + 1}, {linePosition.Character + 1})"); + } else { + var endLinePosition = location.Span.EndLinePosition; + builder.Append ($".WithSpan({pathString}{linePosition.Line + 1}, {linePosition.Character + 1}, {endLinePosition.Line + 1}, {endLinePosition.Character + 1})"); + } + } + } + + + /// + /// Helper method to format a into an easily readable string. + /// + /// The analyzers that this verifier tests. + /// The default file path for diagnostics. + /// A collection of s to be formatted. + /// The formatted as a string. + private static string FormatDiagnostics (ImmutableArray analyzers, string defaultFilePath, params Diagnostic[] diagnostics) + { + var builder = new StringBuilder (); + for (var i = 0; i < diagnostics.Length; ++i) { + var diagnosticsId = diagnostics[i].Id; + var location = diagnostics[i].Location; + + builder.Append ("// ").AppendLine (diagnostics[i].ToString ()); + + var applicableAnalyzer = analyzers.FirstOrDefault (a => a.SupportedDiagnostics.Any (dd => dd.Id == diagnosticsId)); + if (applicableAnalyzer != null) { + var analyzerType = applicableAnalyzer.GetType (); + var rule = location != Location.None && location.IsInSource && applicableAnalyzer.SupportedDiagnostics.Length == 1 ? string.Empty : $"{analyzerType.Name}.{diagnosticsId}"; + + if (location == Location.None || !location.IsInSource) { + builder.Append ($"new DiagnosticResult({rule})"); + } else { + var resultMethodName = location.SourceTree!.FilePath.EndsWith (".cs") ? "VerifyCS.Diagnostic" : "VerifyVB.Diagnostic"; + builder.Append ($"{resultMethodName}({rule})"); + } + } else { + builder.Append ( + diagnostics[i].Severity switch + { + DiagnosticSeverity.Error => $"{nameof (DiagnosticResult)}.{nameof (DiagnosticResult.CompilerError)}(\"{diagnostics[i].Id}\")", + DiagnosticSeverity.Warning => $"{nameof (DiagnosticResult)}.{nameof (DiagnosticResult.CompilerWarning)}(\"{diagnostics[i].Id}\")", + var severity => $"new {nameof (DiagnosticResult)}(\"{diagnostics[i].Id}\", {nameof (DiagnosticSeverity)}.{severity})", + }); + } + + if (location == Location.None) { + // No additional location data needed + } else { + AppendLocation (diagnostics[i].Location); + foreach (var additionalLocation in diagnostics[i].AdditionalLocations) { + AppendLocation (additionalLocation); + } + } + + var arguments = GetArguments (diagnostics[i]); + if (arguments.Count > 0) { + builder.Append ($".{nameof (DiagnosticResult.WithArguments)}("); + builder.Append (string.Join (", ", arguments.Select (a => "\"" + a?.ToString () + "\""))); + builder.Append (")"); + } + + builder.AppendLine (","); + } + + return builder.ToString (); + + // Local functions + void AppendLocation (Location location) + { + var lineSpan = location.GetLineSpan (); + var pathString = location.IsInSource && lineSpan.Path == defaultFilePath ? string.Empty : $"\"{lineSpan.Path}\", "; + var linePosition = lineSpan.StartLinePosition; + var endLinePosition = lineSpan.EndLinePosition; + builder.Append ($".WithSpan({pathString}{linePosition.Line + 1}, {linePosition.Character + 1}, {endLinePosition.Line + 1}, {endLinePosition.Character + 1})"); + } + } + + /// + /// Match actual diagnostics with expected diagnostics. + /// + /// + /// While each actual diagnostic contains complete information about the diagnostic (location, severity, + /// message, etc.), the expected diagnostics sometimes contain partial information. It is therefore possible for + /// an expected diagnostic to match more than one actual diagnostic, while another expected diagnostic with more + /// complete information only matches a single specific actual diagnostic. + /// + /// This method attempts to find a best matching of actual and expected diagnostics. + /// + /// The actual diagnostics reported by analysis. + /// The expected diagnostics. + /// + /// A collection of matched diagnostics, with the following characteristics: + /// + /// + /// Every element of will appear exactly once as the first element of an item in the result. + /// Every element of will appear exactly once as the second element of an item in the result. + /// An item in the result which specifies both a and a indicates a matched pair, i.e. the actual and expected results are believed to refer to the same diagnostic. + /// An item in the result which specifies only a indicates an actual diagnostic for which no matching expected diagnostic was found. + /// An item in the result which specifies only a indicates an expected diagnostic for which no matching actual diagnostic was found. + /// + /// If no exact match is found (all actual diagnostics are matched to an expected diagnostic without + /// errors), this method is allowed to attempt fall-back matching using a strategy intended to minimize + /// the total number of mismatched pairs. + /// + /// + private static ImmutableArray<(Diagnostic? actual, DiagnosticResult? expected)> MatchDiagnostics (Diagnostic[] actualResults, DiagnosticResult[] expectedResults) + { + var actualIds = actualResults.Select (result => result.Id).ToImmutableArray (); + var actualResultLocations = actualResults.Select (result => (location: result.Location.GetLineSpan (), additionalLocations: result.AdditionalLocations.Select (location => location.GetLineSpan ()).ToImmutableArray ())).ToImmutableArray (); + var actualArguments = actualResults.Select (actual => GetArguments (actual).Select (argument => argument?.ToString () ?? string.Empty).ToImmutableArray ()).ToImmutableArray (); + + expectedResults = expectedResults.ToOrderedArray (); + var expectedArguments = expectedResults.Select (expected => expected.MessageArguments?.Select (argument => argument?.ToString () ?? string.Empty).ToImmutableArray () ?? ImmutableArray.Empty).ToImmutableArray (); + + // Initialize the best match to a trivial result where everything is unmatched. This will be updated if/when + // better matches are found. + var bestMatchCount = MatchQuality.RemainingUnmatched (actualResults.Length + expectedResults.Length); + var bestMatch = actualResults.Select (result => ((Diagnostic?) result, default (DiagnosticResult?))).Concat (expectedResults.Select (result => (default (Diagnostic?), (DiagnosticResult?) result))).ToImmutableArray (); + + var builder = ImmutableArray.CreateBuilder<(Diagnostic? actual, DiagnosticResult? expected)> (); + var usedExpected = new bool[expectedResults.Length]; + + // The recursive match algorithm is not optimized, so use a timeout to ensure it completes in a reasonable + // time if a correct match isn't found. + using var cancellationTokenSource = new CancellationTokenSource (MatchDiagnosticsTimeout); + + try { + _ = RecursiveMatch (0, actualResults.Length, 0, expectedArguments.Length, MatchQuality.Full, usedExpected); + } catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) { + // Continue with the best match we have + } + + return bestMatch; + + // Match items using recursive backtracking. Returns the distance the best match under this path is from an + // ideal result of 0 (1:1 matching of actual and expected results). Currently the distance is calculated as + // the sum of the match values: + // + // * Fully-matched items have a value of MatchQuality.Full. + // * Partially-matched items have a value between MatchQuality.Full and MatchQuality.None (exclusive). + // * Fully-unmatched items have a value of MatchQuality.None. + MatchQuality RecursiveMatch (int firstActualIndex, int remainingActualItems, int firstExpectedIndex, int remainingExpectedItems, MatchQuality unmatchedActualResults, bool[] usedExpected) + { + var matchedOnEntry = actualResults.Length - remainingActualItems; + var bestPossibleUnmatchedExpected = MatchQuality.RemainingUnmatched (Math.Abs (remainingActualItems - remainingExpectedItems)); + var bestPossible = unmatchedActualResults + bestPossibleUnmatchedExpected; + + if (firstActualIndex == actualResults.Length) { + // We reached the end of the actual diagnostics. Any remaning unmatched expected diagnostics should + // be added to the end. If this path produced a better result than the best known path so far, + // update the best match to this one. + var totalUnmatched = unmatchedActualResults + MatchQuality.RemainingUnmatched (remainingExpectedItems); + + // Avoid manipulating the builder if we know the current path is no better than the previous best. + if (totalUnmatched < bestMatchCount) { + var addedCount = 0; + + // Add the remaining unmatched expected diagnostics + for (var i = firstExpectedIndex; i < expectedResults.Length; i++) { + if (!usedExpected[i]) { + addedCount++; + builder.Add ((null, (DiagnosticResult?) expectedResults[i])); + } + } + + bestMatchCount = totalUnmatched; + bestMatch = builder.ToImmutable (); + + for (var i = 0; i < addedCount; i++) { + builder.RemoveAt (builder.Count - 1); + } + } + + return totalUnmatched; + } + + cancellationTokenSource.Token.ThrowIfCancellationRequested (); + + var currentBest = unmatchedActualResults + MatchQuality.RemainingUnmatched (remainingActualItems + remainingExpectedItems); + for (var i = firstExpectedIndex; i < expectedResults.Length; i++) { + if (usedExpected[i]) { + continue; + } + + var (lineSpan, additionalLineSpans) = actualResultLocations[firstActualIndex]; + var matchValue = GetMatchValue (actualResults[firstActualIndex], actualIds[firstActualIndex], lineSpan, additionalLineSpans, actualArguments[firstActualIndex], expectedResults[i], expectedArguments[i]); + if (matchValue == MatchQuality.None) { + continue; + } + + try { + usedExpected[i] = true; + builder.Add ((actualResults[firstActualIndex], expectedResults[i])); + var bestResultWithCurrentMatch = RecursiveMatch (firstActualIndex + 1, remainingActualItems - 1, i == firstExpectedIndex ? firstExpectedIndex + 1 : firstExpectedIndex, remainingExpectedItems - 1, unmatchedActualResults + matchValue, usedExpected); + currentBest = Min (bestResultWithCurrentMatch, currentBest); + if (currentBest == bestPossible) { + // Return immediately if we know the current actual result cannot be paired with a different + // expected result to produce a better match. + return bestPossible; + } + } finally { + usedExpected[i] = false; + builder.RemoveAt (builder.Count - 1); + } + } + + if (currentBest > unmatchedActualResults) { + // We might be able to improve the results by leaving the current actual diagnostic unmatched + try { + builder.Add ((actualResults[firstActualIndex], null)); + var bestResultWithCurrentUnmatched = RecursiveMatch (firstActualIndex + 1, remainingActualItems - 1, firstExpectedIndex, remainingExpectedItems, unmatchedActualResults + MatchQuality.None, usedExpected); + return Min (bestResultWithCurrentUnmatched, currentBest); + } finally { + builder.RemoveAt (builder.Count - 1); + } + } + + Debug.Assert (currentBest == unmatchedActualResults, $"Assertion failure: {currentBest} == {unmatchedActualResults}"); + return currentBest; + } + + static MatchQuality Min (MatchQuality val1, MatchQuality val2) + => val2 < val1 ? val2 : val1; + + static MatchQuality GetMatchValue (Diagnostic diagnostic, string diagnosticId, FileLinePositionSpan lineSpan, ImmutableArray additionalLineSpans, ImmutableArray actualArguments, DiagnosticResult diagnosticResult, ImmutableArray expectedArguments) + { + // A full match automatically gets the value MatchQuality.Full. A partial match gets a "point" for each + // of the following elements: + // + // 1. Diagnostic span start + // 2. Diagnostic span end + // 3. Diagnostic ID + // + // A partial match starts at MatchQuality.None, with a point deduction for each of the above matching + // items. + var isLocationMatch = IsLocationMatch (diagnostic, lineSpan, additionalLineSpans, diagnosticResult, out var matchSpanStart, out var matchSpanEnd); + var isIdMatch = diagnosticId == diagnosticResult.Id; + if (isLocationMatch + && isIdMatch + && IsSeverityMatch (diagnostic, diagnosticResult) + && IsMessageMatch (diagnostic, actualArguments, diagnosticResult, expectedArguments)) { + return MatchQuality.Full; + } + + var points = (matchSpanStart ? 1 : 0) + (matchSpanEnd ? 1 : 0) + (isIdMatch ? 1 : 0); + if (points == 0) { + return MatchQuality.None; + } + + return new MatchQuality (4 - points); + } + + static bool IsLocationMatch (Diagnostic diagnostic, FileLinePositionSpan lineSpan, ImmutableArray additionalLineSpans, DiagnosticResult diagnosticResult, out bool matchSpanStart, out bool matchSpanEnd) + { + if (!diagnosticResult.HasLocation) { + matchSpanStart = false; + matchSpanEnd = false; + return Equals (Location.None, diagnostic.Location); + } else { + if (!IsLocationMatch2 (diagnostic.Location, lineSpan, diagnosticResult.Spans[0], out matchSpanStart, out matchSpanEnd)) { + return false; + } + + if (diagnosticResult.Options.HasFlag (DiagnosticOptions.IgnoreAdditionalLocations)) { + return true; + } + + var additionalLocations = diagnostic.AdditionalLocations.ToArray (); + if (additionalLocations.Length != diagnosticResult.Spans.Length - 1) { + // Number of additional locations does not match expected result + return false; + } + + for (var i = 0; i < additionalLocations.Length; i++) { + if (!IsLocationMatch2 (additionalLocations[i], additionalLineSpans[i], diagnosticResult.Spans[i + 1], out _, out _)) { + return false; + } + } + + return true; + } + } + + static bool IsLocationMatch2 (Location actual, FileLinePositionSpan actualSpan, DiagnosticLocation expected, out bool matchSpanStart, out bool matchSpanEnd) + { + matchSpanStart = actualSpan.StartLinePosition == expected.Span.StartLinePosition; + matchSpanEnd = expected.Options.HasFlag (DiagnosticLocationOptions.IgnoreLength) + || actualSpan.EndLinePosition == expected.Span.EndLinePosition; + + var assert = actualSpan.Path == expected.Span.Path || (actualSpan.Path?.Contains ("Test0.") == true && expected.Span.Path.Contains ("Test.")); + if (!assert) { + // Expected diagnostic to be in file "{expected.Span.Path}" was actually in file "{actualSpan.Path}" + return false; + } + + if (!matchSpanStart || !matchSpanEnd) { + return false; + } + + return true; + } + + static bool IsSeverityMatch (Diagnostic actual, DiagnosticResult expected) + { + if (expected.Options.HasFlag (DiagnosticOptions.IgnoreSeverity)) { + return true; + } + + return actual.Severity == expected.Severity; + } + + static bool IsMessageMatch (Diagnostic actual, ImmutableArray actualArguments, DiagnosticResult expected, ImmutableArray expectedArguments) + { + if (expected.Message is null) { + if (expected.MessageArguments?.Length > 0) { + return actualArguments.SequenceEqual (expectedArguments); + } + + return true; + } + + return string.Equals (expected.Message, actual.GetMessage ()); + } + } + + + internal readonly struct MatchQuality : IComparable, IEquatable + { + public static readonly MatchQuality Full = new MatchQuality (0); + public static readonly MatchQuality None = new MatchQuality (4); + + private readonly int _value; + + public MatchQuality (int value) + { + if (value < 0) { + throw new ArgumentOutOfRangeException (nameof (value)); + } + + _value = value; + } + + public static MatchQuality operator + (MatchQuality left, MatchQuality right) + => new MatchQuality (left._value + right._value); + + public static MatchQuality operator - (MatchQuality left, MatchQuality right) + => new MatchQuality (left._value - right._value); + + public static bool operator == (MatchQuality left, MatchQuality right) + => left.Equals (right); + + public static bool operator != (MatchQuality left, MatchQuality right) + => !left.Equals (right); + + public static bool operator < (MatchQuality left, MatchQuality right) + => left._value < right._value; + + public static bool operator <= (MatchQuality left, MatchQuality right) + => left._value <= right._value; + + public static bool operator > (MatchQuality left, MatchQuality right) + => left._value > right._value; + + public static bool operator >= (MatchQuality left, MatchQuality right) + => left._value >= right._value; + + public static MatchQuality RemainingUnmatched (int count) + => new MatchQuality (None._value * count); + + public int CompareTo (MatchQuality other) + => _value.CompareTo (other._value); + + public override bool Equals (object? obj) + => obj is MatchQuality quality && Equals (quality); + + public bool Equals (MatchQuality other) + => _value == other._value; + + public override int GetHashCode () + => _value; + } + + internal static IReadOnlyList GetArguments (Diagnostic diagnostic) + { + return (IReadOnlyList?) diagnostic.GetType ().GetProperty ("Arguments", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue (diagnostic) + ?? Array.Empty (); + } + + class SimpleAnalyzerOptions : AnalyzerConfigOptionsProvider + { + public SimpleAnalyzerOptions ((string, string)[]? globalOptions) + { + globalOptions ??= Array.Empty<(string, string)> (); + GlobalOptions = new SimpleAnalyzerConfigOptions (ImmutableDictionary.CreateRange ( + StringComparer.OrdinalIgnoreCase, + globalOptions.Select (x => new KeyValuePair (x.Item1, x.Item2)))); + } + + public override AnalyzerConfigOptions GlobalOptions { get; } + + public override AnalyzerConfigOptions GetOptions (SyntaxTree tree) + => SimpleAnalyzerConfigOptions.Empty; + + public override AnalyzerConfigOptions GetOptions (AdditionalText textFile) + => SimpleAnalyzerConfigOptions.Empty; + + class SimpleAnalyzerConfigOptions : AnalyzerConfigOptions + { + public static readonly SimpleAnalyzerConfigOptions Empty = new SimpleAnalyzerConfigOptions (ImmutableDictionary.Empty); + + private readonly ImmutableDictionary _dict; + public SimpleAnalyzerConfigOptions (ImmutableDictionary dict) + { + _dict = dict; + } + + // Suppress warning about missing nullable attributes +#pragma warning disable 8765 + public override bool TryGetValue (string key, out string? value) + => _dict.TryGetValue (key, out value); +#pragma warning restore 8765 + } + } + + + } + + internal static class IEnumerableExtensions + { + private static readonly Func s_notNullTest = x => x is object; + + public static DiagnosticResult[] ToOrderedArray (this IEnumerable diagnosticResults) + { + return diagnosticResults + .OrderBy (diagnosticResult => diagnosticResult.Spans.FirstOrDefault ().Span.Path, StringComparer.Ordinal) + .ThenBy (diagnosticResult => diagnosticResult.Spans.FirstOrDefault ().Span.Span.Start.Line) + .ThenBy (diagnosticResult => diagnosticResult.Spans.FirstOrDefault ().Span.Span.Start.Character) + .ThenBy (diagnosticResult => diagnosticResult.Spans.FirstOrDefault ().Span.Span.End.Line) + .ThenBy (diagnosticResult => diagnosticResult.Spans.FirstOrDefault ().Span.Span.End.Character) + .ThenBy (diagnosticResult => diagnosticResult.Id, StringComparer.Ordinal) + .ToArray (); + } + + internal static IEnumerable WhereNotNull (this IEnumerable source) + where T : class + { + return source.Where (s_notNullTest)!; + } + + public static T? SingleOrNull (this IEnumerable source) + where T : struct + { + return source.Select (value => (T?) value).SingleOrDefault (); + } + } +} diff --git a/test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpVerifierHelper.cs b/test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpVerifierHelper.cs new file mode 100644 index 000000000000..d95b3e01492c --- /dev/null +++ b/test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpVerifierHelper.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ILLink.RoslynAnalyzer.Tests +{ + internal static class CSharpVerifierHelper + { + /// + /// By default, the compiler reports diagnostics for nullable reference types at + /// , and the analyzer test framework defaults to only validating + /// diagnostics at . This map contains all compiler diagnostic IDs + /// related to nullability mapped to , which is then used to enable all + /// of these warnings for default validation during analyzer and code fix tests. + /// + internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler (); + + private static ImmutableDictionary GetNullableWarningsFromCompiler () + { + string[] args = { "/warnaserror:nullable" }; + var commandLineArguments = CSharpCommandLineParser.Default.Parse (args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory); + var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + + // Workaround for https://github.com/dotnet/roslyn/issues/41610 + nullableWarnings = nullableWarnings + .SetItem ("CS8632", ReportDiagnostic.Error) + .SetItem ("CS8669", ReportDiagnostic.Error); + + return nullableWarnings; + } + } +}