diff --git a/Directory.Build.targets b/Directory.Build.targets index 1d952c9..4b8fe0b 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -21,7 +21,9 @@ + + diff --git a/Finite.Commands.sln b/Finite.Commands.sln index 3e8a35a..ca78fd7 100644 --- a/Finite.Commands.sln +++ b/Finite.Commands.sln @@ -41,6 +41,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Parsing", "Parsing", "{4B61 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Finite.Commands.Parsing.Positional.SourceGenerator", "tools\SourceGenerators\Parsing\Positional\Finite.Commands.Parsing.Positional.SourceGenerator.csproj", "{76C919E7-93B1-4319-900C-08B15312F5B9}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SourceGenerators", "SourceGenerators", "{0DB848EE-2A0B-47C8-9CE6-7F159F04979A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestingHelpers", "tests\SourceGenerators\TestingHelpers\TestingHelpers.csproj", "{E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Models", "Models", "{2C8876C9-5710-40EA-B818-29669B31C4F0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Finite.Commands.Models.AttributedModel.SourceGenerator.UnitTests", "tests\SourceGenerators\Models\AttributedModel\Finite.Commands.Models.AttributedModel.SourceGenerator.UnitTests.csproj", "{1E81186D-DBB8-44FF-8168-B7473A298782}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -162,6 +170,30 @@ Global {76C919E7-93B1-4319-900C-08B15312F5B9}.Release|x64.Build.0 = Release|Any CPU {76C919E7-93B1-4319-900C-08B15312F5B9}.Release|x86.ActiveCfg = Release|Any CPU {76C919E7-93B1-4319-900C-08B15312F5B9}.Release|x86.Build.0 = Release|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Debug|x64.ActiveCfg = Debug|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Debug|x64.Build.0 = Debug|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Debug|x86.ActiveCfg = Debug|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Debug|x86.Build.0 = Debug|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Release|Any CPU.Build.0 = Release|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Release|x64.ActiveCfg = Release|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Release|x64.Build.0 = Release|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Release|x86.ActiveCfg = Release|Any CPU + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE}.Release|x86.Build.0 = Release|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Debug|x64.Build.0 = Debug|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Debug|x86.Build.0 = Debug|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Release|Any CPU.Build.0 = Release|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Release|x64.ActiveCfg = Release|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Release|x64.Build.0 = Release|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Release|x86.ActiveCfg = Release|Any CPU + {1E81186D-DBB8-44FF-8168-B7473A298782}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {545A3778-CEE6-4439-95DF-73BE895CDB1B} = {1912D85B-006D-44DE-B046-164AFB717064} @@ -179,5 +211,9 @@ Global {E7B03E1B-71DA-428C-BCFE-E7CA338CE868} = {AF3EE126-32C3-4DB4-8CE9-9CF1CFBA0627} {4B6105E8-FD91-4AC8-A2C1-BE9448DD52B1} = {6263DEF7-8D9E-4F0B-B608-0C3C9BEC710D} {76C919E7-93B1-4319-900C-08B15312F5B9} = {4B6105E8-FD91-4AC8-A2C1-BE9448DD52B1} + {0DB848EE-2A0B-47C8-9CE6-7F159F04979A} = {4FEFE436-21B1-48D9-9F3E-7AF2F56842FD} + {E374AC1C-D3E1-4DF0-9AA7-CD6F52BFDFFE} = {0DB848EE-2A0B-47C8-9CE6-7F159F04979A} + {2C8876C9-5710-40EA-B818-29669B31C4F0} = {0DB848EE-2A0B-47C8-9CE6-7F159F04979A} + {1E81186D-DBB8-44FF-8168-B7473A298782} = {2C8876C9-5710-40EA-B818-29669B31C4F0} EndGlobalSection EndGlobal diff --git a/samples/Console/TestCommands/HelloWorldCommand.cs b/samples/Console/TestCommands/HelloModule.cs similarity index 67% rename from samples/Console/TestCommands/HelloWorldCommand.cs rename to samples/Console/TestCommands/HelloModule.cs index 08b6ee2..9b586dd 100644 --- a/samples/Console/TestCommands/HelloWorldCommand.cs +++ b/samples/Console/TestCommands/HelloModule.cs @@ -33,5 +33,19 @@ public ValueTask HelloWorldCommand( return new ValueTask(new NoContentCommandResult()); } + + [Command("other")] + public ValueTask OtherCommand( + [Remainder]int coolParameter, string coolerParameter) + { + _logger.LogInformation("Hello world from HelloModule!"); + + _logger.LogInformation( + "The int is {int} and the string is {string}", + coolParameter, + coolerParameter); + + return new ValueTask(new NoContentCommandResult()); + } } } diff --git a/tests/SourceGenerators/Models/AttributedModel/AttributedModelSourceGeneratorTests.NoAttributedModelCommandsGeneratesOnlyDataProvider.cs b/tests/SourceGenerators/Models/AttributedModel/AttributedModelSourceGeneratorTests.NoAttributedModelCommandsGeneratesOnlyDataProvider.cs new file mode 100644 index 0000000..49d30d2 --- /dev/null +++ b/tests/SourceGenerators/Models/AttributedModel/AttributedModelSourceGeneratorTests.NoAttributedModelCommandsGeneratesOnlyDataProvider.cs @@ -0,0 +1,89 @@ +using System; +using NUnit.Framework; +using Finite.Commands; +using Finite.Commands.UnitTests; +using Finite.Commands.AttributedModel.SourceGenerator; +using Finite.Commands.AttributedModel; +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.CSharp; + +namespace Finite.Commands.Models.AttributedModel.SourceGenerator.UnitTests +{ + /// + /// Unit tests for the attributed model source generator. + /// + public class AttributedModelSourceGeneratorTests + { + /// + /// Ensures that the attributed model source generator only generates + /// the data provider type when no commands are defined. + /// + [Test] + public void NoAttributedModelCommandsGeneratesOnlyDataProvider() + { + var helper = new GeneratorTestHelper( + typeof(System.Reflection.Binder).Assembly, + typeof(ICommandResult).Assembly, + typeof(Module).Assembly + ); + + var result = helper.Run( +@"using System; + +namespace UnitTestCode +{ + public class Program + { + public static void Main(string[] args) + { + Console.WriteLine(""No commands""); + } + } +}"); + + Assert.IsEmpty(result.Diagnostics); + Assert.AreEqual(result.GeneratedTrees.Length, 1); + + var text = result.GeneratedTrees[0]; + var expected = CSharpSyntaxTree.ParseText( +@"using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Finite.Commands.AttributedModel.Internal.Commands +{ + internal static class DataProvider + { + private static readonly IAdditionalDataProviderFactory[] Factories + = new IAdditionalDataProviderFactory[] + { + + }; + + public static IEnumerable> GetData( + MethodInfo method) + { + foreach (var factory in Factories) + foreach (var provider in factory.GetDataProvider(method)) + foreach (var kvp in provider.GetData()) + yield return kvp; + } + + public static IEnumerable> GetData( + ParameterInfo parameter) + { + foreach (var factory in Factories) + foreach (var provider in factory.GetDataProvider(parameter)) + foreach (var kvp in provider.GetData()) + yield return kvp; + } + } +}"); + + Assert.IsTrue(text.IsEquivalentTo(expected)); + } + } +} diff --git a/tests/SourceGenerators/Models/AttributedModel/Finite.Commands.Models.AttributedModel.SourceGenerator.UnitTests.csproj b/tests/SourceGenerators/Models/AttributedModel/Finite.Commands.Models.AttributedModel.SourceGenerator.UnitTests.csproj new file mode 100644 index 0000000..8833dd9 --- /dev/null +++ b/tests/SourceGenerators/Models/AttributedModel/Finite.Commands.Models.AttributedModel.SourceGenerator.UnitTests.csproj @@ -0,0 +1,21 @@ + + + + net5.0 + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/tests/SourceGenerators/TestingHelpers/GeneratorTestHelper.cs b/tests/SourceGenerators/TestingHelpers/GeneratorTestHelper.cs new file mode 100644 index 0000000..c10b015 --- /dev/null +++ b/tests/SourceGenerators/TestingHelpers/GeneratorTestHelper.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Finite.Commands.UnitTests +{ + /// + /// Defines a class which can be used to run source generator tests. + /// + public class GeneratorTestHelper + where TGenerator : ISourceGenerator, new() + { + private readonly GeneratorDriver _driver; + private readonly TGenerator _generator; + private readonly Assembly[] _references; + + /// + /// Gets the source generator instance used. + /// + public TGenerator Generator => _generator; + + /// + /// Creates a new instance of the + /// class. + /// + public GeneratorTestHelper(params Assembly[] references) + { + _generator = new TGenerator(); + _driver = CSharpGeneratorDriver.Create(_generator); + _references = references; + } + + /// + /// Runs the generator with the given source code. + /// + /// + /// The source code to pass to the generator. + /// + /// + /// Returns a containing the + /// result of running the generator with the given source code. + /// + public GeneratorDriverRunResult Run(string sourceCode) + { + var compilation = CSharpCompilation.Create("UnitTest", + syntaxTrees: new[] { CSharpSyntaxTree.ParseText(sourceCode) }, + references: _references + .Select(x => MetadataReference.CreateFromFile(x.Location)), + options: new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary)); + + var resultDriver = _driver.RunGenerators(compilation); + return resultDriver.GetRunResult(); + } + } +} diff --git a/tests/SourceGenerators/TestingHelpers/TestingHelpers.csproj b/tests/SourceGenerators/TestingHelpers/TestingHelpers.csproj new file mode 100644 index 0000000..c73908b --- /dev/null +++ b/tests/SourceGenerators/TestingHelpers/TestingHelpers.csproj @@ -0,0 +1,11 @@ + + + + net5.0 + + + + + + + diff --git a/tools/SourceGenerators/Directory.Build.props b/tools/SourceGenerators/Directory.Build.props index 262b2c5..76b4f9f 100644 --- a/tools/SourceGenerators/Directory.Build.props +++ b/tools/SourceGenerators/Directory.Build.props @@ -21,11 +21,12 @@ false true - - $(NoWarn);RS2000;RS2001;RS2002;RS2003;RS2004;RS2005;RS2006;RS2007;RS2008 + + + diff --git a/tools/SourceGenerators/Models/AttributedModel/AnalyzerReleases.Shipped.md b/tools/SourceGenerators/Models/AttributedModel/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..e69de29 diff --git a/tools/SourceGenerators/Models/AttributedModel/AnalyzerReleases.Unshipped.md b/tools/SourceGenerators/Models/AttributedModel/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..91d8f75 --- /dev/null +++ b/tools/SourceGenerators/Models/AttributedModel/AnalyzerReleases.Unshipped.md @@ -0,0 +1,4 @@ +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +FCAM0001 | FiniteCommandsCorrectness | Error | AttributedModelSourceGenerator diff --git a/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.Diagnostics.cs b/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.Diagnostics.cs new file mode 100644 index 0000000..be8e6a0 --- /dev/null +++ b/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.Diagnostics.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.CodeAnalysis; + +namespace Finite.Commands.AttributedModel.SourceGenerator +{ + public partial class AttributedModelSourceGenerator + { + private static readonly DiagnosticDescriptor InvalidCommandReturnTypeRule + = new( + id: "FCAM0001", + title: "Command has invalid return type", + messageFormat: "Command '{0}' has invalid " + + "return type '{1}'", + category: "FiniteCommandsCorrectness", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Commands should return ICommandResult, or an " + + "awaitable type returning ICommandResult." + ); + } +} diff --git a/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.cs b/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.cs index 3c27393..59ace0d 100644 --- a/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.cs +++ b/tools/SourceGenerators/Models/AttributedModel/AttributedModelSourceGenerator.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Finite.Commands.AttributedModel.SourceGenerator @@ -36,6 +37,9 @@ public void Execute(GeneratorExecutionContext context) { var receiver = (SyntaxReceiver)context.SyntaxContextReceiver!; + var commandResultSymbol = context.Compilation + .GetTypeByMetadataName( + "Finite.Commands.ICommandResult"); var groupAttributeSymbol = context.Compilation .GetTypeByMetadataName( "Finite.Commands.AttributedModel.GroupAttribute"); @@ -43,6 +47,7 @@ public void Execute(GeneratorExecutionContext context) .GetTypeByMetadataName( "Finite.Commands.AttributedModel.CommandAttribute"); + Debug.Assert(commandResultSymbol != null); Debug.Assert(groupAttributeSymbol != null); Debug.Assert(commandAttributeSymbol != null); @@ -71,6 +76,23 @@ public void Execute(GeneratorExecutionContext context) "Could not find method symbol for " + $"{method.Identifier}"); + if ( + !IsValidSyncReturnType(methodSymbol.ReturnType, + commandResultSymbol!) + && !IsValidAsyncReturnType(semanticModel, + methodSymbol.ReturnType, commandResultSymbol!)) + { + context.ReportDiagnostic( + Diagnostic.Create( + InvalidCommandReturnTypeRule, + method.GetLocation(), + methodSymbol.ToDisplayString(), + methodSymbol.ReturnType.ToDisplayString())); + + continue; + } + + foreach (var parameterSymbol in methodSymbol.Parameters) { context.AddSource( @@ -85,6 +107,35 @@ public void Execute(GeneratorExecutionContext context) groupAttributeSymbol!, commandAttributeSymbol!)); } } + + static bool IsValidAsyncReturnType(SemanticModel model, + ITypeSymbol returnType, INamedTypeSymbol commandResultType) + { + return returnType.GetMembers() + .OfType() + .FirstOrDefault(x => x.Name == "GetAwaiter") + is IMethodSymbol getAwaiterMethod + + && getAwaiterMethod.ReturnType.GetMembers() + .OfType() + .FirstOrDefault(x => x.Name == "GetResult") + is IMethodSymbol getResultMethod + + && ( + SymbolEqualityComparer.Default.Equals( + getResultMethod.ReturnType, commandResultType) + || getResultMethod.ReturnType.AllInterfaces + .Contains(commandResultType, + SymbolEqualityComparer.Default)); + } + + static bool IsValidSyncReturnType(ITypeSymbol returnType, + INamedTypeSymbol commandResultType) + { + return returnType.AllInterfaces + .Contains(commandResultType, + SymbolEqualityComparer.Default); + } } public void Initialize(GeneratorInitializationContext context) diff --git a/tools/SourceGenerators/Parsing/Positional/AnalyzerReleases.Shipped.md b/tools/SourceGenerators/Parsing/Positional/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..e69de29 diff --git a/tools/SourceGenerators/Parsing/Positional/AnalyzerReleases.Unshipped.md b/tools/SourceGenerators/Parsing/Positional/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..f5fd824 --- /dev/null +++ b/tools/SourceGenerators/Parsing/Positional/AnalyzerReleases.Unshipped.md @@ -0,0 +1,4 @@ +### New Rules +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +FCPP0001 | FiniteCommandsCorrectness | Warning | PositionalSourceGenerator diff --git a/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.Diagnostics.cs b/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.Diagnostics.cs new file mode 100644 index 0000000..d5b877b --- /dev/null +++ b/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.Diagnostics.cs @@ -0,0 +1,20 @@ +using Microsoft.CodeAnalysis; + +namespace Finite.Commands.Parsing.Positional.SourceGenerator +{ + public partial class PositionalSourceGenerator + { + private static readonly DiagnosticDescriptor RemainderParameterMustBeLastRule + = new( + id: "FCPP0001", + title: "Remainder parameter must be last", + messageFormat: "Remainder parameter '{0}' must be the last " + + "parameter in the method", + category: "FiniteCommandsCorrectness", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Parameters marked with RemainderAttribute " + + "should be the last parameter." + ); + } +} diff --git a/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.cs b/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.cs index 506ef81..3a19ee8 100644 --- a/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.cs +++ b/tools/SourceGenerators/Parsing/Positional/PositionalSourceGenerator.cs @@ -49,6 +49,22 @@ public void Execute(GeneratorExecutionContext context) var @class = method.ContainingType; + var position = method.Parameters + .IndexOf(parameter, 0, + SymbolEqualityComparer.Default); + + if (position != method.Parameters.Length - 1) + { + foreach (var location in parameter.Locations) + context.ReportDiagnostic( + Diagnostic.Create( + RemainderParameterMustBeLastRule, + location, + parameter.Name)); + + continue; + } + parameterDataProviders.Add( ( $"ParameterDataProvider__{@class.Name}__{method.Name}__{parameter.Name}",