* Start on API analyzers * Finish GuildAccessAnalyzer * Update build script (will this do?) * Correct slashes * Extrapolate DerivesFromModuleBase() to an extension method * Quick refactoring * Add doc filetags/2.0.0-beta
| @@ -1,6 +1,6 @@ | |||
| Microsoft Visual Studio Solution File, Format Version 12.00 | |||
| # Visual Studio 15 | |||
| VisualStudioVersion = 15.0.27004.2009 | |||
| VisualStudioVersion = 15.0.27130.0 | |||
| MinimumVisualStudioVersion = 10.0.40219.1 | |||
| Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Core", "src\Discord.Net.Core\Discord.Net.Core.csproj", "{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}" | |||
| EndProject | |||
| @@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Tests", "test\D | |||
| EndProject | |||
| Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Webhook", "src\Discord.Net.Webhook\Discord.Net.Webhook.csproj", "{9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}" | |||
| EndProject | |||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Analyzers", "src\Discord.Net.Analyzers\Discord.Net.Analyzers.csproj", "{BBA8E7FB-C834-40DC-822F-B112CB7F0140}" | |||
| EndProject | |||
| Global | |||
| GlobalSection(SolutionConfigurationPlatforms) = preSolution | |||
| Debug|Any CPU = Debug|Any CPU | |||
| @@ -116,6 +118,18 @@ Global | |||
| {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x64.Build.0 = Release|Any CPU | |||
| {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x86.ActiveCfg = Release|Any CPU | |||
| {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x86.Build.0 = Release|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|Any CPU.Build.0 = Debug|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x64.ActiveCfg = Debug|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x64.Build.0 = Debug|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x86.ActiveCfg = Debug|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Debug|x86.Build.0 = Debug|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|Any CPU.ActiveCfg = Release|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|Any CPU.Build.0 = Release|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x64.ActiveCfg = Release|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x64.Build.0 = Release|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x86.ActiveCfg = Release|Any CPU | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140}.Release|x86.Build.0 = Release|Any CPU | |||
| EndGlobalSection | |||
| GlobalSection(SolutionProperties) = preSolution | |||
| HideSolutionNode = FALSE | |||
| @@ -126,6 +140,7 @@ Global | |||
| {688FD1D8-7F01-4539-B2E9-F473C5D699C7} = {288C363D-A636-4EAE-9AC1-4698B641B26E} | |||
| {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} = {B0657AAE-DCC5-4FBF-8E5D-1FB578CF3012} | |||
| {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} | |||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} | |||
| EndGlobalSection | |||
| GlobalSection(ExtensibilityGlobals) = postSolution | |||
| SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} | |||
| @@ -29,6 +29,7 @@ after_build: | |||
| - ps: dotnet pack "src\Discord.Net.Commands\Discord.Net.Commands.csproj" -c "Release" -o "../../artifacts" --no-build /p:BuildNumber="$Env:BUILD" /p:IsTagBuild="$Env:APPVEYOR_REPO_TAG" | |||
| - ps: dotnet pack "src\Discord.Net.Webhook\Discord.Net.Webhook.csproj" -c "Release" -o "../../artifacts" --no-build /p:BuildNumber="$Env:BUILD" /p:IsTagBuild="$Env:APPVEYOR_REPO_TAG" | |||
| - ps: dotnet pack "src\Discord.Net.Providers.WS4Net\Discord.Net.Providers.WS4Net.csproj" -c "Release" -o "../../artifacts" --no-build /p:BuildNumber="$Env:BUILD" /p:IsTagBuild="$Env:APPVEYOR_REPO_TAG" | |||
| - ps: dotnet pack "src\Discord.Net.Analyzers\Discord.Net.Analyzers.csproj" -c "Release" -o "../../artifacts" --no-build /p:BuildNumber="$Env:BUILD" /p:IsTagBuild="$Env:APPVEYOR_REPO_TAG" | |||
| - ps: >- | |||
| if ($Env:APPVEYOR_REPO_TAG -eq "true") { | |||
| nuget pack src\Discord.Net\Discord.Net.nuspec -OutputDirectory "artifacts" -properties suffix="" | |||
| @@ -0,0 +1,15 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Import Project="../../Discord.Net.targets" /> | |||
| <PropertyGroup> | |||
| <AssemblyName>Discord.Net.Analyzers</AssemblyName> | |||
| <RootNamespace>Discord.Analyzers</RootNamespace> | |||
| <Description>A Discord.Net extension adding support for design-time analysis of the API usage.</Description> | |||
| <TargetFramework>netstandard1.3</TargetFramework> | |||
| </PropertyGroup> | |||
| <ItemGroup> | |||
| <PackageReference Include="Microsoft.CodeAnalysis" Version="2.6.0" /> | |||
| </ItemGroup> | |||
| <ItemGroup> | |||
| <ProjectReference Include="..\Discord.Net.Commands\Discord.Net.Commands.csproj" /> | |||
| </ItemGroup> | |||
| </Project> | |||
| @@ -0,0 +1,70 @@ | |||
| using System; | |||
| using System.Collections.Immutable; | |||
| using System.Linq; | |||
| using Microsoft.CodeAnalysis; | |||
| using Microsoft.CodeAnalysis.CSharp; | |||
| using Microsoft.CodeAnalysis.CSharp.Syntax; | |||
| using Microsoft.CodeAnalysis.Diagnostics; | |||
| using Discord.Commands; | |||
| namespace Discord.Analyzers | |||
| { | |||
| [DiagnosticAnalyzer(LanguageNames.CSharp)] | |||
| public sealed class GuildAccessAnalyzer : DiagnosticAnalyzer | |||
| { | |||
| private const string DiagnosticId = "DNET0001"; | |||
| private const string Title = "Limit command to Guild contexts."; | |||
| private const string MessageFormat = "Command method '{0}' is accessing 'Context.Guild' but is not restricted to Guild contexts."; | |||
| private const string Description = "Accessing 'Context.Guild' in a command without limiting the command to run only in guilds."; | |||
| private const string Category = "API Usage"; | |||
| private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description); | |||
| public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule); | |||
| public override void Initialize(AnalysisContext context) | |||
| { | |||
| context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); | |||
| } | |||
| private static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) | |||
| { | |||
| // Bail out if the accessed member isn't named 'Guild' | |||
| var memberAccessSymbol = context.SemanticModel.GetSymbolInfo(context.Node).Symbol; | |||
| if (memberAccessSymbol.Name != "Guild") | |||
| return; | |||
| // Bail out if it happens to be 'ContextType.Guild' in the '[RequireContext]' argument | |||
| if (context.Node.Parent is AttributeArgumentSyntax) | |||
| return; | |||
| // Bail out if the containing class doesn't derive from 'ModuleBase<T>' | |||
| var typeNode = context.Node.FirstAncestorOrSelf<TypeDeclarationSyntax>(); | |||
| var typeSymbol = context.SemanticModel.GetDeclaredSymbol(typeNode); | |||
| if (!typeSymbol.DerivesFromModuleBase()) | |||
| return; | |||
| // Bail out if the containing method isn't marked with '[Command]' | |||
| var methodNode = context.Node.FirstAncestorOrSelf<MethodDeclarationSyntax>(); | |||
| var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodNode); | |||
| var methodAttributes = methodSymbol.GetAttributes(); | |||
| if (!methodAttributes.Any(a => a.AttributeClass.Name == nameof(CommandAttribute))) | |||
| return; | |||
| // Is the '[RequireContext]' attribute not applied to either the | |||
| // method or the class, or its argument isn't 'ContextType.Guild'? | |||
| var ctxAttribute = methodAttributes.SingleOrDefault(_attributeDataPredicate) | |||
| ?? typeSymbol.GetAttributes().SingleOrDefault(_attributeDataPredicate); | |||
| if (ctxAttribute == null || ctxAttribute.ConstructorArguments.Any(arg => !arg.Value.Equals((int)ContextType.Guild))) | |||
| { | |||
| // Report the diagnostic | |||
| var diagnostic = Diagnostic.Create(Rule, context.Node.GetLocation(), methodSymbol.Name); | |||
| context.ReportDiagnostic(diagnostic); | |||
| } | |||
| } | |||
| private static readonly Func<AttributeData, bool> _attributeDataPredicate = | |||
| (a => a.AttributeClass.Name == nameof(RequireContextAttribute)); | |||
| } | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| using System; | |||
| using Microsoft.CodeAnalysis; | |||
| using Discord.Commands; | |||
| namespace Discord.Analyzers | |||
| { | |||
| internal static class SymbolExtensions | |||
| { | |||
| private static readonly string _moduleBaseName = typeof(ModuleBase<>).Name; | |||
| public static bool DerivesFromModuleBase(this ITypeSymbol symbol) | |||
| { | |||
| for (var bType = symbol.BaseType; bType != null; bType = bType.BaseType) | |||
| { | |||
| if (bType.MetadataName == _moduleBaseName) | |||
| return true; | |||
| } | |||
| return false; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,30 @@ | |||
| # DNET0001 | |||
| <table> | |||
| <tr> | |||
| <td>TypeName</td> | |||
| <td>GuildAccessAnalyzer</td> | |||
| </tr> | |||
| <tr> | |||
| <td>CheckId</td> | |||
| <td>DNET0001</td> | |||
| </tr> | |||
| <tr> | |||
| <td>Category</td> | |||
| <td>API Usage</td> | |||
| </tr> | |||
| </table> | |||
| ## Cause | |||
| A method identified as a command is accessing `Context.Guild` without the requisite precondition. | |||
| ## Rule description | |||
| The value of `Context.Guild` is `null` if a command is invoked in a DM channel. Attempting to access | |||
| guild properties in such a case will result in a `NullReferenceException` at runtime. | |||
| This exception is entirely avoidable by using the library's provided preconditions. | |||
| ## How to fix violations | |||
| Add the precondition `[RequireContext(ContextType.Guild)]` to the command or module class. | |||
| @@ -0,0 +1,30 @@ | |||
| using System.Linq; | |||
| using System.Reflection; | |||
| using Microsoft.DotNet.PlatformAbstractions; | |||
| using Microsoft.Extensions.DependencyModel; | |||
| namespace System | |||
| { | |||
| /// <summary> Polyfill of the AppDomain class from full framework. </summary> | |||
| internal class AppDomain | |||
| { | |||
| public static AppDomain CurrentDomain { get; private set; } | |||
| private AppDomain() | |||
| { | |||
| } | |||
| static AppDomain() | |||
| { | |||
| CurrentDomain = new AppDomain(); | |||
| } | |||
| public Assembly[] GetAssemblies() | |||
| { | |||
| var rid = RuntimeEnvironment.GetRuntimeIdentifier(); | |||
| var ass = DependencyContext.Default.GetRuntimeAssemblyNames(rid); | |||
| return ass.Select(xan => Assembly.Load(xan)).ToArray(); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,111 @@ | |||
| using System; | |||
| using System.Collections.Generic; | |||
| using System.Text; | |||
| using System.Linq; | |||
| using System.Threading.Tasks; | |||
| using Microsoft.CodeAnalysis; | |||
| using Microsoft.CodeAnalysis.Diagnostics; | |||
| using Discord.Analyzers; | |||
| using TestHelper; | |||
| using Xunit; | |||
| namespace Discord | |||
| { | |||
| public partial class AnalyserTests | |||
| { | |||
| public class GuildAccessTests : DiagnosticVerifier | |||
| { | |||
| [Fact] | |||
| public void VerifyDiagnosticWhenLackingRequireContext() | |||
| { | |||
| string source = @"using System; | |||
| using System.Threading.Tasks; | |||
| using Discord.Commands; | |||
| namespace Test | |||
| { | |||
| public class TestModule : ModuleBase<ICommandContext> | |||
| { | |||
| [Command(""test"")] | |||
| public Task TestCmd() => ReplyAsync(Context.Guild.Name); | |||
| } | |||
| }"; | |||
| var expected = new DiagnosticResult() | |||
| { | |||
| Id = "DNET0001", | |||
| Locations = new[] { new DiagnosticResultLocation("Test0.cs", line: 10, column: 45) }, | |||
| Message = "Command method 'TestCmd' is accessing 'Context.Guild' but is not restricted to Guild contexts.", | |||
| Severity = DiagnosticSeverity.Warning | |||
| }; | |||
| VerifyCSharpDiagnostic(source, expected); | |||
| } | |||
| [Fact] | |||
| public void VerifyDiagnosticWhenWrongRequireContext() | |||
| { | |||
| string source = @"using System; | |||
| using System.Threading.Tasks; | |||
| using Discord.Commands; | |||
| namespace Test | |||
| { | |||
| public class TestModule : ModuleBase<ICommandContext> | |||
| { | |||
| [Command(""test""), RequireContext(ContextType.Group)] | |||
| public Task TestCmd() => ReplyAsync(Context.Guild.Name); | |||
| } | |||
| }"; | |||
| var expected = new DiagnosticResult() | |||
| { | |||
| Id = "DNET0001", | |||
| Locations = new[] { new DiagnosticResultLocation("Test0.cs", line: 10, column: 45) }, | |||
| Message = "Command method 'TestCmd' is accessing 'Context.Guild' but is not restricted to Guild contexts.", | |||
| Severity = DiagnosticSeverity.Warning | |||
| }; | |||
| VerifyCSharpDiagnostic(source, expected); | |||
| } | |||
| [Fact] | |||
| public void VerifyNoDiagnosticWhenRequireContextOnMethod() | |||
| { | |||
| string source = @"using System; | |||
| using System.Threading.Tasks; | |||
| using Discord.Commands; | |||
| namespace Test | |||
| { | |||
| public class TestModule : ModuleBase<ICommandContext> | |||
| { | |||
| [Command(""test""), RequireContext(ContextType.Guild)] | |||
| public Task TestCmd() => ReplyAsync(Context.Guild.Name); | |||
| } | |||
| }"; | |||
| VerifyCSharpDiagnostic(source, Array.Empty<DiagnosticResult>()); | |||
| } | |||
| [Fact] | |||
| public void VerifyNoDiagnosticWhenRequireContextOnClass() | |||
| { | |||
| string source = @"using System; | |||
| using System.Threading.Tasks; | |||
| using Discord.Commands; | |||
| namespace Test | |||
| { | |||
| [RequireContext(ContextType.Guild)] | |||
| public class TestModule : ModuleBase<ICommandContext> | |||
| { | |||
| [Command(""test"")] | |||
| public Task TestCmd() => ReplyAsync(Context.Guild.Name); | |||
| } | |||
| }"; | |||
| VerifyCSharpDiagnostic(source, Array.Empty<DiagnosticResult>()); | |||
| } | |||
| protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() | |||
| => new GuildAccessAnalyzer(); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,85 @@ | |||
| using Microsoft.CodeAnalysis; | |||
| using Microsoft.CodeAnalysis.CodeActions; | |||
| using Microsoft.CodeAnalysis.Formatting; | |||
| using Microsoft.CodeAnalysis.Simplification; | |||
| using System.Collections.Generic; | |||
| using System.Linq; | |||
| using System.Threading; | |||
| namespace TestHelper | |||
| { | |||
| /// <summary> | |||
| /// Diagnostic Producer class with extra methods dealing with applying codefixes | |||
| /// All methods are static | |||
| /// </summary> | |||
| public abstract partial class CodeFixVerifier : DiagnosticVerifier | |||
| { | |||
| /// <summary> | |||
| /// Apply the inputted CodeAction to the inputted document. | |||
| /// Meant to be used to apply codefixes. | |||
| /// </summary> | |||
| /// <param name="document">The Document to apply the fix on</param> | |||
| /// <param name="codeAction">A CodeAction that will be applied to the Document.</param> | |||
| /// <returns>A Document with the changes from the CodeAction</returns> | |||
| private static Document ApplyFix(Document document, CodeAction codeAction) | |||
| { | |||
| var operations = codeAction.GetOperationsAsync(CancellationToken.None).Result; | |||
| var solution = operations.OfType<ApplyChangesOperation>().Single().ChangedSolution; | |||
| return solution.GetDocument(document.Id); | |||
| } | |||
| /// <summary> | |||
| /// Compare two collections of Diagnostics,and return a list of any new diagnostics that appear only in the second collection. | |||
| /// Note: Considers Diagnostics to be the same if they have the same Ids. In the case of multiple diagnostics with the same Id in a row, | |||
| /// this method may not necessarily return the new one. | |||
| /// </summary> | |||
| /// <param name="diagnostics">The Diagnostics that existed in the code before the CodeFix was applied</param> | |||
| /// <param name="newDiagnostics">The Diagnostics that exist in the code after the CodeFix was applied</param> | |||
| /// <returns>A list of Diagnostics that only surfaced in the code after the CodeFix was applied</returns> | |||
| private static IEnumerable<Diagnostic> GetNewDiagnostics(IEnumerable<Diagnostic> diagnostics, IEnumerable<Diagnostic> newDiagnostics) | |||
| { | |||
| var oldArray = diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray(); | |||
| var newArray = newDiagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray(); | |||
| int oldIndex = 0; | |||
| int newIndex = 0; | |||
| while (newIndex < newArray.Length) | |||
| { | |||
| if (oldIndex < oldArray.Length && oldArray[oldIndex].Id == newArray[newIndex].Id) | |||
| { | |||
| ++oldIndex; | |||
| ++newIndex; | |||
| } | |||
| else | |||
| { | |||
| yield return newArray[newIndex++]; | |||
| } | |||
| } | |||
| } | |||
| /// <summary> | |||
| /// Get the existing compiler diagnostics on the inputted document. | |||
| /// </summary> | |||
| /// <param name="document">The Document to run the compiler diagnostic analyzers on</param> | |||
| /// <returns>The compiler diagnostics that were found in the code</returns> | |||
| private static IEnumerable<Diagnostic> GetCompilerDiagnostics(Document document) | |||
| { | |||
| return document.GetSemanticModelAsync().Result.GetDiagnostics(); | |||
| } | |||
| /// <summary> | |||
| /// Given a document, turn it into a string based on the syntax root | |||
| /// </summary> | |||
| /// <param name="document">The Document to be converted to a string</param> | |||
| /// <returns>A string containing the syntax of the Document after formatting</returns> | |||
| private static string GetStringFromDocument(Document document) | |||
| { | |||
| var simplifiedDoc = Simplifier.ReduceAsync(document, Simplifier.Annotation).Result; | |||
| var root = simplifiedDoc.GetSyntaxRootAsync().Result; | |||
| root = Formatter.Format(root, Formatter.Annotation, simplifiedDoc.Project.Solution.Workspace); | |||
| return root.GetText().ToString(); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,87 @@ | |||
| using Microsoft.CodeAnalysis; | |||
| using System; | |||
| namespace TestHelper | |||
| { | |||
| /// <summary> | |||
| /// Location where the diagnostic appears, as determined by path, line number, and column number. | |||
| /// </summary> | |||
| public struct DiagnosticResultLocation | |||
| { | |||
| public DiagnosticResultLocation(string path, int line, int column) | |||
| { | |||
| if (line < -1) | |||
| { | |||
| throw new ArgumentOutOfRangeException(nameof(line), "line must be >= -1"); | |||
| } | |||
| if (column < -1) | |||
| { | |||
| throw new ArgumentOutOfRangeException(nameof(column), "column must be >= -1"); | |||
| } | |||
| this.Path = path; | |||
| this.Line = line; | |||
| this.Column = column; | |||
| } | |||
| public string Path { get; } | |||
| public int Line { get; } | |||
| public int Column { get; } | |||
| } | |||
| /// <summary> | |||
| /// Struct that stores information about a Diagnostic appearing in a source | |||
| /// </summary> | |||
| public struct DiagnosticResult | |||
| { | |||
| private DiagnosticResultLocation[] locations; | |||
| public DiagnosticResultLocation[] Locations | |||
| { | |||
| get | |||
| { | |||
| if (this.locations == null) | |||
| { | |||
| this.locations = new DiagnosticResultLocation[] { }; | |||
| } | |||
| return this.locations; | |||
| } | |||
| set | |||
| { | |||
| this.locations = value; | |||
| } | |||
| } | |||
| public DiagnosticSeverity Severity { get; set; } | |||
| public string Id { get; set; } | |||
| public string Message { get; set; } | |||
| public string Path | |||
| { | |||
| get | |||
| { | |||
| return this.Locations.Length > 0 ? this.Locations[0].Path : ""; | |||
| } | |||
| } | |||
| public int Line | |||
| { | |||
| get | |||
| { | |||
| return this.Locations.Length > 0 ? this.Locations[0].Line : -1; | |||
| } | |||
| } | |||
| public int Column | |||
| { | |||
| get | |||
| { | |||
| return this.Locations.Length > 0 ? this.Locations[0].Column : -1; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,207 @@ | |||
| using System; | |||
| using System.Collections.Generic; | |||
| using System.Collections.Immutable; | |||
| using System.Linq; | |||
| using System.Reflection; | |||
| using Microsoft.CodeAnalysis; | |||
| using Microsoft.CodeAnalysis.CSharp; | |||
| using Microsoft.CodeAnalysis.Diagnostics; | |||
| using Microsoft.CodeAnalysis.Text; | |||
| using Discord; | |||
| using Discord.Commands; | |||
| namespace TestHelper | |||
| { | |||
| /// <summary> | |||
| /// Class for turning strings into documents and getting the diagnostics on them | |||
| /// All methods are static | |||
| /// </summary> | |||
| public abstract partial class DiagnosticVerifier | |||
| { | |||
| private static readonly MetadataReference CorlibReference = MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location); | |||
| private static readonly MetadataReference SystemCoreReference = MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location); | |||
| private static readonly MetadataReference CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).GetTypeInfo().Assembly.Location); | |||
| private static readonly MetadataReference CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).GetTypeInfo().Assembly.Location); | |||
| //private static readonly MetadataReference DiscordNetReference = MetadataReference.CreateFromFile(typeof(IDiscordClient).GetTypeInfo().Assembly.Location); | |||
| //private static readonly MetadataReference DiscordCommandsReference = MetadataReference.CreateFromFile(typeof(CommandAttribute).GetTypeInfo().Assembly.Location); | |||
| private static readonly Assembly DiscordCommandsAssembly = typeof(CommandAttribute).GetTypeInfo().Assembly; | |||
| internal static string DefaultFilePathPrefix = "Test"; | |||
| internal static string CSharpDefaultFileExt = "cs"; | |||
| internal static string VisualBasicDefaultExt = "vb"; | |||
| internal static string TestProjectName = "TestProject"; | |||
| #region Get Diagnostics | |||
| /// <summary> | |||
| /// Given classes in the form of strings, their language, and an IDiagnosticAnlayzer to apply to it, return the diagnostics found in the string after converting it to a document. | |||
| /// </summary> | |||
| /// <param name="sources">Classes in the form of strings</param> | |||
| /// <param name="language">The language the source classes are in</param> | |||
| /// <param name="analyzer">The analyzer to be run on the sources</param> | |||
| /// <returns>An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location</returns> | |||
| private static Diagnostic[] GetSortedDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer) | |||
| { | |||
| return GetSortedDiagnosticsFromDocuments(analyzer, GetDocuments(sources, language)); | |||
| } | |||
| /// <summary> | |||
| /// Given an analyzer and a document to apply it to, run the analyzer and gather an array of diagnostics found in it. | |||
| /// The returned diagnostics are then ordered by location in the source document. | |||
| /// </summary> | |||
| /// <param name="analyzer">The analyzer to run on the documents</param> | |||
| /// <param name="documents">The Documents that the analyzer will be run on</param> | |||
| /// <returns>An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location</returns> | |||
| protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents) | |||
| { | |||
| var projects = new HashSet<Project>(); | |||
| foreach (var document in documents) | |||
| { | |||
| projects.Add(document.Project); | |||
| } | |||
| var diagnostics = new List<Diagnostic>(); | |||
| foreach (var project in projects) | |||
| { | |||
| var compilationWithAnalyzers = project.GetCompilationAsync().Result.WithAnalyzers(ImmutableArray.Create(analyzer)); | |||
| var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result; | |||
| foreach (var diag in diags) | |||
| { | |||
| if (diag.Location == Location.None || diag.Location.IsInMetadata) | |||
| { | |||
| diagnostics.Add(diag); | |||
| } | |||
| else | |||
| { | |||
| for (int i = 0; i < documents.Length; i++) | |||
| { | |||
| var document = documents[i]; | |||
| var tree = document.GetSyntaxTreeAsync().Result; | |||
| if (tree == diag.Location.SourceTree) | |||
| { | |||
| diagnostics.Add(diag); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| var results = SortDiagnostics(diagnostics); | |||
| diagnostics.Clear(); | |||
| return results; | |||
| } | |||
| /// <summary> | |||
| /// Sort diagnostics by location in source document | |||
| /// </summary> | |||
| /// <param name="diagnostics">The list of Diagnostics to be sorted</param> | |||
| /// <returns>An IEnumerable containing the Diagnostics in order of Location</returns> | |||
| private static Diagnostic[] SortDiagnostics(IEnumerable<Diagnostic> diagnostics) | |||
| { | |||
| return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray(); | |||
| } | |||
| #endregion | |||
| #region Set up compilation and documents | |||
| /// <summary> | |||
| /// Given an array of strings as sources and a language, turn them into a project and return the documents and spans of it. | |||
| /// </summary> | |||
| /// <param name="sources">Classes in the form of strings</param> | |||
| /// <param name="language">The language the source code is in</param> | |||
| /// <returns>A Tuple containing the Documents produced from the sources and their TextSpans if relevant</returns> | |||
| private static Document[] GetDocuments(string[] sources, string language) | |||
| { | |||
| if (language != LanguageNames.CSharp && language != LanguageNames.VisualBasic) | |||
| { | |||
| throw new ArgumentException("Unsupported Language"); | |||
| } | |||
| var project = CreateProject(sources, language); | |||
| var documents = project.Documents.ToArray(); | |||
| if (sources.Length != documents.Length) | |||
| { | |||
| throw new Exception("Amount of sources did not match amount of Documents created"); | |||
| } | |||
| return documents; | |||
| } | |||
| /// <summary> | |||
| /// Create a Document from a string through creating a project that contains it. | |||
| /// </summary> | |||
| /// <param name="source">Classes in the form of a string</param> | |||
| /// <param name="language">The language the source code is in</param> | |||
| /// <returns>A Document created from the source string</returns> | |||
| protected static Document CreateDocument(string source, string language = LanguageNames.CSharp) | |||
| { | |||
| return CreateProject(new[] { source }, language).Documents.First(); | |||
| } | |||
| /// <summary> | |||
| /// Create a project using the inputted strings as sources. | |||
| /// </summary> | |||
| /// <param name="sources">Classes in the form of strings</param> | |||
| /// <param name="language">The language the source code is in</param> | |||
| /// <returns>A Project created out of the Documents created from the source strings</returns> | |||
| private static Project CreateProject(string[] sources, string language = LanguageNames.CSharp) | |||
| { | |||
| string fileNamePrefix = DefaultFilePathPrefix; | |||
| string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt; | |||
| var projectId = ProjectId.CreateNewId(debugName: TestProjectName); | |||
| var solution = new AdhocWorkspace() | |||
| .CurrentSolution | |||
| .AddProject(projectId, TestProjectName, TestProjectName, language) | |||
| .AddMetadataReference(projectId, CorlibReference) | |||
| .AddMetadataReference(projectId, SystemCoreReference) | |||
| .AddMetadataReference(projectId, CSharpSymbolsReference) | |||
| .AddMetadataReference(projectId, CodeAnalysisReference) | |||
| .AddMetadataReferences(projectId, Transitive(DiscordCommandsAssembly)); | |||
| int count = 0; | |||
| foreach (var source in sources) | |||
| { | |||
| var newFileName = fileNamePrefix + count + "." + fileExt; | |||
| var documentId = DocumentId.CreateNewId(projectId, debugName: newFileName); | |||
| solution = solution.AddDocument(documentId, newFileName, SourceText.From(source)); | |||
| count++; | |||
| } | |||
| return solution.GetProject(projectId); | |||
| } | |||
| #endregion | |||
| /// <summary> | |||
| /// Get the <see cref="MetadataReference"/> for <paramref name="assembly"/> and all assemblies referenced by <paramref name="assembly"/> | |||
| /// </summary> | |||
| /// <param name="assembly">The assembly.</param> | |||
| /// <returns><see cref="MetadataReference"/>s.</returns> | |||
| private static IEnumerable<MetadataReference> Transitive(Assembly assembly) | |||
| { | |||
| foreach (var a in RecursiveReferencedAssemblies(assembly)) | |||
| { | |||
| yield return MetadataReference.CreateFromFile(a.Location); | |||
| } | |||
| } | |||
| private static HashSet<Assembly> RecursiveReferencedAssemblies(Assembly a, HashSet<Assembly> assemblies = null) | |||
| { | |||
| assemblies = assemblies ?? new HashSet<Assembly>(); | |||
| if (assemblies.Add(a)) | |||
| { | |||
| foreach (var referencedAssemblyName in a.GetReferencedAssemblies()) | |||
| { | |||
| var referencedAssembly = AppDomain.CurrentDomain.GetAssemblies() | |||
| .SingleOrDefault(x => x.GetName() == referencedAssemblyName) ?? | |||
| Assembly.Load(referencedAssemblyName); | |||
| RecursiveReferencedAssemblies(referencedAssembly, assemblies); | |||
| } | |||
| } | |||
| return assemblies; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,129 @@ | |||
| using Microsoft.CodeAnalysis; | |||
| using Microsoft.CodeAnalysis.CodeActions; | |||
| using Microsoft.CodeAnalysis.CodeFixes; | |||
| using Microsoft.CodeAnalysis.Diagnostics; | |||
| using Microsoft.CodeAnalysis.Formatting; | |||
| //using Microsoft.VisualStudio.TestTools.UnitTesting; | |||
| using System.Collections.Generic; | |||
| using System.Linq; | |||
| using System.Threading; | |||
| using Xunit; | |||
| namespace TestHelper | |||
| { | |||
| /// <summary> | |||
| /// Superclass of all Unit tests made for diagnostics with codefixes. | |||
| /// Contains methods used to verify correctness of codefixes | |||
| /// </summary> | |||
| public abstract partial class CodeFixVerifier : DiagnosticVerifier | |||
| { | |||
| /// <summary> | |||
| /// Returns the codefix being tested (C#) - to be implemented in non-abstract class | |||
| /// </summary> | |||
| /// <returns>The CodeFixProvider to be used for CSharp code</returns> | |||
| protected virtual CodeFixProvider GetCSharpCodeFixProvider() | |||
| { | |||
| return null; | |||
| } | |||
| /// <summary> | |||
| /// Returns the codefix being tested (VB) - to be implemented in non-abstract class | |||
| /// </summary> | |||
| /// <returns>The CodeFixProvider to be used for VisualBasic code</returns> | |||
| protected virtual CodeFixProvider GetBasicCodeFixProvider() | |||
| { | |||
| return null; | |||
| } | |||
| /// <summary> | |||
| /// Called to test a C# codefix when applied on the inputted string as a source | |||
| /// </summary> | |||
| /// <param name="oldSource">A class in the form of a string before the CodeFix was applied to it</param> | |||
| /// <param name="newSource">A class in the form of a string after the CodeFix was applied to it</param> | |||
| /// <param name="codeFixIndex">Index determining which codefix to apply if there are multiple</param> | |||
| /// <param name="allowNewCompilerDiagnostics">A bool controlling whether or not the test will fail if the CodeFix introduces other warnings after being applied</param> | |||
| protected void VerifyCSharpFix(string oldSource, string newSource, int? codeFixIndex = null, bool allowNewCompilerDiagnostics = false) | |||
| { | |||
| VerifyFix(LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), GetCSharpCodeFixProvider(), oldSource, newSource, codeFixIndex, allowNewCompilerDiagnostics); | |||
| } | |||
| /// <summary> | |||
| /// Called to test a VB codefix when applied on the inputted string as a source | |||
| /// </summary> | |||
| /// <param name="oldSource">A class in the form of a string before the CodeFix was applied to it</param> | |||
| /// <param name="newSource">A class in the form of a string after the CodeFix was applied to it</param> | |||
| /// <param name="codeFixIndex">Index determining which codefix to apply if there are multiple</param> | |||
| /// <param name="allowNewCompilerDiagnostics">A bool controlling whether or not the test will fail if the CodeFix introduces other warnings after being applied</param> | |||
| protected void VerifyBasicFix(string oldSource, string newSource, int? codeFixIndex = null, bool allowNewCompilerDiagnostics = false) | |||
| { | |||
| VerifyFix(LanguageNames.VisualBasic, GetBasicDiagnosticAnalyzer(), GetBasicCodeFixProvider(), oldSource, newSource, codeFixIndex, allowNewCompilerDiagnostics); | |||
| } | |||
| /// <summary> | |||
| /// General verifier for codefixes. | |||
| /// Creates a Document from the source string, then gets diagnostics on it and applies the relevant codefixes. | |||
| /// Then gets the string after the codefix is applied and compares it with the expected result. | |||
| /// Note: If any codefix causes new diagnostics to show up, the test fails unless allowNewCompilerDiagnostics is set to true. | |||
| /// </summary> | |||
| /// <param name="language">The language the source code is in</param> | |||
| /// <param name="analyzer">The analyzer to be applied to the source code</param> | |||
| /// <param name="codeFixProvider">The codefix to be applied to the code wherever the relevant Diagnostic is found</param> | |||
| /// <param name="oldSource">A class in the form of a string before the CodeFix was applied to it</param> | |||
| /// <param name="newSource">A class in the form of a string after the CodeFix was applied to it</param> | |||
| /// <param name="codeFixIndex">Index determining which codefix to apply if there are multiple</param> | |||
| /// <param name="allowNewCompilerDiagnostics">A bool controlling whether or not the test will fail if the CodeFix introduces other warnings after being applied</param> | |||
| private void VerifyFix(string language, DiagnosticAnalyzer analyzer, CodeFixProvider codeFixProvider, string oldSource, string newSource, int? codeFixIndex, bool allowNewCompilerDiagnostics) | |||
| { | |||
| var document = CreateDocument(oldSource, language); | |||
| var analyzerDiagnostics = GetSortedDiagnosticsFromDocuments(analyzer, new[] { document }); | |||
| var compilerDiagnostics = GetCompilerDiagnostics(document); | |||
| var attempts = analyzerDiagnostics.Length; | |||
| for (int i = 0; i < attempts; ++i) | |||
| { | |||
| var actions = new List<CodeAction>(); | |||
| var context = new CodeFixContext(document, analyzerDiagnostics[0], (a, d) => actions.Add(a), CancellationToken.None); | |||
| codeFixProvider.RegisterCodeFixesAsync(context).Wait(); | |||
| if (!actions.Any()) | |||
| { | |||
| break; | |||
| } | |||
| if (codeFixIndex != null) | |||
| { | |||
| document = ApplyFix(document, actions.ElementAt((int)codeFixIndex)); | |||
| break; | |||
| } | |||
| document = ApplyFix(document, actions.ElementAt(0)); | |||
| analyzerDiagnostics = GetSortedDiagnosticsFromDocuments(analyzer, new[] { document }); | |||
| var newCompilerDiagnostics = GetNewDiagnostics(compilerDiagnostics, GetCompilerDiagnostics(document)); | |||
| //check if applying the code fix introduced any new compiler diagnostics | |||
| if (!allowNewCompilerDiagnostics && newCompilerDiagnostics.Any()) | |||
| { | |||
| // Format and get the compiler diagnostics again so that the locations make sense in the output | |||
| document = document.WithSyntaxRoot(Formatter.Format(document.GetSyntaxRootAsync().Result, Formatter.Annotation, document.Project.Solution.Workspace)); | |||
| newCompilerDiagnostics = GetNewDiagnostics(compilerDiagnostics, GetCompilerDiagnostics(document)); | |||
| Assert.True(false, | |||
| string.Format("Fix introduced new compiler diagnostics:\r\n{0}\r\n\r\nNew document:\r\n{1}\r\n", | |||
| string.Join("\r\n", newCompilerDiagnostics.Select(d => d.ToString())), | |||
| document.GetSyntaxRootAsync().Result.ToFullString())); | |||
| } | |||
| //check if there are analyzer diagnostics left after the code fix | |||
| if (!analyzerDiagnostics.Any()) | |||
| { | |||
| break; | |||
| } | |||
| } | |||
| //after applying all of the code fixes, compare the resulting string to the inputted one | |||
| var actual = GetStringFromDocument(document); | |||
| Assert.Equal(newSource, actual); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,271 @@ | |||
| using System.Collections.Generic; | |||
| using System.Linq; | |||
| using System.Text; | |||
| using Microsoft.CodeAnalysis; | |||
| using Microsoft.CodeAnalysis.CSharp; | |||
| using Microsoft.CodeAnalysis.Diagnostics; | |||
| //using Microsoft.VisualStudio.TestTools.UnitTesting; | |||
| using Xunit; | |||
| namespace TestHelper | |||
| { | |||
| /// <summary> | |||
| /// Superclass of all Unit Tests for DiagnosticAnalyzers | |||
| /// </summary> | |||
| public abstract partial class DiagnosticVerifier | |||
| { | |||
| #region To be implemented by Test classes | |||
| /// <summary> | |||
| /// Get the CSharp analyzer being tested - to be implemented in non-abstract class | |||
| /// </summary> | |||
| protected virtual DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() | |||
| { | |||
| return null; | |||
| } | |||
| /// <summary> | |||
| /// Get the Visual Basic analyzer being tested (C#) - to be implemented in non-abstract class | |||
| /// </summary> | |||
| protected virtual DiagnosticAnalyzer GetBasicDiagnosticAnalyzer() | |||
| { | |||
| return null; | |||
| } | |||
| #endregion | |||
| #region Verifier wrappers | |||
| /// <summary> | |||
| /// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source | |||
| /// Note: input a DiagnosticResult for each Diagnostic expected | |||
| /// </summary> | |||
| /// <param name="source">A class in the form of a string to run the analyzer on</param> | |||
| /// <param name="expected"> DiagnosticResults that should appear after the analyzer is run on the source</param> | |||
| protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected) | |||
| { | |||
| VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected); | |||
| } | |||
| /// <summary> | |||
| /// Called to test a VB DiagnosticAnalyzer when applied on the single inputted string as a source | |||
| /// Note: input a DiagnosticResult for each Diagnostic expected | |||
| /// </summary> | |||
| /// <param name="source">A class in the form of a string to run the analyzer on</param> | |||
| /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the source</param> | |||
| protected void VerifyBasicDiagnostic(string source, params DiagnosticResult[] expected) | |||
| { | |||
| VerifyDiagnostics(new[] { source }, LanguageNames.VisualBasic, GetBasicDiagnosticAnalyzer(), expected); | |||
| } | |||
| /// <summary> | |||
| /// Called to test a C# DiagnosticAnalyzer when applied on the inputted strings as a source | |||
| /// Note: input a DiagnosticResult for each Diagnostic expected | |||
| /// </summary> | |||
| /// <param name="sources">An array of strings to create source documents from to run the analyzers on</param> | |||
| /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param> | |||
| protected void VerifyCSharpDiagnostic(string[] sources, params DiagnosticResult[] expected) | |||
| { | |||
| VerifyDiagnostics(sources, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected); | |||
| } | |||
| /// <summary> | |||
| /// Called to test a VB DiagnosticAnalyzer when applied on the inputted strings as a source | |||
| /// Note: input a DiagnosticResult for each Diagnostic expected | |||
| /// </summary> | |||
| /// <param name="sources">An array of strings to create source documents from to run the analyzers on</param> | |||
| /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param> | |||
| protected void VerifyBasicDiagnostic(string[] sources, params DiagnosticResult[] expected) | |||
| { | |||
| VerifyDiagnostics(sources, LanguageNames.VisualBasic, GetBasicDiagnosticAnalyzer(), expected); | |||
| } | |||
| /// <summary> | |||
| /// General method that gets a collection of actual diagnostics found in the source after the analyzer is run, | |||
| /// then verifies each of them. | |||
| /// </summary> | |||
| /// <param name="sources">An array of strings to create source documents from to run the analyzers on</param> | |||
| /// <param name="language">The language of the classes represented by the source strings</param> | |||
| /// <param name="analyzer">The analyzer to be run on the source code</param> | |||
| /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param> | |||
| private 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 | |||
| /// <summary> | |||
| /// 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. | |||
| /// </summary> | |||
| /// <param name="actualResults">The Diagnostics found by the compiler after running the analyzer on the source code</param> | |||
| /// <param name="analyzer">The analyzer that was being run on the sources</param> | |||
| /// <param name="expectedResults">Diagnostic Results that should have appeared in the code</param> | |||
| private static void VerifyDiagnosticResults(IEnumerable<Diagnostic> actualResults, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expectedResults) | |||
| { | |||
| int expectedCount = expectedResults.Count(); | |||
| int actualCount = actualResults.Count(); | |||
| if (expectedCount != actualCount) | |||
| { | |||
| string diagnosticsOutput = actualResults.Any() ? FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE."; | |||
| Assert.True(false, | |||
| string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput)); | |||
| } | |||
| for (int 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.True(false, | |||
| string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}", | |||
| FormatDiagnostics(analyzer, actual))); | |||
| } | |||
| } | |||
| else | |||
| { | |||
| VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First()); | |||
| var additionalLocations = actual.AdditionalLocations.ToArray(); | |||
| if (additionalLocations.Length != expected.Locations.Length - 1) | |||
| { | |||
| Assert.True(false, | |||
| string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n", | |||
| expected.Locations.Length - 1, additionalLocations.Length, | |||
| FormatDiagnostics(analyzer, actual))); | |||
| } | |||
| for (int j = 0; j < additionalLocations.Length; ++j) | |||
| { | |||
| VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]); | |||
| } | |||
| } | |||
| if (actual.Id != expected.Id) | |||
| { | |||
| Assert.True(false, | |||
| string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", | |||
| expected.Id, actual.Id, FormatDiagnostics(analyzer, actual))); | |||
| } | |||
| if (actual.Severity != expected.Severity) | |||
| { | |||
| Assert.True(false, | |||
| string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", | |||
| expected.Severity, actual.Severity, FormatDiagnostics(analyzer, actual))); | |||
| } | |||
| if (actual.GetMessage() != expected.Message) | |||
| { | |||
| Assert.True(false, | |||
| string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", | |||
| expected.Message, actual.GetMessage(), FormatDiagnostics(analyzer, actual))); | |||
| } | |||
| } | |||
| } | |||
| /// <summary> | |||
| /// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult. | |||
| /// </summary> | |||
| /// <param name="analyzer">The analyzer that was being run on the sources</param> | |||
| /// <param name="diagnostic">The diagnostic that was found in the code</param> | |||
| /// <param name="actual">The Location of the Diagnostic found in the code</param> | |||
| /// <param name="expected">The DiagnosticResultLocation that should have been found</param> | |||
| 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.") && expected.Path.Contains("Test.")), | |||
| string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", | |||
| expected.Path, actualSpan.Path, FormatDiagnostics(analyzer, diagnostic))); | |||
| 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.True(false, | |||
| string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", | |||
| expected.Line, actualLinePosition.Line + 1, FormatDiagnostics(analyzer, diagnostic))); | |||
| } | |||
| } | |||
| // 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.True(false, | |||
| string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", | |||
| expected.Column, actualLinePosition.Character + 1, FormatDiagnostics(analyzer, diagnostic))); | |||
| } | |||
| } | |||
| } | |||
| #endregion | |||
| #region Formatting Diagnostics | |||
| /// <summary> | |||
| /// Helper method to format a Diagnostic into an easily readable string | |||
| /// </summary> | |||
| /// <param name="analyzer">The analyzer that this verifier tests</param> | |||
| /// <param name="diagnostics">The Diagnostics to be formatted</param> | |||
| /// <returns>The Diagnostics formatted as a string</returns> | |||
| private static string FormatDiagnostics(DiagnosticAnalyzer analyzer, params Diagnostic[] diagnostics) | |||
| { | |||
| var builder = new StringBuilder(); | |||
| for (int i = 0; i < diagnostics.Length; ++i) | |||
| { | |||
| builder.AppendLine("// " + diagnostics[i].ToString()); | |||
| var analyzerType = analyzer.GetType(); | |||
| var rules = analyzer.SupportedDiagnostics; | |||
| foreach (var rule in rules) | |||
| { | |||
| if (rule != null && rule.Id == diagnostics[i].Id) | |||
| { | |||
| var location = diagnostics[i].Location; | |||
| if (location == Location.None) | |||
| { | |||
| builder.AppendFormat("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"); | |||
| string resultMethodName = diagnostics[i].Location.SourceTree.FilePath.EndsWith(".cs") ? "GetCSharpResultAt" : "GetBasicResultAt"; | |||
| var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition; | |||
| builder.AppendFormat("{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 | |||
| } | |||
| } | |||
| @@ -14,6 +14,7 @@ | |||
| <ProjectReference Include="../../src/Discord.Net.Commands/Discord.Net.Commands.csproj" /> | |||
| <ProjectReference Include="../../src/Discord.Net.Core/Discord.Net.Core.csproj" /> | |||
| <ProjectReference Include="../../src/Discord.Net.Rest/Discord.Net.Rest.csproj" /> | |||
| <ProjectReference Include="../../src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj" /> | |||
| </ItemGroup> | |||
| <ItemGroup> | |||
| <PackageReference Include="Akavache" Version="5.0.0" /> | |||