using System; using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using System.Linq; using System.Text; using Xunit; using Amazon.JSII.Analyzers.UnitTests.Helpers; namespace Amazon.JSII.Analyzers.UnitTests.Verifiers { /// /// Superclass of all Unit Tests for DiagnosticAnalyzers /// public abstract partial class DiagnosticVerifier { #region To be implemented by Test classes /// /// Get the CSharp analyzer being tested - to be implemented in non-abstract class /// protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer(); #endregion #region Verifier wrappers /// /// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source /// Note: input a DiagnosticResult for each Diagnostic expected /// /// A class in the form of a string to run the analyzer on /// DiagnosticResults that should appear after the analyzer is run on the source protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected) { VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected); } /// /// General method that gets a collection of actual diagnostics found in the source after the analyzer is run, /// then verifies each of them. /// /// An array of strings to create source documents from to run the analyzers on /// The language of the classes represented by the source strings /// The analyzer to be run on the source code /// DiagnosticResults that should appear after the analyzer is run on the sources private static void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected) { var diagnostics = GetSortedDiagnostics(sources, language, analyzer); VerifyDiagnosticResults(diagnostics, analyzer, expected); } #endregion #region Actual comparisons and verifications /// /// Checks each of the actual Diagnostics found and compares them with the corresponding DiagnosticResult in the array of expected results. /// Diagnostics are considered equal only if the DiagnosticResultLocation, Id, Severity, and Message of the DiagnosticResult match the actual diagnostic. /// /// The Diagnostics found by the compiler after running the analyzer on the source code /// The analyzer that was being run on the sources /// Diagnostic Results that should have appeared in the code private static void VerifyDiagnosticResults(Diagnostic[] actualResults, DiagnosticAnalyzer? analyzer, params DiagnosticResult[] expectedResults) { var expectedCount = expectedResults.Length; var actualCount = actualResults.Length; if (expectedCount != actualCount) { string diagnosticsOutput = actualResults.Any() ? FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE."; Assert.Fail($"Mismatch between number of diagnostics returned, expected \"{expectedCount}\" actual \"{actualCount}\"\r\n\r\nDiagnostics:\r\n{diagnosticsOutput}\r\n"); } for (var i = 0; i < expectedResults.Length; i++) { var actual = actualResults.ElementAt(i); var expected = expectedResults[i]; if (expected.Line == -1 && expected.Column == -1) { if (actual.Location != Location.None) { Assert.Fail($"Expected:\nA project diagnostic with No location\nActual:\n{FormatDiagnostics(analyzer, actual)}"); } } else { VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations[0]); var additionalLocations = actual.AdditionalLocations.ToArray(); if (additionalLocations.Length != expected.Locations.Count - 1) { Assert.Fail($"Expected {expected.Locations.Count - 1} additional locations but got {additionalLocations.Length} for Diagnostic:\r\n {FormatDiagnostics(analyzer, actual)}\r\n"); } for (int j = 0; j < additionalLocations.Length; ++j) { VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]); } } if (actual.Id != expected.Id) { Assert.Fail($"Expected diagnostic id to be \"{expected.Id}\" was \"{actual.Id}\"\r\n\r\nDiagnostic:\r\n {FormatDiagnostics(analyzer, actual)}\r\n"); } if (actual.Severity != expected.Severity) { Assert.Fail($"Expected diagnostic severity to be \"{expected.Severity}\" was \"{actual.Severity}\"\r\n\r\nDiagnostic:\r\n {FormatDiagnostics(analyzer, actual)}\r\n"); } if (actual.GetMessage(CultureInfo.InvariantCulture) != expected.Message) { Assert.Fail($"Expected diagnostic message to be \"{expected.Message}\" was \"{actual.GetMessage(CultureInfo.InvariantCulture)}\"\r\n\r\nDiagnostic:\r\n {FormatDiagnostics(analyzer, actual)}\r\n"); } } } /// /// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult. /// /// The analyzer that was being run on the sources /// The diagnostic that was found in the code /// The Location of the Diagnostic found in the code /// The DiagnosticResultLocation that should have been found private static void VerifyDiagnosticLocation(DiagnosticAnalyzer? analyzer, Diagnostic diagnostic, Location actual, DiagnosticResultLocation expected) { var actualSpan = actual.GetLineSpan(); Assert.True(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.", StringComparison.InvariantCulture) && expected.Path.Contains("Test.", StringComparison.InvariantCulture)), $"Expected diagnostic to be in file \"{expected.Path}\" was actually in file \"{actualSpan.Path}\"\r\n\r\nDiagnostic:\r\n {FormatDiagnostics(analyzer, diagnostic)}\r\n"); var actualLinePosition = actualSpan.StartLinePosition; // Only check line position if there is an actual line in the real diagnostic if (actualLinePosition.Line > 0) { if (actualLinePosition.Line + 1 != expected.Line) { Assert.Fail($"Expected diagnostic to be on line \"{expected.Line}\" was actually on line \"{actualLinePosition.Line + 1}\"\r\n\r\nDiagnostic:\r\n {FormatDiagnostics(analyzer, diagnostic)}\r\n"); } } // Only check column position if there is an actual column position in the real diagnostic if (actualLinePosition.Character > 0) { if (actualLinePosition.Character + 1 != expected.Column) { Assert.Fail($"Expected diagnostic to start at column \"{expected.Column}\" was actually at column \"{actualLinePosition.Character + 1}\"\r\n\r\nDiagnostic:\r\n {FormatDiagnostics(analyzer, diagnostic)}\r\n"); } } } #endregion #region Formatting Diagnostics /// /// Helper method to format a Diagnostic into an easily readable string /// /// The analyzer that this verifier tests /// The Diagnostics to be formatted /// The Diagnostics formatted as a string private static string FormatDiagnostics(DiagnosticAnalyzer? analyzer, params Diagnostic[] diagnostics) { var builder = new StringBuilder(); for (var i = 0; i < diagnostics.Length; ++i) { builder.AppendLine(CultureInfo.InvariantCulture, $"// {diagnostics[i]}"); var analyzerType = analyzer?.GetType(); if (analyzerType == null) { continue; } var rules = analyzer!.SupportedDiagnostics; foreach (var rule in rules) { if (rule == null || rule.Id != diagnostics[i].Id) continue; var location = diagnostics[i].Location; if (location == Location.None) { builder.AppendFormat(CultureInfo.InvariantCulture, "GetGlobalResult({0}.{1})", analyzerType.Name, rule.Id); } else { Assert.True(location.IsInSource, $"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n"); var fileIsCSharp = diagnostics[i].Location.SourceTree?.FilePath.EndsWith(".cs", StringComparison.InvariantCulture) ?? false; var resultMethodName = fileIsCSharp ? "GetCSharpResultAt" : "GetBasicResultAt"; var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition; builder.AppendFormat(CultureInfo.InvariantCulture, "{0}({1}, {2}, {3}.{4})", resultMethodName, linePosition.Line + 1, linePosition.Character + 1, analyzerType.Name, rule.Id); } if (i != diagnostics.Length - 1) { builder.Append(','); } builder.AppendLine(); break; } } return builder.ToString(); } #endregion } }