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