diff --git a/examples/Console/AnalyzerTester/AnalyzerTester.csproj b/examples/Console/AnalyzerTester/AnalyzerTester.csproj new file mode 100644 index 0000000..76cf494 --- /dev/null +++ b/examples/Console/AnalyzerTester/AnalyzerTester.csproj @@ -0,0 +1,16 @@ + + + + Exe + net5.0 + + + + + + + + diff --git a/examples/Console/AnalyzerTester/Program.cs b/examples/Console/AnalyzerTester/Program.cs new file mode 100644 index 0000000..ed5b945 --- /dev/null +++ b/examples/Console/AnalyzerTester/Program.cs @@ -0,0 +1,32 @@ +using Spectre.Console; + +namespace AnalyzerTester +{ + class Program + { + static void Main(string[] args) + { + AnsiConsole.WriteLine("Hello World!"); + } + } + + class Dependency + { + private readonly IAnsiConsole _ansiConsole; + + public Dependency(IAnsiConsole ansiConsole) + { + _ansiConsole = ansiConsole; + } + + public void DoIt() + { + _ansiConsole.WriteLine("Hey mom!"); + } + + public void DoIt(IAnsiConsole thisConsole) + { + thisConsole.WriteLine("Hey mom!"); + } + } +} diff --git a/src/Spectre.Console.Analyzer/Analyzers/BaseAnalyzer.cs b/src/Spectre.Console.Analyzer/Analyzers/BaseAnalyzer.cs new file mode 100644 index 0000000..5886b10 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Analyzers/BaseAnalyzer.cs @@ -0,0 +1,25 @@ +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Spectre.Console.Analyzer +{ + /// + /// Base class for Spectre analyzers. + /// + public abstract class BaseAnalyzer : DiagnosticAnalyzer + { + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(AnalyzeCompilation); + } + + /// + /// Analyze compilation. + /// + /// Compilation Start Analysis Context. + protected abstract void AnalyzeCompilation(CompilationStartAnalysisContext compilationStartContext); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Analyzers/FavorInstanceAnsiConsoleOverStaticAnalyzer.cs b/src/Spectre.Console.Analyzer/Analyzers/FavorInstanceAnsiConsoleOverStaticAnalyzer.cs new file mode 100644 index 0000000..c9a7620 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Analyzers/FavorInstanceAnsiConsoleOverStaticAnalyzer.cs @@ -0,0 +1,89 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Spectre.Console.Analyzer +{ + /// + /// Analyzer to suggest using available instances of AnsiConsole over the static methods. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class FavorInstanceAnsiConsoleOverStaticAnalyzer : BaseAnalyzer + { + private static readonly DiagnosticDescriptor _diagnosticDescriptor = + Descriptors.S1010_FavorInstanceAnsiConsoleOverStatic; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(_diagnosticDescriptor); + + /// + protected override void AnalyzeCompilation(CompilationStartAnalysisContext compilationStartContext) + { + compilationStartContext.RegisterOperationAction( + context => + { + var ansiConsoleType = context.Compilation.GetTypeByMetadataName("Spectre.Console.AnsiConsole"); + + // if this operation isn't an invocation against one of the System.Console methods + // defined in _methods then we can safely stop analyzing and return; + var invocationOperation = (IInvocationOperation)context.Operation; + if (!Equals(invocationOperation.TargetMethod.ContainingType, ansiConsoleType)) + { + return; + } + + if (!HasFieldAnsiConsole(invocationOperation.Syntax) && + !HasParameterAnsiConsole(invocationOperation.Syntax)) + { + return; + } + + var methodSymbol = invocationOperation.TargetMethod; + + var displayString = SymbolDisplay.ToDisplayString( + methodSymbol, + SymbolDisplayFormat.CSharpShortErrorMessageFormat + .WithParameterOptions(SymbolDisplayParameterOptions.None) + .WithGenericsOptions(SymbolDisplayGenericsOptions.None)); + + context.ReportDiagnostic( + Diagnostic.Create( + _diagnosticDescriptor, + invocationOperation.Syntax.GetLocation(), + displayString)); + }, OperationKind.Invocation); + } + + private static bool HasParameterAnsiConsole(SyntaxNode syntaxNode) + { + return syntaxNode + .Ancestors().OfType() + .First() + .ParameterList.Parameters + .Any(i => i.Type.NormalizeWhitespace().ToString() == "IAnsiConsole"); + } + + private static bool HasFieldAnsiConsole(SyntaxNode syntaxNode) + { + var isStatic = syntaxNode + .Ancestors() + .OfType() + .First() + .Modifiers.Any(i => i.Kind() == SyntaxKind.StaticKeyword); + + return syntaxNode + .Ancestors().OfType() + .First() + .Members + .OfType() + .Any(i => + i.Declaration.Type.NormalizeWhitespace().ToString() == "IAnsiConsole" && + (!isStatic ^ i.Modifiers.Any(modifier => modifier.Kind() == SyntaxKind.StaticKeyword))); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Analyzers/UseSpectreInsteadOfSystemConsoleAnalyzer.cs b/src/Spectre.Console.Analyzer/Analyzers/UseSpectreInsteadOfSystemConsoleAnalyzer.cs new file mode 100644 index 0000000..5dbe6c0 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Analyzers/UseSpectreInsteadOfSystemConsoleAnalyzer.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Spectre.Console.Analyzer +{ + /// + /// Analyzer to enforce the use of AnsiConsole over System.Console for known methods. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class UseSpectreInsteadOfSystemConsoleAnalyzer : BaseAnalyzer + { + private static readonly DiagnosticDescriptor _diagnosticDescriptor = + Descriptors.S1000_UseAnsiConsoleOverSystemConsole; + + private static readonly string[] _methods = { "WriteLine", "Write" }; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(_diagnosticDescriptor); + + /// + protected override void AnalyzeCompilation(CompilationStartAnalysisContext compilationStartContext) + { + compilationStartContext.RegisterOperationAction( + context => + { + // if this operation isn't an invocation against one of the System.Console methods + // defined in _methods then we can safely stop analyzing and return; + var invocationOperation = (IInvocationOperation)context.Operation; + var systemConsoleType = context.Compilation.GetTypeByMetadataName("System.Console"); + + if (!Equals(invocationOperation.TargetMethod.ContainingType, systemConsoleType)) + { + return; + } + + var methodName = System.Array.Find(_methods, i => i.Equals(invocationOperation.TargetMethod.Name)); + if (methodName == null) + { + return; + } + + var methodSymbol = invocationOperation.TargetMethod; + + var displayString = SymbolDisplay.ToDisplayString( + methodSymbol, + SymbolDisplayFormat.CSharpShortErrorMessageFormat + .WithParameterOptions(SymbolDisplayParameterOptions.None) + .WithGenericsOptions(SymbolDisplayGenericsOptions.None)); + + context.ReportDiagnostic( + Diagnostic.Create( + _diagnosticDescriptor, + invocationOperation.Syntax.GetLocation(), + displayString)); + }, OperationKind.Invocation); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Constants.cs b/src/Spectre.Console.Analyzer/Constants.cs new file mode 100644 index 0000000..6c14084 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Constants.cs @@ -0,0 +1,8 @@ +namespace Spectre.Console.Analyzer +{ + internal static class Constants + { + internal const string StaticInstance = "AnsiConsole"; + internal const string SpectreConsole = "Spectre.Console"; + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Descriptors.cs b/src/Spectre.Console.Analyzer/Descriptors.cs new file mode 100644 index 0000000..3d4d242 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Descriptors.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; +using Microsoft.CodeAnalysis; +using static Microsoft.CodeAnalysis.DiagnosticSeverity; +using static Spectre.Console.Analyzer.Descriptors.Category; + +namespace Spectre.Console.Analyzer +{ + /// + /// Code analysis descriptors. + /// + public static class Descriptors + { + internal enum Category + { + Usage, // 1xxx + } + + private static readonly ConcurrentDictionary _categoryMapping = new(); + + private static DiagnosticDescriptor Rule(string id, string title, Category category, DiagnosticSeverity defaultSeverity, string messageFormat, string? description = null) + { + var helpLink = $"https://spectreconsole.net/spectre.console.analyzers/rules/{id}"; + const bool IsEnabledByDefault = true; + return new DiagnosticDescriptor(id, title, messageFormat, _categoryMapping.GetOrAdd(category, c => c.ToString()), defaultSeverity, IsEnabledByDefault, description, helpLink); + } + + /// + /// Gets definitions of diagnostics Spectre1000. + /// + public static DiagnosticDescriptor S1000_UseAnsiConsoleOverSystemConsole { get; } = + Rule( + "Spectre1000", + "Use AnsiConsole instead of System.Console", + Usage, + Warning, + "Use AnsiConsole instead of System.Console"); + + /// + /// Gets definitions of diagnostics Spectre1010. + /// + public static DiagnosticDescriptor S1010_FavorInstanceAnsiConsoleOverStatic { get; } = + Rule( + "Spectre1010", + "Favor the use of the instance of AnsiConsole over the static helper.", + Usage, + Info, + "Favor the use of the instance of AnsiConsole over the static helper."); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Fixes/CodeActions/SwitchToAnsiConsoleAction.cs b/src/Spectre.Console.Analyzer/Fixes/CodeActions/SwitchToAnsiConsoleAction.cs new file mode 100644 index 0000000..c1c9e77 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Fixes/CodeActions/SwitchToAnsiConsoleAction.cs @@ -0,0 +1,115 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Spectre.Console.Analyzer.CodeActions +{ + /// + /// Code action to change calls to System.Console to AnsiConsole. + /// + public class SwitchToAnsiConsoleAction : CodeAction + { + private readonly Document _document; + private readonly InvocationExpressionSyntax _originalInvocation; + + /// + /// Initializes a new instance of the class. + /// + /// Document to change. + /// The method to change. + /// Title of the fix. + public SwitchToAnsiConsoleAction(Document document, InvocationExpressionSyntax originalInvocation, string title) + { + _document = document; + _originalInvocation = originalInvocation; + Title = title; + } + + /// + public override string Title { get; } + + /// + public override string EquivalenceKey => Title; + + /// + protected override async Task GetChangedDocumentAsync(CancellationToken cancellationToken) + { + var originalCaller = ((MemberAccessExpressionSyntax)_originalInvocation.Expression).Name.ToString(); + + var syntaxTree = await _document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); + var root = (CompilationUnitSyntax)await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + // If there is an ansiConsole passed into the method then we'll use it. + // otherwise we'll check for a field level instance. + // if neither of those exist we'll fall back to the static param. + var ansiConsoleParameterDeclaration = GetAnsiConsoleParameterDeclaration(); + var ansiConsoleFieldIdentifier = GetAnsiConsoleFieldDeclaration(); + var ansiConsoleIdentifier = ansiConsoleParameterDeclaration ?? + ansiConsoleFieldIdentifier ?? + Constants.StaticInstance; + + // Replace the System.Console call with a call to the identifier above. + var newRoot = root.ReplaceNode( + _originalInvocation, + GetImportedSpectreCall(originalCaller, ansiConsoleIdentifier)); + + // If we are calling the static instance and Spectre isn't imported yet we should do so. + if (ansiConsoleIdentifier == Constants.StaticInstance && root.Usings.ToList().All(i => i.Name.ToString() != Constants.SpectreConsole)) + { + newRoot = newRoot.AddUsings(Syntax.SpectreUsing); + } + + return _document.WithSyntaxRoot(newRoot); + } + + private string? GetAnsiConsoleParameterDeclaration() + { + return _originalInvocation + .Ancestors().OfType() + .First() + .ParameterList.Parameters + .FirstOrDefault(i => i.Type.NormalizeWhitespace().ToString() == "IAnsiConsole") + ?.Identifier.Text; + } + + private string? GetAnsiConsoleFieldDeclaration() + { + // let's look to see if our call is in a static method. + // if so we'll only want to look for static IAnsiConsoles + // and vice-versa if we aren't. + var isStatic = _originalInvocation + .Ancestors() + .OfType() + .First() + .Modifiers.Any(i => i.Kind() == SyntaxKind.StaticKeyword); + + return _originalInvocation + .Ancestors().OfType() + .First() + .Members + .OfType() + .FirstOrDefault(i => + i.Declaration.Type.NormalizeWhitespace().ToString() == "IAnsiConsole" && + (!isStatic ^ i.Modifiers.Any(modifier => modifier.Kind() == SyntaxKind.StaticKeyword))) + ?.Declaration.Variables.First().Identifier.Text; + } + + private ExpressionSyntax GetImportedSpectreCall(string originalCaller, string ansiConsoleIdentifier) + { + return ExpressionStatement( + InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName(ansiConsoleIdentifier), + IdentifierName(originalCaller))) + .WithArgumentList( + _originalInvocation.ArgumentList)) + .Expression; + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Fixes/FixProviders/StaticAnsiConsoleToInstanceFix.cs b/src/Spectre.Console.Analyzer/Fixes/FixProviders/StaticAnsiConsoleToInstanceFix.cs new file mode 100644 index 0000000..53a8b74 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Fixes/FixProviders/StaticAnsiConsoleToInstanceFix.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Spectre.Console.Analyzer.CodeActions; + +namespace Spectre.Console.Analyzer.FixProviders +{ + /// + /// Fix provider to change System.Console calls to AnsiConsole calls. + /// + [ExportCodeFixProvider(LanguageNames.CSharp)] + [Shared] + public class StaticAnsiConsoleToInstanceFix : CodeFixProvider + { + /// + public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + Descriptors.S1010_FavorInstanceAnsiConsoleOverStatic.Id); + + /// + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var methodDeclaration = root.FindNode(context.Span).FirstAncestorOrSelf(); + context.RegisterCodeFix( + new SwitchToAnsiConsoleAction(context.Document, methodDeclaration, "Convert static AnsiConsole calls to local instance."), + context.Diagnostics); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Fixes/FixProviders/SystemConsoleToAnsiConsoleFix.cs b/src/Spectre.Console.Analyzer/Fixes/FixProviders/SystemConsoleToAnsiConsoleFix.cs new file mode 100644 index 0000000..530ebe0 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Fixes/FixProviders/SystemConsoleToAnsiConsoleFix.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Spectre.Console.Analyzer.CodeActions; + +namespace Spectre.Console.Analyzer.FixProviders +{ + /// + /// Fix provider to change System.Console calls to AnsiConsole calls. + /// + [ExportCodeFixProvider(LanguageNames.CSharp)] + [Shared] + public class SystemConsoleToAnsiConsoleFix : CodeFixProvider + { + /// + public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create( + Descriptors.S1000_UseAnsiConsoleOverSystemConsole.Id); + + /// + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var methodDeclaration = root.FindNode(context.Span).FirstAncestorOrSelf(); + context.RegisterCodeFix( + new SwitchToAnsiConsoleAction(context.Document, methodDeclaration, "Convert static call to AnsiConsole to Spectre.Console.AnsiConsole"), + context.Diagnostics); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Fixes/Syntax.cs b/src/Spectre.Console.Analyzer/Fixes/Syntax.cs new file mode 100644 index 0000000..cc0e6e3 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Fixes/Syntax.cs @@ -0,0 +1,10 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Spectre.Console.Analyzer +{ + internal static class Syntax + { + public static readonly UsingDirectiveSyntax SpectreUsing = UsingDirective(QualifiedName(IdentifierName("Spectre"), IdentifierName("Console"))); + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Analyzer/Spectre.Console.Analyzer.csproj b/src/Spectre.Console.Analyzer/Spectre.Console.Analyzer.csproj new file mode 100644 index 0000000..aa0e6b0 --- /dev/null +++ b/src/Spectre.Console.Analyzer/Spectre.Console.Analyzer.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + true + enable + + + + + + + + + + + + + + + + + diff --git a/src/Spectre.Console.Tests/CodeAnalyzer/Analyzers/UseInstanceAnsiConsoleTests.cs b/src/Spectre.Console.Tests/CodeAnalyzer/Analyzers/UseInstanceAnsiConsoleTests.cs new file mode 100644 index 0000000..5c57e7b --- /dev/null +++ b/src/Spectre.Console.Tests/CodeAnalyzer/Analyzers/UseInstanceAnsiConsoleTests.cs @@ -0,0 +1,39 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Spectre.Console.Analyzer; +using Xunit; +using AnalyzerVerify = + Spectre.Console.Tests.CodeAnalyzers.SpectreAnalyzerVerifier< + Spectre.Console.Analyzer.FavorInstanceAnsiConsoleOverStaticAnalyzer>; + +namespace Spectre.Console.Tests.CodeAnalyzers.Analyzers +{ + public class FavorInstanceAnsiConsoleOverStaticAnalyzerTests + { + private static readonly DiagnosticResult _expectedDiagnostics = new( + Descriptors.S1010_FavorInstanceAnsiConsoleOverStatic.Id, + DiagnosticSeverity.Info); + + [Fact] + public async void Console_Write_Has_Warning() + { + const string Source = @" +using Spectre.Console; + +class TestClass +{ + IAnsiConsole _ansiConsole = AnsiConsole.Console; + + void TestMethod() + { + _ansiConsole.Write(""this is fine""); + AnsiConsole.Write(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyAnalyzerAsync(Source, _expectedDiagnostics.WithLocation(11, 9)) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/CodeAnalyzer/Analyzers/UseSpectreInsteadOfSystemConsoleAnalyzerTests.cs b/src/Spectre.Console.Tests/CodeAnalyzer/Analyzers/UseSpectreInsteadOfSystemConsoleAnalyzerTests.cs new file mode 100644 index 0000000..f63ee88 --- /dev/null +++ b/src/Spectre.Console.Tests/CodeAnalyzer/Analyzers/UseSpectreInsteadOfSystemConsoleAnalyzerTests.cs @@ -0,0 +1,53 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Spectre.Console.Analyzer; +using Xunit; +using AnalyzerVerify = + Spectre.Console.Tests.CodeAnalyzers.SpectreAnalyzerVerifier< + Spectre.Console.Analyzer.UseSpectreInsteadOfSystemConsoleAnalyzer>; + +namespace Spectre.Console.Tests.CodeAnalyzers.Analyzers +{ + public class UseSpectreInsteadOfSystemConsoleAnalyzerTests + { + private static readonly DiagnosticResult _expectedDiagnostics = new( + Descriptors.S1000_UseAnsiConsoleOverSystemConsole.Id, + DiagnosticSeverity.Warning); + + [Fact] + public async void Console_Write_Has_Warning() + { + const string Source = @" +using System; + +class TestClass { + void TestMethod() + { + Console.Write(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyAnalyzerAsync(Source, _expectedDiagnostics.WithLocation(7, 9)) + .ConfigureAwait(false); + } + + [Fact] + public async void Console_WriteLine_Has_Warning() + { + const string Source = @" +using System; + +class TestClass +{ + void TestMethod() { + Console.WriteLine(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyAnalyzerAsync(Source, _expectedDiagnostics.WithLocation(7, 9)) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/CodeAnalyzer/CodeFixProviderDiscovery.cs b/src/Spectre.Console.Tests/CodeAnalyzer/CodeFixProviderDiscovery.cs new file mode 100644 index 0000000..daecc51 --- /dev/null +++ b/src/Spectre.Console.Tests/CodeAnalyzer/CodeFixProviderDiscovery.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.VisualStudio.Composition; +using Spectre.Console.Analyzer; +using Spectre.Console.Analyzer.FixProviders; + +namespace Spectre.Console.Tests.CodeAnalyzers +{ + internal static class CodeFixProviderDiscovery + { + private static readonly Lazy _exportProviderFactory; + + static CodeFixProviderDiscovery() + { + _exportProviderFactory = new Lazy( + () => + { + var discovery = new AttributedPartDiscovery(Resolver.DefaultInstance, isNonPublicSupported: true); + var parts = Task.Run(() => discovery.CreatePartsAsync(typeof(SystemConsoleToAnsiConsoleFix).Assembly)).GetAwaiter().GetResult(); + var catalog = ComposableCatalog.Create(Resolver.DefaultInstance).AddParts(parts); + + var configuration = CompositionConfiguration.Create(catalog); + var runtimeComposition = RuntimeComposition.CreateRuntimeComposition(configuration); + return runtimeComposition.CreateExportProviderFactory(); + }, + LazyThreadSafetyMode.ExecutionAndPublication); + } + + public static IEnumerable GetCodeFixProviders(string language) + { + var exportProvider = _exportProviderFactory.Value.CreateExportProvider(); + var exports = exportProvider.GetExports(); + return exports.Where(export => export.Metadata.Languages.Contains(language)).Select(export => export.Value); + } + + private class LanguageMetadata + { + public LanguageMetadata(IDictionary data) + { + if (!data.TryGetValue(nameof(ExportCodeFixProviderAttribute.Languages), out var languages)) + { + languages = Array.Empty(); + } + + Languages = ((string[])languages).ToImmutableArray(); + } + + public ImmutableArray Languages { get; } + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/CodeAnalyzer/Fixes/UseInstanceOfStaticAnsiConsoleTests.cs b/src/Spectre.Console.Tests/CodeAnalyzer/Fixes/UseInstanceOfStaticAnsiConsoleTests.cs new file mode 100644 index 0000000..74a13e1 --- /dev/null +++ b/src/Spectre.Console.Tests/CodeAnalyzer/Fixes/UseInstanceOfStaticAnsiConsoleTests.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Spectre.Console.Analyzer; +using Xunit; +using AnalyzerVerify = + Spectre.Console.Tests.CodeAnalyzers.SpectreAnalyzerVerifier< + Spectre.Console.Analyzer.FavorInstanceAnsiConsoleOverStaticAnalyzer>; + +namespace Spectre.Console.Tests.CodeAnalyzers.Fixes +{ + public class UseInstanceOfStaticAnsiConsoleTests + { + private static readonly DiagnosticResult _expectedDiagnostic = new( + Descriptors.S1010_FavorInstanceAnsiConsoleOverStatic.Id, + DiagnosticSeverity.Info); + + [Fact] + public async Task SystemConsole_replaced_with_AnsiConsole() + { + const string Source = @" +using Spectre.Console; + +class TestClass +{ + IAnsiConsole _ansiConsole = AnsiConsole.Console; + + void TestMethod() + { + _ansiConsole.Write(""this is fine""); + AnsiConsole.Write(""Hello, World""); + } +}"; + + const string FixedSource = @" +using Spectre.Console; + +class TestClass +{ + IAnsiConsole _ansiConsole = AnsiConsole.Console; + + void TestMethod() + { + _ansiConsole.Write(""this is fine""); + _ansiConsole.Write(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyCodeFixAsync(Source, _expectedDiagnostic.WithLocation(11, 9), FixedSource) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/CodeAnalyzer/Fixes/UseSpectreInsteadOfSystemConsoleFixTests.cs b/src/Spectre.Console.Tests/CodeAnalyzer/Fixes/UseSpectreInsteadOfSystemConsoleFixTests.cs new file mode 100644 index 0000000..0524313 --- /dev/null +++ b/src/Spectre.Console.Tests/CodeAnalyzer/Fixes/UseSpectreInsteadOfSystemConsoleFixTests.cs @@ -0,0 +1,152 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Spectre.Console.Analyzer; +using Xunit; +using AnalyzerVerify = + Spectre.Console.Tests.CodeAnalyzers.SpectreAnalyzerVerifier< + Spectre.Console.Analyzer.UseSpectreInsteadOfSystemConsoleAnalyzer>; + +namespace Spectre.Console.Tests.CodeAnalyzers.Fixes +{ + public class UseSpectreInsteadOfSystemConsoleFixTests + { + private static readonly DiagnosticResult _expectedDiagnostic = new( + Descriptors.S1000_UseAnsiConsoleOverSystemConsole.Id, + DiagnosticSeverity.Warning); + + [Fact] + public async Task SystemConsole_replaced_with_AnsiConsole() + { + const string Source = @" +using System; + +class TestClass +{ + void TestMethod() + { + Console.WriteLine(""Hello, World""); + } +}"; + + const string FixedSource = @" +using System; +using Spectre.Console; + +class TestClass +{ + void TestMethod() + { + AnsiConsole.WriteLine(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyCodeFixAsync(Source, _expectedDiagnostic.WithLocation(8, 9), FixedSource) + .ConfigureAwait(false); + } + + [Fact] + public async Task SystemConsole_replaced_with_imported_AnsiConsole() + { + const string Source = @" +using System; + +class TestClass +{ + void TestMethod() + { + Console.WriteLine(""Hello, World""); + } +}"; + + const string FixedSource = @" +using System; +using Spectre.Console; + +class TestClass +{ + void TestMethod() + { + AnsiConsole.WriteLine(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyCodeFixAsync(Source, _expectedDiagnostic.WithLocation(8, 9), FixedSource) + .ConfigureAwait(false); + } + + [Fact] + public async Task SystemConsole_replaced_with_field_AnsiConsole() + { + const string Source = @" +using System; +using Spectre.Console; + +class TestClass +{ + IAnsiConsole _ansiConsole; + + void TestMethod() + { + Console.WriteLine(""Hello, World""); + } +}"; + + const string FixedSource = @" +using System; +using Spectre.Console; + +class TestClass +{ + IAnsiConsole _ansiConsole; + + void TestMethod() + { + _ansiConsole.WriteLine(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyCodeFixAsync(Source, _expectedDiagnostic.WithLocation(11, 9), FixedSource) + .ConfigureAwait(false); + } + + [Fact] + public async Task SystemConsole_replaced_with_static_field_AnsiConsole() + { + const string Source = @" +using System; +using Spectre.Console; + +class TestClass +{ + static IAnsiConsole _ansiConsole; + + static void TestMethod() + { + Console.WriteLine(""Hello, World""); + } +}"; + + const string FixedSource = @" +using System; +using Spectre.Console; + +class TestClass +{ + static IAnsiConsole _ansiConsole; + + static void TestMethod() + { + _ansiConsole.WriteLine(""Hello, World""); + } +}"; + + await AnalyzerVerify + .VerifyCodeFixAsync(Source, _expectedDiagnostic.WithLocation(11, 9), FixedSource) + .ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/CodeAnalyzer/SpectreAnalyzerVerifier.cs b/src/Spectre.Console.Tests/CodeAnalyzer/SpectreAnalyzerVerifier.cs new file mode 100644 index 0000000..2f00a09 --- /dev/null +++ b/src/Spectre.Console.Tests/CodeAnalyzer/SpectreAnalyzerVerifier.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace Spectre.Console.Tests.CodeAnalyzers +{ + public static class SpectreAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + { + public static Task VerifyCodeFixAsync(string source, DiagnosticResult expected, string fixedSource) + => VerifyCodeFixAsync(source, new[] { expected }, fixedSource); + + private static Task VerifyCodeFixAsync(string source, IEnumerable expected, string fixedSource) + { + // Roslyn fixers always use \r\n for newlines, regardless of OS environment settings, so we normalize + // the source as it typically comes from multi-line strings with varying newlines. + if (Environment.NewLine != "\r\n") + { + source = source.Replace(Environment.NewLine, "\r\n"); + fixedSource = fixedSource.Replace(Environment.NewLine, "\r\n"); + } + + var test = new Test + { + TestCode = source, + FixedCode = fixedSource, + }; + + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(); + } + + public static Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + CompilerDiagnostics = CompilerDiagnostics.All, + ReferenceAssemblies = CodeAnalyzerHelper.CurrentSpectre, + TestBehaviors = TestBehaviors.SkipGeneratedCodeCheck, + }; + + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(); + } + + // Code fix tests support both analyzer and code fix testing. This test class is derived from the code fix test + // to avoid the need to maintain duplicate copies of the customization work. + private class Test : CSharpCodeFixTest + { + public Test() + { + ReferenceAssemblies = CodeAnalyzerHelper.CurrentSpectre; + TestBehaviors |= TestBehaviors.SkipGeneratedCodeCheck; + } + + protected override IEnumerable GetCodeFixProviders() + { + var analyzer = new TAnalyzer(); + foreach (var provider in CodeFixProviderDiscovery.GetCodeFixProviders(Language)) + { + if (analyzer.SupportedDiagnostics.Any(diagnostic => provider.FixableDiagnosticIds.Contains(diagnostic.Id))) + { + yield return provider; + } + } + } + } + } + + internal static class CodeAnalyzerHelper + { + internal static ReferenceAssemblies CurrentSpectre { get; } + + static CodeAnalyzerHelper() + { + CurrentSpectre = ReferenceAssemblies.Net.Net50.AddAssemblies(ImmutableArray.Create(typeof(AnsiConsole).Assembly.Location.Replace(".dll", string.Empty))); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.Tests/Spectre.Console.Tests.csproj b/src/Spectre.Console.Tests/Spectre.Console.Tests.csproj index f667424..49f69fc 100644 --- a/src/Spectre.Console.Tests/Spectre.Console.Tests.csproj +++ b/src/Spectre.Console.Tests/Spectre.Console.Tests.csproj @@ -17,6 +17,9 @@ + + + @@ -29,6 +32,7 @@ + diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index 1b6df2e..d59b8d9 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -90,6 +90,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "..\examples\Share EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Live", "..\examples\Console\Live\Live.csproj", "{E607AA2A-A4A6-48E4-8AAB-B0EB74EACAA1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnalyzerTester", "..\examples\Console\AnalyzerTester\AnalyzerTester.csproj", "{D2B32355-D99F-480B-92BF-9FAABE79ADD4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spectre.Console.Analyzer", "Spectre.Console.Analyzer\Spectre.Console.Analyzer.csproj", "{26006ACD-F19D-4C2A-8864-FE0D6C15B58C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -472,6 +476,30 @@ Global {E607AA2A-A4A6-48E4-8AAB-B0EB74EACAA1}.Release|x64.Build.0 = Release|Any CPU {E607AA2A-A4A6-48E4-8AAB-B0EB74EACAA1}.Release|x86.ActiveCfg = Release|Any CPU {E607AA2A-A4A6-48E4-8AAB-B0EB74EACAA1}.Release|x86.Build.0 = Release|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Debug|x64.ActiveCfg = Debug|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Debug|x64.Build.0 = Debug|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Debug|x86.ActiveCfg = Debug|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Debug|x86.Build.0 = Debug|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Release|Any CPU.Build.0 = Release|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Release|x64.ActiveCfg = Release|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Release|x64.Build.0 = Release|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Release|x86.ActiveCfg = Release|Any CPU + {D2B32355-D99F-480B-92BF-9FAABE79ADD4}.Release|x86.Build.0 = Release|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Debug|x64.ActiveCfg = Debug|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Debug|x64.Build.0 = Debug|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Debug|x86.ActiveCfg = Debug|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Debug|x86.Build.0 = Debug|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Release|Any CPU.Build.0 = Release|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Release|x64.ActiveCfg = Release|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Release|x64.Build.0 = Release|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Release|x86.ActiveCfg = Release|Any CPU + {26006ACD-F19D-4C2A-8864-FE0D6C15B58C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -508,6 +536,7 @@ Global {A0C772BA-C5F4-451D-AA7A-4045F2FA0201} = {F0575243-121F-4DEE-9F6B-246E26DC0844} {8428A7DD-29FC-4417-9CA0-B90D34B26AB2} = {A0C772BA-C5F4-451D-AA7A-4045F2FA0201} {E607AA2A-A4A6-48E4-8AAB-B0EB74EACAA1} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {D2B32355-D99F-480B-92BF-9FAABE79ADD4} = {A0C772BA-C5F4-451D-AA7A-4045F2FA0201} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C}