diff --git a/Discord.Net.sln b/Discord.Net.sln
index cac6c9064..daf902b96 100644
--- a/Discord.Net.sln
+++ b/Discord.Net.sln
@@ -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}
diff --git a/appveyor.yml b/appveyor.yml
index 393485fee..54b9a1251 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -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=""
diff --git a/src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj b/src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj
new file mode 100644
index 000000000..8ab398ff5
--- /dev/null
+++ b/src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Discord.Net.Analyzers
+ Discord.Analyzers
+ A Discord.Net extension adding support for design-time analysis of the API usage.
+ netstandard1.3
+
+
+
+
+
+
+
+
diff --git a/src/Discord.Net.Analyzers/GuildAccessAnalyzer.cs b/src/Discord.Net.Analyzers/GuildAccessAnalyzer.cs
new file mode 100644
index 000000000..0760d019f
--- /dev/null
+++ b/src/Discord.Net.Analyzers/GuildAccessAnalyzer.cs
@@ -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 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'
+ var typeNode = context.Node.FirstAncestorOrSelf();
+ 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();
+ 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 _attributeDataPredicate =
+ (a => a.AttributeClass.Name == nameof(RequireContextAttribute));
+ }
+}
diff --git a/src/Discord.Net.Analyzers/SymbolExtensions.cs b/src/Discord.Net.Analyzers/SymbolExtensions.cs
new file mode 100644
index 000000000..680de66b5
--- /dev/null
+++ b/src/Discord.Net.Analyzers/SymbolExtensions.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/Discord.Net.Analyzers/docs/DNET0001.md b/src/Discord.Net.Analyzers/docs/DNET0001.md
new file mode 100644
index 000000000..0c1b8098f
--- /dev/null
+++ b/src/Discord.Net.Analyzers/docs/DNET0001.md
@@ -0,0 +1,30 @@
+# DNET0001
+
+
+
+ TypeName |
+ GuildAccessAnalyzer |
+
+
+ CheckId |
+ DNET0001 |
+
+
+ Category |
+ API Usage |
+
+
+
+## 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.
diff --git a/test/Discord.Net.Tests/AnalyzerTests/Extensions/AppDomainPolyfill.cs b/test/Discord.Net.Tests/AnalyzerTests/Extensions/AppDomainPolyfill.cs
new file mode 100644
index 000000000..729bc385c
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/Extensions/AppDomainPolyfill.cs
@@ -0,0 +1,30 @@
+using System.Linq;
+using System.Reflection;
+using Microsoft.DotNet.PlatformAbstractions;
+using Microsoft.Extensions.DependencyModel;
+
+namespace System
+{
+ /// Polyfill of the AppDomain class from full framework.
+ 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Discord.Net.Tests/AnalyzerTests/GuildAccessTests.cs b/test/Discord.Net.Tests/AnalyzerTests/GuildAccessTests.cs
new file mode 100644
index 000000000..073cc1de7
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/GuildAccessTests.cs
@@ -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
+ {
+ [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
+ {
+ [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
+ {
+ [Command(""test""), RequireContext(ContextType.Guild)]
+ public Task TestCmd() => ReplyAsync(Context.Guild.Name);
+ }
+}";
+
+ VerifyCSharpDiagnostic(source, Array.Empty());
+ }
+
+ [Fact]
+ public void VerifyNoDiagnosticWhenRequireContextOnClass()
+ {
+ string source = @"using System;
+using System.Threading.Tasks;
+using Discord.Commands;
+
+namespace Test
+{
+ [RequireContext(ContextType.Guild)]
+ public class TestModule : ModuleBase
+ {
+ [Command(""test"")]
+ public Task TestCmd() => ReplyAsync(Context.Guild.Name);
+ }
+}";
+
+ VerifyCSharpDiagnostic(source, Array.Empty());
+ }
+
+ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
+ => new GuildAccessAnalyzer();
+ }
+ }
+}
diff --git a/test/Discord.Net.Tests/AnalyzerTests/Helpers/CodeFixVerifier.Helper.cs b/test/Discord.Net.Tests/AnalyzerTests/Helpers/CodeFixVerifier.Helper.cs
new file mode 100644
index 000000000..0f73d0643
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/Helpers/CodeFixVerifier.Helper.cs
@@ -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
+{
+ ///
+ /// Diagnostic Producer class with extra methods dealing with applying codefixes
+ /// All methods are static
+ ///
+ public abstract partial class CodeFixVerifier : DiagnosticVerifier
+ {
+ ///
+ /// Apply the inputted CodeAction to the inputted document.
+ /// Meant to be used to apply codefixes.
+ ///
+ /// The Document to apply the fix on
+ /// A CodeAction that will be applied to the Document.
+ /// A Document with the changes from the CodeAction
+ private static Document ApplyFix(Document document, CodeAction codeAction)
+ {
+ var operations = codeAction.GetOperationsAsync(CancellationToken.None).Result;
+ var solution = operations.OfType().Single().ChangedSolution;
+ return solution.GetDocument(document.Id);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The Diagnostics that existed in the code before the CodeFix was applied
+ /// The Diagnostics that exist in the code after the CodeFix was applied
+ /// A list of Diagnostics that only surfaced in the code after the CodeFix was applied
+ private static IEnumerable GetNewDiagnostics(IEnumerable diagnostics, IEnumerable 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++];
+ }
+ }
+ }
+
+ ///
+ /// Get the existing compiler diagnostics on the inputted document.
+ ///
+ /// The Document to run the compiler diagnostic analyzers on
+ /// The compiler diagnostics that were found in the code
+ private static IEnumerable GetCompilerDiagnostics(Document document)
+ {
+ return document.GetSemanticModelAsync().Result.GetDiagnostics();
+ }
+
+ ///
+ /// Given a document, turn it into a string based on the syntax root
+ ///
+ /// The Document to be converted to a string
+ /// A string containing the syntax of the Document after formatting
+ 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();
+ }
+ }
+}
+
diff --git a/test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticResult.cs b/test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticResult.cs
new file mode 100644
index 000000000..5ae6f528e
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticResult.cs
@@ -0,0 +1,87 @@
+using Microsoft.CodeAnalysis;
+using System;
+
+namespace TestHelper
+{
+ ///
+ /// Location where the diagnostic appears, as determined by path, line number, and column number.
+ ///
+ 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; }
+ }
+
+ ///
+ /// Struct that stores information about a Diagnostic appearing in a source
+ ///
+ 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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticVerifier.Helper.cs b/test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticVerifier.Helper.cs
new file mode 100644
index 000000000..7a8eb2e9c
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticVerifier.Helper.cs
@@ -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
+{
+ ///
+ /// Class for turning strings into documents and getting the diagnostics on them
+ /// All methods are static
+ ///
+ 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
+
+ ///
+ /// 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.
+ ///
+ /// Classes in the form of strings
+ /// The language the source classes are in
+ /// The analyzer to be run on the sources
+ /// An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location
+ private static Diagnostic[] GetSortedDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer)
+ {
+ return GetSortedDiagnosticsFromDocuments(analyzer, GetDocuments(sources, language));
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The analyzer to run on the documents
+ /// The Documents that the analyzer will be run on
+ /// An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location
+ protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents)
+ {
+ var projects = new HashSet();
+ foreach (var document in documents)
+ {
+ projects.Add(document.Project);
+ }
+
+ var diagnostics = new List();
+ 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;
+ }
+
+ ///
+ /// Sort diagnostics by location in source document
+ ///
+ /// The list of Diagnostics to be sorted
+ /// An IEnumerable containing the Diagnostics in order of Location
+ private static Diagnostic[] SortDiagnostics(IEnumerable diagnostics)
+ {
+ return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray();
+ }
+
+ #endregion
+
+ #region Set up compilation and documents
+ ///
+ /// Given an array of strings as sources and a language, turn them into a project and return the documents and spans of it.
+ ///
+ /// Classes in the form of strings
+ /// The language the source code is in
+ /// A Tuple containing the Documents produced from the sources and their TextSpans if relevant
+ 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;
+ }
+
+ ///
+ /// Create a Document from a string through creating a project that contains it.
+ ///
+ /// Classes in the form of a string
+ /// The language the source code is in
+ /// A Document created from the source string
+ protected static Document CreateDocument(string source, string language = LanguageNames.CSharp)
+ {
+ return CreateProject(new[] { source }, language).Documents.First();
+ }
+
+ ///
+ /// Create a project using the inputted strings as sources.
+ ///
+ /// Classes in the form of strings
+ /// The language the source code is in
+ /// A Project created out of the Documents created from the source strings
+ 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
+
+ ///
+ /// Get the for and all assemblies referenced by
+ ///
+ /// The assembly.
+ /// s.
+ private static IEnumerable Transitive(Assembly assembly)
+ {
+ foreach (var a in RecursiveReferencedAssemblies(assembly))
+ {
+ yield return MetadataReference.CreateFromFile(a.Location);
+ }
+ }
+
+ private static HashSet RecursiveReferencedAssemblies(Assembly a, HashSet assemblies = null)
+ {
+ assemblies = assemblies ?? new HashSet();
+ 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;
+ }
+ }
+}
+
diff --git a/test/Discord.Net.Tests/AnalyzerTests/Verifiers/CodeFixVerifier.cs b/test/Discord.Net.Tests/AnalyzerTests/Verifiers/CodeFixVerifier.cs
new file mode 100644
index 000000000..5d057b610
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/Verifiers/CodeFixVerifier.cs
@@ -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
+{
+ ///
+ /// Superclass of all Unit tests made for diagnostics with codefixes.
+ /// Contains methods used to verify correctness of codefixes
+ ///
+ public abstract partial class CodeFixVerifier : DiagnosticVerifier
+ {
+ ///
+ /// Returns the codefix being tested (C#) - to be implemented in non-abstract class
+ ///
+ /// The CodeFixProvider to be used for CSharp code
+ protected virtual CodeFixProvider GetCSharpCodeFixProvider()
+ {
+ return null;
+ }
+
+ ///
+ /// Returns the codefix being tested (VB) - to be implemented in non-abstract class
+ ///
+ /// The CodeFixProvider to be used for VisualBasic code
+ protected virtual CodeFixProvider GetBasicCodeFixProvider()
+ {
+ return null;
+ }
+
+ ///
+ /// Called to test a C# codefix when applied on the inputted string as a source
+ ///
+ /// A class in the form of a string before the CodeFix was applied to it
+ /// A class in the form of a string after the CodeFix was applied to it
+ /// Index determining which codefix to apply if there are multiple
+ /// A bool controlling whether or not the test will fail if the CodeFix introduces other warnings after being applied
+ protected void VerifyCSharpFix(string oldSource, string newSource, int? codeFixIndex = null, bool allowNewCompilerDiagnostics = false)
+ {
+ VerifyFix(LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), GetCSharpCodeFixProvider(), oldSource, newSource, codeFixIndex, allowNewCompilerDiagnostics);
+ }
+
+ ///
+ /// Called to test a VB codefix when applied on the inputted string as a source
+ ///
+ /// A class in the form of a string before the CodeFix was applied to it
+ /// A class in the form of a string after the CodeFix was applied to it
+ /// Index determining which codefix to apply if there are multiple
+ /// A bool controlling whether or not the test will fail if the CodeFix introduces other warnings after being applied
+ protected void VerifyBasicFix(string oldSource, string newSource, int? codeFixIndex = null, bool allowNewCompilerDiagnostics = false)
+ {
+ VerifyFix(LanguageNames.VisualBasic, GetBasicDiagnosticAnalyzer(), GetBasicCodeFixProvider(), oldSource, newSource, codeFixIndex, allowNewCompilerDiagnostics);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The language the source code is in
+ /// The analyzer to be applied to the source code
+ /// The codefix to be applied to the code wherever the relevant Diagnostic is found
+ /// A class in the form of a string before the CodeFix was applied to it
+ /// A class in the form of a string after the CodeFix was applied to it
+ /// Index determining which codefix to apply if there are multiple
+ /// A bool controlling whether or not the test will fail if the CodeFix introduces other warnings after being applied
+ 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();
+ 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Discord.Net.Tests/AnalyzerTests/Verifiers/DiagnosticVerifier.cs b/test/Discord.Net.Tests/AnalyzerTests/Verifiers/DiagnosticVerifier.cs
new file mode 100644
index 000000000..498e5ef27
--- /dev/null
+++ b/test/Discord.Net.Tests/AnalyzerTests/Verifiers/DiagnosticVerifier.cs
@@ -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
+{
+ ///
+ /// Superclass of all Unit Tests for DiagnosticAnalyzers
+ ///
+ public abstract partial class DiagnosticVerifier
+ {
+ #region To be implemented by Test classes
+ ///
+ /// Get the CSharp analyzer being tested - to be implemented in non-abstract class
+ ///
+ protected virtual DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
+ {
+ return null;
+ }
+
+ ///
+ /// Get the Visual Basic analyzer being tested (C#) - to be implemented in non-abstract class
+ ///
+ protected virtual DiagnosticAnalyzer GetBasicDiagnosticAnalyzer()
+ {
+ return null;
+ }
+ #endregion
+
+ #region Verifier wrappers
+
+ ///
+ /// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source
+ /// Note: input a DiagnosticResult for each Diagnostic expected
+ ///
+ /// A class in the form of a string to run the analyzer on
+ /// DiagnosticResults that should appear after the analyzer is run on the source
+ protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected)
+ {
+ VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
+ }
+
+ ///
+ /// Called to test a VB DiagnosticAnalyzer when applied on the single inputted string as a source
+ /// Note: input a DiagnosticResult for each Diagnostic expected
+ ///
+ /// A class in the form of a string to run the analyzer on
+ /// DiagnosticResults that should appear after the analyzer is run on the source
+ protected void VerifyBasicDiagnostic(string source, params DiagnosticResult[] expected)
+ {
+ VerifyDiagnostics(new[] { source }, LanguageNames.VisualBasic, GetBasicDiagnosticAnalyzer(), expected);
+ }
+
+ ///
+ /// Called to test a C# DiagnosticAnalyzer when applied on the inputted strings as a source
+ /// Note: input a DiagnosticResult for each Diagnostic expected
+ ///
+ /// An array of strings to create source documents from to run the analyzers on
+ /// DiagnosticResults that should appear after the analyzer is run on the sources
+ protected void VerifyCSharpDiagnostic(string[] sources, params DiagnosticResult[] expected)
+ {
+ VerifyDiagnostics(sources, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
+ }
+
+ ///
+ /// Called to test a VB DiagnosticAnalyzer when applied on the inputted strings as a source
+ /// Note: input a DiagnosticResult for each Diagnostic expected
+ ///
+ /// An array of strings to create source documents from to run the analyzers on
+ /// DiagnosticResults that should appear after the analyzer is run on the sources
+ protected void VerifyBasicDiagnostic(string[] sources, params DiagnosticResult[] expected)
+ {
+ VerifyDiagnostics(sources, LanguageNames.VisualBasic, GetBasicDiagnosticAnalyzer(), expected);
+ }
+
+ ///
+ /// General method that gets a collection of actual diagnostics found in the source after the analyzer is run,
+ /// then verifies each of them.
+ ///
+ /// An array of strings to create source documents from to run the analyzers on
+ /// The language of the classes represented by the source strings
+ /// The analyzer to be run on the source code
+ /// DiagnosticResults that should appear after the analyzer is run on the sources
+ private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected)
+ {
+ var diagnostics = GetSortedDiagnostics(sources, language, analyzer);
+ VerifyDiagnosticResults(diagnostics, analyzer, expected);
+ }
+
+ #endregion
+
+ #region Actual comparisons and verifications
+ ///
+ /// Checks each of the actual Diagnostics found and compares them with the corresponding DiagnosticResult in the array of expected results.
+ /// Diagnostics are considered equal only if the DiagnosticResultLocation, Id, Severity, and Message of the DiagnosticResult match the actual diagnostic.
+ ///
+ /// The Diagnostics found by the compiler after running the analyzer on the source code
+ /// The analyzer that was being run on the sources
+ /// Diagnostic Results that should have appeared in the code
+ private static void VerifyDiagnosticResults(IEnumerable 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)));
+ }
+ }
+ }
+
+ ///
+ /// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult.
+ ///
+ /// The analyzer that was being run on the sources
+ /// The diagnostic that was found in the code
+ /// The Location of the Diagnostic found in the code
+ /// The DiagnosticResultLocation that should have been found
+ private static void VerifyDiagnosticLocation(DiagnosticAnalyzer analyzer, Diagnostic diagnostic, Location actual, DiagnosticResultLocation expected)
+ {
+ var actualSpan = actual.GetLineSpan();
+
+ Assert.True(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && 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
+ ///
+ /// Helper method to format a Diagnostic into an easily readable string
+ ///
+ /// The analyzer that this verifier tests
+ /// The Diagnostics to be formatted
+ /// The Diagnostics formatted as a string
+ private static string FormatDiagnostics(DiagnosticAnalyzer analyzer, params Diagnostic[] diagnostics)
+ {
+ var builder = new StringBuilder();
+ for (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
+ }
+}
diff --git a/test/Discord.Net.Tests/Discord.Net.Tests.csproj b/test/Discord.Net.Tests/Discord.Net.Tests.csproj
index 9e734641c..bf2457187 100644
--- a/test/Discord.Net.Tests/Discord.Net.Tests.csproj
+++ b/test/Discord.Net.Tests/Discord.Net.Tests.csproj
@@ -14,6 +14,7 @@
+