* 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 | 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" /> | ||||