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