* 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 | Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| # Visual Studio 15 | # Visual Studio 15 | ||||
| VisualStudioVersion = 15.0.27004.2009 | |||||
| VisualStudioVersion = 15.0.27130.0 | |||||
| MinimumVisualStudioVersion = 10.0.40219.1 | 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}" | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Core", "src\Discord.Net.Core\Discord.Net.Core.csproj", "{91E9E7BD-75C9-4E98-84AA-2C271922E5C2}" | ||||
| EndProject | EndProject | ||||
| @@ -22,6 +22,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Tests", "test\D | |||||
| EndProject | EndProject | ||||
| Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Webhook", "src\Discord.Net.Webhook\Discord.Net.Webhook.csproj", "{9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}" | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Webhook", "src\Discord.Net.Webhook\Discord.Net.Webhook.csproj", "{9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}" | ||||
| EndProject | 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 | Global | ||||
| GlobalSection(SolutionConfigurationPlatforms) = preSolution | GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| Debug|Any CPU = Debug|Any CPU | 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|x64.Build.0 = Release|Any CPU | ||||
| {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30}.Release|x86.ActiveCfg = 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 | {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 | EndGlobalSection | ||||
| GlobalSection(SolutionProperties) = preSolution | GlobalSection(SolutionProperties) = preSolution | ||||
| HideSolutionNode = FALSE | HideSolutionNode = FALSE | ||||
| @@ -126,6 +140,7 @@ Global | |||||
| {688FD1D8-7F01-4539-B2E9-F473C5D699C7} = {288C363D-A636-4EAE-9AC1-4698B641B26E} | {688FD1D8-7F01-4539-B2E9-F473C5D699C7} = {288C363D-A636-4EAE-9AC1-4698B641B26E} | ||||
| {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} = {B0657AAE-DCC5-4FBF-8E5D-1FB578CF3012} | {6BDEEC08-417B-459F-9CA3-FF8BAB18CAC7} = {B0657AAE-DCC5-4FBF-8E5D-1FB578CF3012} | ||||
| {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} | {9AFAB80E-D2D3-4EDB-B58C-BACA78D1EA30} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} | ||||
| {BBA8E7FB-C834-40DC-822F-B112CB7F0140} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} | |||||
| EndGlobalSection | EndGlobalSection | ||||
| GlobalSection(ExtensibilityGlobals) = postSolution | GlobalSection(ExtensibilityGlobals) = postSolution | ||||
| SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} | 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.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.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.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: >- | - ps: >- | ||||
| if ($Env:APPVEYOR_REPO_TAG -eq "true") { | if ($Env:APPVEYOR_REPO_TAG -eq "true") { | ||||
| nuget pack src\Discord.Net\Discord.Net.nuspec -OutputDirectory "artifacts" -properties suffix="" | 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.Commands/Discord.Net.Commands.csproj" /> | ||||
| <ProjectReference Include="../../src/Discord.Net.Core/Discord.Net.Core.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.Rest/Discord.Net.Rest.csproj" /> | ||||
| <ProjectReference Include="../../src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj" /> | |||||
| </ItemGroup> | </ItemGroup> | ||||
| <ItemGroup> | <ItemGroup> | ||||
| <PackageReference Include="Akavache" Version="5.0.0" /> | <PackageReference Include="Akavache" Version="5.0.0" /> | ||||