Browse Source

Add API Analyzer Assembly (#906)

* Start on API analyzers

* Finish GuildAccessAnalyzer

* Update build script (will this do?)

* Correct slashes

* Extrapolate DerivesFromModuleBase() to an extension method

* Quick refactoring

* Add doc file
tags/2.0.0-beta
Joe4evr Christopher F 7 years ago
parent
commit
f69ef2a8ca
14 changed files with 1074 additions and 1 deletions
  1. +16
    -1
      Discord.Net.sln
  2. +1
    -0
      appveyor.yml
  3. +15
    -0
      src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj
  4. +70
    -0
      src/Discord.Net.Analyzers/GuildAccessAnalyzer.cs
  5. +21
    -0
      src/Discord.Net.Analyzers/SymbolExtensions.cs
  6. +30
    -0
      src/Discord.Net.Analyzers/docs/DNET0001.md
  7. +30
    -0
      test/Discord.Net.Tests/AnalyzerTests/Extensions/AppDomainPolyfill.cs
  8. +111
    -0
      test/Discord.Net.Tests/AnalyzerTests/GuildAccessTests.cs
  9. +85
    -0
      test/Discord.Net.Tests/AnalyzerTests/Helpers/CodeFixVerifier.Helper.cs
  10. +87
    -0
      test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticResult.cs
  11. +207
    -0
      test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticVerifier.Helper.cs
  12. +129
    -0
      test/Discord.Net.Tests/AnalyzerTests/Verifiers/CodeFixVerifier.cs
  13. +271
    -0
      test/Discord.Net.Tests/AnalyzerTests/Verifiers/DiagnosticVerifier.cs
  14. +1
    -0
      test/Discord.Net.Tests/Discord.Net.Tests.csproj

+ 16
- 1
Discord.Net.sln View File

@@ -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}


+ 1
- 0
appveyor.yml View File

@@ -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=""


+ 15
- 0
src/Discord.Net.Analyzers/Discord.Net.Analyzers.csproj View File

@@ -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>

+ 70
- 0
src/Discord.Net.Analyzers/GuildAccessAnalyzer.cs View File

@@ -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));
}
}

+ 21
- 0
src/Discord.Net.Analyzers/SymbolExtensions.cs View File

@@ -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;
}
}
}

+ 30
- 0
src/Discord.Net.Analyzers/docs/DNET0001.md View File

@@ -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.

+ 30
- 0
test/Discord.Net.Tests/AnalyzerTests/Extensions/AppDomainPolyfill.cs View File

@@ -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();
}
}
}

+ 111
- 0
test/Discord.Net.Tests/AnalyzerTests/GuildAccessTests.cs View File

@@ -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();
}
}
}

+ 85
- 0
test/Discord.Net.Tests/AnalyzerTests/Helpers/CodeFixVerifier.Helper.cs View File

@@ -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();
}
}
}


+ 87
- 0
test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticResult.cs View File

@@ -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;
}
}
}
}

+ 207
- 0
test/Discord.Net.Tests/AnalyzerTests/Helpers/DiagnosticVerifier.Helper.cs View File

@@ -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;
}
}
}


+ 129
- 0
test/Discord.Net.Tests/AnalyzerTests/Verifiers/CodeFixVerifier.cs View File

@@ -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);
}
}
}

+ 271
- 0
test/Discord.Net.Tests/AnalyzerTests/Verifiers/DiagnosticVerifier.cs View File

@@ -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
}
}

+ 1
- 0
test/Discord.Net.Tests/Discord.Net.Tests.csproj View File

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


Loading…
Cancel
Save