Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/mono/linker.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndy Gocke <angocke@microsoft.com>2020-10-27 20:02:20 +0300
committerGitHub <noreply@github.com>2020-10-27 20:02:20 +0300
commit57974c1f5790e6fb33f5fce161707be5cd86c4d3 (patch)
tree12c844675405451a9aa079d0d5a197c1a8161871 /test/ILLink.RoslynAnalyzer.Tests
parentefcf6819afac59bccf311fd2024c0ac156742eda (diff)
Add first roslyn analyzer (#1563)
This analyzer approximates the warnings produced by the linker around the RequiresUnreferencedCodeAttribute.
Diffstat (limited to 'test/ILLink.RoslynAnalyzer.Tests')
-rw-r--r--test/ILLink.RoslynAnalyzer.Tests/AnalyzerTests.cs32
-rw-r--r--test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj17
-rw-r--r--test/ILLink.RoslynAnalyzer.Tests/LinkerTestCases.cs33
-rw-r--r--test/ILLink.RoslynAnalyzer.Tests/TestCaseUtils.cs198
-rw-r--r--test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs741
-rw-r--r--test/ILLink.RoslynAnalyzer.Tests/Verifiers/CSharpVerifierHelper.cs37
6 files changed, 1058 insertions, 0 deletions
diff --git a/test/ILLink.RoslynAnalyzer.Tests/AnalyzerTests.cs b/test/ILLink.RoslynAnalyzer.Tests/AnalyzerTests.cs
new file mode 100644
index 000000000..04d87a854
--- /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 000000000..07aa8f3d7
--- /dev/null
+++ b/test/ILLink.RoslynAnalyzer.Tests/ILLink.RoslynAnalyzer.Tests.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <Nullable>enable</Nullable>
+ <LangVersion>latest</LangVersion>
+ <TargetFramework>net5.0</TargetFramework>
+ <DefineConstants>$(DefineConstants);ILLINK</DefineConstants>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="$(MicrosoftCodeAnalysisCSharpAnalyzerTestingXunitVersion)" />
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="$(MicrosoftCodeAnalysisCSharpWorkspacesVersion)" />
+ <ProjectReference Include="..\..\src\ILLink.RoslynAnalyzer\ILLink.RoslynAnalyzer.csproj" />
+ <ProjectReference Include="../Mono.Linker.Tests.Cases\Mono.Linker.Tests.Cases.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/test/ILLink.RoslynAnalyzer.Tests/LinkerTestCases.cs b/test/ILLink.RoslynAnalyzer.Tests/LinkerTestCases.cs
new file mode 100644
index 000000000..fe21ab18d
--- /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
+{
+ /// <summary>
+ /// Test cases stored in files
+ /// </summary>
+ public class LinkerTestCases : TestCaseUtils
+ {
+ [Theory]
+ [MemberData (nameof (GetTestData), parameters: nameof (RequiresCapability))]
+ public void RequiresCapability (MethodDeclarationSyntax m, List<AttributeSyntax> 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 000000000..8deb3f7d3
--- /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<object[]> GetTestData (string testSuiteName)
+ {
+ var testFile = File.ReadAllText (s_testFiles[testSuiteName][0]);
+
+ var root = CSharpSyntaxTree.ParseText (testFile).GetRoot ();
+
+ var attributes = root.DescendantNodes ()
+ .OfType<AttributeSyntax> ()
+ .Where (a => IsWellKnown (a));
+
+ var methodsXattributes = root.DescendantNodes ()
+ .OfType<MethodDeclarationSyntax> ()
+ .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<AttributeSyntax> attrs)
+ {
+ var comp = CSharpAnalyzerVerifier<RequiresUnreferencedCodeAnalyzer>.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 <diagId>: <location>:'
+ // 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<string, List<string>> s_testFiles = GetTestFilesByDirName ();
+
+ private static ImmutableDictionary<string, List<string>> GetTestFilesByDirName ()
+ {
+ var builder = ImmutableDictionary.CreateBuilder<string, List<string>> ();
+
+ 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<string> () { file };
+ builder[dirName] = sources;
+ }
+ }
+
+ return builder.ToImmutable ();
+ }
+
+ private static IEnumerable<string> 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/<config>/<tfm>
+ 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 000000000..91b28b1aa
--- /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<TAnalyzer>
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ {
+ /// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.Diagnostic()"/>
+ public static DiagnosticResult Diagnostic ()
+ => CSharpAnalyzerVerifier<TAnalyzer, XUnitVerifier>.Diagnostic ();
+
+ /// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.Diagnostic(string)"/>
+ public static DiagnosticResult Diagnostic (string diagnosticId)
+ => CSharpAnalyzerVerifier<TAnalyzer, XUnitVerifier>.Diagnostic (diagnosticId);
+
+ /// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.Diagnostic(DiagnosticDescriptor)"/>
+ public static DiagnosticResult Diagnostic (DiagnosticDescriptor descriptor)
+ => CSharpAnalyzerVerifier<TAnalyzer, XUnitVerifier>.Diagnostic (descriptor);
+
+ public static Task<CompilationWithAnalyzers> CreateCompilation (
+ string src,
+ (string, string)[]? globalAnalyzerOptions = null)
+ => CreateCompilation (CSharpSyntaxTree.ParseText (src), globalAnalyzerOptions);
+
+ public static async Task<CompilationWithAnalyzers> 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<AdditionalText>.Empty,
+ new SimpleAnalyzerOptions (globalAnalyzerOptions));
+
+ var compWithAnalyzerOptions = new CompilationWithAnalyzersOptions (
+ analyzerOptions,
+ (_1, _2, _3) => { },
+ concurrentAnalysis: true,
+ logAnalyzerExecutionTime: false);
+
+ var analyzers = ImmutableArray.Create<DiagnosticAnalyzer> (new TAnalyzer ());
+ return new CompilationWithAnalyzers (
+ comp,
+ analyzers,
+ compWithAnalyzerOptions);
+ }
+
+ /// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.VerifyAnalyzerAsync(string, DiagnosticResult[])"/>
+ public static async Task VerifyAnalyzerAsync (string src, params DiagnosticResult[] expected)
+ {
+ var diags = await (await CreateCompilation (src)).GetAllDiagnosticsAsync ();
+
+ var analyzers = ImmutableArray.Create<DiagnosticAnalyzer> (new TAnalyzer ());
+ VerifyDiagnosticResults (diags, analyzers, expected, DefaultVerifier);
+ }
+
+ private static IVerifier DefaultVerifier = new DefaultVerifier ();
+
+ /// <summary>
+ /// Gets the default full name of the first source file added for a test.
+ /// </summary>
+ private static string DefaultFilePath => "";
+
+ /// <summary>
+ /// Gets or sets the timeout to use when matching expected and actual diagnostics. The default value is 2
+ /// seconds.
+ /// </summary>
+ private static TimeSpan MatchDiagnosticsTimeout => TimeSpan.FromSeconds (2);
+
+
+ /// <summary>
+ /// Checks each of the actual <see cref="Diagnostic"/>s found and compares them with the corresponding
+ /// <see cref="DiagnosticResult"/> in the array of expected results. <see cref="Diagnostic"/>s are considered
+ /// equal only if the <see cref="DiagnosticResult.Spans"/>, <see cref="DiagnosticResult.Id"/>,
+ /// <see cref="DiagnosticResult.Severity"/>, and <see cref="DiagnosticResult.Message"/> of the
+ /// <see cref="DiagnosticResult"/> match the actual <see cref="Diagnostic"/>.
+ /// </summary>
+ /// <param name="actualResults">The <see cref="Diagnostic"/>s found by the compiler after running the analyzer
+ /// on the source code.</param>
+ /// <param name="analyzers">The analyzers that have been run on the sources.</param>
+ /// <param name="expectedResults">A collection of <see cref="DiagnosticResult"/>s describing the expected
+ /// diagnostics for the sources.</param>
+ /// <param name="verifier">The verifier to use for test assertions.</param>
+ internal static void VerifyDiagnosticResults (IEnumerable<Diagnostic> actualResults, ImmutableArray<DiagnosticAnalyzer> 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<DiagnosticAnalyzer> 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}";
+ }
+
+ /// <summary>
+ /// Helper method to <see cref="VerifyDiagnosticResults"/> that checks the location of a
+ /// <see cref="Diagnostic"/> and compares it with the location described by a
+ /// <see cref="FileLinePositionSpan"/>.
+ /// </summary>
+ /// <param name="analyzers">The analyzer that have been run on the sources.</param>
+ /// <param name="diagnostic">The diagnostic that was found in the code.</param>
+ /// <param name="expectedDiagnostic">The expected diagnostic.</param>
+ /// <param name="actual">The location of the diagnostic found in the code.</param>
+ /// <param name="expected">The <see cref="FileLinePositionSpan"/> describing the expected location of the
+ /// diagnostic.</param>
+ /// <param name="verifier">The verifier to use for test assertions.</param>
+ private static void VerifyDiagnosticLocation (ImmutableArray<DiagnosticAnalyzer> 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<DiagnosticAnalyzer> 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);
+ }
+
+ /// <summary>
+ /// Helper method to format a <see cref="Diagnostic"/> into an easily readable string.
+ /// </summary>
+ /// <param name="analyzers">The analyzers that this verifier tests.</param>
+ /// <param name="defaultFilePath">The default file path for diagnostics.</param>
+ /// <param name="diagnostics">A collection of <see cref="DiagnosticResult"/>s to be formatted.</param>
+ /// <returns>The <paramref name="diagnostics"/> formatted as a string.</returns>
+ private static string FormatDiagnostics (ImmutableArray<DiagnosticAnalyzer> 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})");
+ }
+ }
+ }
+
+
+ /// <summary>
+ /// Helper method to format a <see cref="Diagnostic"/> into an easily readable string.
+ /// </summary>
+ /// <param name="analyzers">The analyzers that this verifier tests.</param>
+ /// <param name="defaultFilePath">The default file path for diagnostics.</param>
+ /// <param name="diagnostics">A collection of <see cref="Diagnostic"/>s to be formatted.</param>
+ /// <returns>The <paramref name="diagnostics"/> formatted as a string.</returns>
+ private static string FormatDiagnostics (ImmutableArray<DiagnosticAnalyzer> 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})");
+ }
+ }
+
+ /// <summary>
+ /// Match actual diagnostics with expected diagnostics.
+ /// </summary>
+ /// <remarks>
+ /// <para>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.</para>
+ ///
+ /// <para>This method attempts to find a best matching of actual and expected diagnostics.</para>
+ /// </remarks>
+ /// <param name="actualResults">The actual diagnostics reported by analysis.</param>
+ /// <param name="expectedResults">The expected diagnostics.</param>
+ /// <returns>
+ /// <para>A collection of matched diagnostics, with the following characteristics:</para>
+ ///
+ /// <list type="bullet">
+ /// <item><description>Every element of <paramref name="actualResults"/> will appear exactly once as the first element of an item in the result.</description></item>
+ /// <item><description>Every element of <paramref name="expectedResults"/> will appear exactly once as the second element of an item in the result.</description></item>
+ /// <item><description>An item in the result which specifies both a <see cref="Diagnostic"/> and a <see cref="DiagnosticResult"/> indicates a matched pair, i.e. the actual and expected results are believed to refer to the same diagnostic.</description></item>
+ /// <item><description>An item in the result which specifies only a <see cref="Diagnostic"/> indicates an actual diagnostic for which no matching expected diagnostic was found.</description></item>
+ /// <item><description>An item in the result which specifies only a <see cref="DiagnosticResult"/> indicates an expected diagnostic for which no matching actual diagnostic was found.</description></item>
+ ///
+ /// <para>If no exact match is found (all actual diagnostics are matched to an expected diagnostic without
+ /// errors), this method is <em>allowed</em> to attempt fall-back matching using a strategy intended to minimize
+ /// the total number of mismatched pairs.</para>
+ /// </list>
+ /// </returns>
+ 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<string>.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<FileLinePositionSpan> additionalLineSpans, ImmutableArray<string> actualArguments, DiagnosticResult diagnosticResult, ImmutableArray<string> 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<FileLinePositionSpan> 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<string> actualArguments, DiagnosticResult expected, ImmutableArray<string> 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<MatchQuality>, IEquatable<MatchQuality>
+ {
+ 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<object?> GetArguments (Diagnostic diagnostic)
+ {
+ return (IReadOnlyList<object?>?) diagnostic.GetType ().GetProperty ("Arguments", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue (diagnostic)
+ ?? Array.Empty<object> ();
+ }
+
+ 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<string, string> (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<string, string>.Empty);
+
+ private readonly ImmutableDictionary<string, string> _dict;
+ public SimpleAnalyzerConfigOptions (ImmutableDictionary<string, string> 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<object?, bool> s_notNullTest = x => x is object;
+
+ public static DiagnosticResult[] ToOrderedArray (this IEnumerable<DiagnosticResult> 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<T> WhereNotNull<T> (this IEnumerable<T?> source)
+ where T : class
+ {
+ return source.Where<T?> (s_notNullTest)!;
+ }
+
+ public static T? SingleOrNull<T> (this IEnumerable<T> 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 000000000..d95b3e014
--- /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
+ {
+ /// <summary>
+ /// By default, the compiler reports diagnostics for nullable reference types at
+ /// <see cref="DiagnosticSeverity.Warning"/>, and the analyzer test framework defaults to only validating
+ /// diagnostics at <see cref="DiagnosticSeverity.Error"/>. This map contains all compiler diagnostic IDs
+ /// related to nullability mapped to <see cref="ReportDiagnostic.Error"/>, which is then used to enable all
+ /// of these warnings for default validation during analyzer and code fix tests.
+ /// </summary>
+ internal static ImmutableDictionary<string, ReportDiagnostic> NullableWarnings { get; } = GetNullableWarningsFromCompiler ();
+
+ private static ImmutableDictionary<string, ReportDiagnostic> 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;
+ }
+ }
+}