diff --git a/Plugins/Reqnroll.MSTest.Generator.ReqnrollPlugin/build/Reqnroll.MsTest.props b/Plugins/Reqnroll.MSTest.Generator.ReqnrollPlugin/build/Reqnroll.MsTest.props index a4e2112a1..fdd491d83 100644 --- a/Plugins/Reqnroll.MSTest.Generator.ReqnrollPlugin/build/Reqnroll.MsTest.props +++ b/Plugins/Reqnroll.MSTest.Generator.ReqnrollPlugin/build/Reqnroll.MsTest.props @@ -1,5 +1,9 @@ + + MsTest + + diff --git a/Plugins/Reqnroll.NUnit.Generator.ReqnrollPlugin/build/Reqnroll.NUnit.props b/Plugins/Reqnroll.NUnit.Generator.ReqnrollPlugin/build/Reqnroll.NUnit.props index 0192d6e96..0f3cacd31 100644 --- a/Plugins/Reqnroll.NUnit.Generator.ReqnrollPlugin/build/Reqnroll.NUnit.props +++ b/Plugins/Reqnroll.NUnit.Generator.ReqnrollPlugin/build/Reqnroll.NUnit.props @@ -1,5 +1,10 @@ + + NUnit + true + + diff --git a/Plugins/Reqnroll.xUnit.Generator.ReqnrollPlugin/build/Reqnroll.xUnit.props b/Plugins/Reqnroll.xUnit.Generator.ReqnrollPlugin/build/Reqnroll.xUnit.props index d2d3733b7..72a2fa1fe 100644 --- a/Plugins/Reqnroll.xUnit.Generator.ReqnrollPlugin/build/Reqnroll.xUnit.props +++ b/Plugins/Reqnroll.xUnit.Generator.ReqnrollPlugin/build/Reqnroll.xUnit.props @@ -1,4 +1,8 @@ + + + xunit + diff --git a/Reqnroll.FeatureSourceGenerator/.editorconfig b/Reqnroll.FeatureSourceGenerator/.editorconfig new file mode 100644 index 000000000..4c0a7ea39 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/.editorconfig @@ -0,0 +1,10 @@ +[*] +insert_final_newline = true +indent_size = 4 +indent_style = space + +[*.cs] +csharp_style_namespace_declarations = file_scoped + +[*.{csproj,targets,props}] +indent_size = 2 diff --git a/Reqnroll.FeatureSourceGenerator/AnalyzerConfigOptionsExtensions.cs b/Reqnroll.FeatureSourceGenerator/AnalyzerConfigOptionsExtensions.cs new file mode 100644 index 000000000..cc98490ce --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/AnalyzerConfigOptionsExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Reqnroll.FeatureSourceGenerator; + +internal static class AnalyzerConfigOptionsExtensions +{ + public static string? GetStringValue(this AnalyzerConfigOptions options, string name) + { + if (options.TryGetValue(name, out var value)) + { + return string.IsNullOrEmpty(value) ? null : value; + } + + return null; + } + + public static string? GetStringValue(this AnalyzerConfigOptions options, string name1, string name2) + { + return GetStringValue(options, name1) ?? GetStringValue(options, name2); + } + + public static string? GetStringValue(this AnalyzerConfigOptions options, string name1, string name2, string name3) + { + return GetStringValue(options, name1) ?? GetStringValue(options, name2) ?? GetStringValue(options, name3); + } + + public static bool? GetBooleanValue(this AnalyzerConfigOptions options, string name) + { + if (options.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)) + { + if (bool.TryParse(value, out bool result)) + { + return result; + } + + return false; + } + + return null; + } + + public static bool? GetBooleanValue(this AnalyzerConfigOptions options, string name1, string name2) + { + return GetBooleanValue(options, name1) ?? GetBooleanValue(options, name2); + } + + public static bool? GetBooleanValue(this AnalyzerConfigOptions options, string name1, string name2, string name3) + { + + return GetBooleanValue(options, name1) ?? GetBooleanValue(options, name2) ?? GetBooleanValue(options, name3); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/BuiltInTestFrameworkHandlers.cs b/Reqnroll.FeatureSourceGenerator/BuiltInTestFrameworkHandlers.cs new file mode 100644 index 000000000..492bf7ac7 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/BuiltInTestFrameworkHandlers.cs @@ -0,0 +1,26 @@ +using Reqnroll.FeatureSourceGenerator.MSTest; +using Reqnroll.FeatureSourceGenerator.NUnit; +using Reqnroll.FeatureSourceGenerator.XUnit; + +namespace Reqnroll.FeatureSourceGenerator; + +/// +/// Provides instances of the set of built-in handlers. +/// +internal static class BuiltInTestFrameworkHandlers +{ + /// + /// Gets the handler instance for NUnit. + /// + public static NUnitHandler NUnit { get; } = new(); + + /// + /// Gets the handler instance for MSTest. + /// + public static MSTestHandler MSTest { get; } = new(); + + /// + /// Gets the handler instance for xUnit. + /// + public static XUnitHandler XUnit { get; } = new(); +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpCompilationInformation.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpCompilationInformation.cs new file mode 100644 index 000000000..0e45eb769 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpCompilationInformation.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis.CSharp; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +public record CSharpCompilationInformation( + string? AssemblyName, + ImmutableArray ReferencedAssemblies, + LanguageVersion LanguageVersion, + bool HasNullableReferencesEnabled) : CompilationInformation(AssemblyName, ReferencedAssemblies) +{ + public virtual bool Equals(CSharpCompilationInformation other) + { + if (!base.Equals(other)) + { + return false; + } + + return LanguageVersion.Equals(other.LanguageVersion) && + HasNullableReferencesEnabled.Equals(other.HasNullableReferencesEnabled); + } + + public override int GetHashCode() + { + unchecked // Overflow is fine, just wrap + { + int hash = base.GetHashCode(); + + hash = hash * 43 + LanguageVersion.GetHashCode(); + hash = hash * 43 + HasNullableReferencesEnabled.GetHashCode(); + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpRenderingContext.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpRenderingContext.cs new file mode 100644 index 000000000..8fac2ff21 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpRenderingContext.cs @@ -0,0 +1,13 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; +public class CSharpRenderingContext( + FeatureInformation feature, + string outputHintName, + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions) : TestFixtureSourceRenderingContext(feature, outputHintName) +{ + public CSharpSourceTextWriter Writer { get; } = writer; + + public CSharpRenderingOptions RenderingOptions { get; } = renderingOptions; +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpRenderingOptions.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpRenderingOptions.cs new file mode 100644 index 000000000..39bfcebd2 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpRenderingOptions.cs @@ -0,0 +1,8 @@ +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +/// +/// Defines the options which control the rendering of C# code. +/// +/// Whether to include #line directives to map generated tests to lines in source feature files. +/// Whether to use nullable reference types in generated code. +public record CSharpRenderingOptions(bool EnableLineMapping = true, bool UseNullableReferenceTypes = false); diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSourceTextWriter.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSourceTextWriter.cs new file mode 100644 index 000000000..2d1560d09 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSourceTextWriter.cs @@ -0,0 +1,413 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +public class CSharpSourceTextWriter +{ + class Buffer + { + private readonly StringBuilder _sb = new(); + + public int Line { get; private set; } + + public int Character { get; private set; } + + public void Append(char c) + { + Debug.Assert(!Environment.NewLine.Contains(c), "Use WriteLine to append a line."); + _sb.Append(c); + Character++; + } + + public void Append(string s) + { + Debug.Assert(!s.Contains(Environment.NewLine), "Use WriteLine to append a line."); + _sb.Append(s); + Character++; + } + + public void AppendLine() + { + _sb.AppendLine(); + Character = 0; + Line++; + } + + public void AppendLine(char c) + { + _sb.Append(c); + _sb.AppendLine(); + Character = 0; + Line++; + } + + public void AppendLine(string s) + { + _sb.AppendLine(s); + Character = 0; + Line++; + } + + public override string ToString() => _sb.ToString(); + } + + class CodeBlock + { + public CodeBlock() + { + Depth = 0; + } + + public CodeBlock(CodeBlock parent) + { + Parent = parent; + Depth = parent.Depth + 1; + } + + public int Depth { get; } + + public CodeBlock? Parent { get; } + + public bool InSection { get; set; } + + public bool HasSection { get; set; } + } + + private static readonly UTF8Encoding Encoding = new(false); + + private readonly Buffer _buffer = new(); + + private const string Indent = " "; + + private CodeBlock _context = new(); + + /// + /// Gets the depth of the current block. + /// + public int Depth => _context.Depth; + + /// + /// Gets the position of the writer. + /// + public LinePosition Position => new(_buffer.Line, _buffer.Character); + + /// + /// Gets the number of characters which will be appended to the next new line in the current block. + /// + public int NewLineOffset => Indent.Length * Depth; + + public CSharpSourceTextWriter Write(char c) + { + WriteIndentIfIsFreshLine(); + + _buffer.Append(c); + return this; + } + + public CSharpSourceTextWriter Write(string text) + { + WriteIndentIfIsFreshLine(); + + _buffer.Append(text); + return this; + } + + public CSharpSourceTextWriter WriteLiteralList(IEnumerable values) + { + var first = true; + + foreach (var value in values) + { + if (first) + { + first = false; + } + else + { + Write(", "); + } + + WriterLiteral(value); + } + + return this; + } + + public CSharpSourceTextWriter WriterLiteral(object? value) + { + return value switch + { + null => Write("null"), + string s => WriteLiteral(s), + ImmutableArray array => WriteLiteralArray(array), + ImmutableArray array => WriteLiteralArray(array), + _ => throw new NotSupportedException($"Values of type {value.GetType().FullName} cannot be encoded as a literal value in C#.") + }; + } + + public CSharpSourceTextWriter WriteLiteralArray(ImmutableArray array) + { + if (!CSharpSyntax.TypeAliases.TryGetValue(typeof(T), out var typeName)) + { + typeName = $"global::{typeof(T).FullName}"; + } + + Write("new ").Write(typeName); + + if (array.Length == 0) + { + return Write("[0]"); + } + + return Write("[] { ").WriteLiteralList(array).Write(" }"); + } + + public CSharpSourceTextWriter WriteLiteral(string? s) + { + if (s == null) + { + return Write("null"); + } + + var escapedValue = CSharpSyntax.FormatLiteral(s); + + Write(escapedValue); + return this; + } + + private void WriteIndentIfIsFreshLine() + { + if (_buffer.Character == 0) + { + for (var i = 0; i < Depth; i++) + { + _buffer.Append(Indent); + } + } + } + + public CSharpSourceTextWriter WriteLineDirective(LinePosition start, LinePosition end, int offset, string filePath) + { + // Roslyn uses 0-based indexing for line number and character offset values. + // #line directives use 1-based indexing for line number of character offset values. + return WriteDirective( + $"#line ({start.Line + 1}, {start.Character + 1}) - ({end.Line + 1}, {end.Character + 1}) {offset} \"{filePath}\""); + } + + public CSharpSourceTextWriter WriteDirective(string directive) + { + if (_buffer.Character != 0) + { + throw new InvalidOperationException(CSharpSourceTextWriterResources.CannotAppendDirectiveUnlessAtStartOfLine); + } + + _buffer.AppendLine(directive); + + return this; + } + + public CSharpSourceTextWriter WriteLine() + { + WriteIndentIfIsFreshLine(); + + _buffer.AppendLine(); + + return this; + } + + public CSharpSourceTextWriter WriteLine(string text) + { + WriteIndentIfIsFreshLine(); + + _buffer.AppendLine(text); + + return this; + } + + /// + /// Starts a new code block. + /// + public CSharpSourceTextWriter BeginBlock() + { + _context = new CodeBlock(_context); + return this; + } + + /// + /// Appends the specified text and starts a new block. + /// + /// The text to append. + public CSharpSourceTextWriter BeginBlock(string text) => WriteLine(text).BeginBlock(); + + /// + /// Ends the current block and begins a new line. + /// + /// + /// The builder is not currently in a block (the is zero.) + /// + public CSharpSourceTextWriter EndBlock() + { + if (_context.Parent == null) + { + throw new InvalidOperationException(CSharpSourceTextWriterResources.NotInCodeBlock); + } + + _context = _context.Parent; + return WriteLine(); + } + + /// + /// Ends the current block, appends the specified text and begins a new line. + /// + /// The text to append. + /// + /// The builder is not currently in a block (the is zero.) + /// + public CSharpSourceTextWriter EndBlock(string text) + { + if (_context.Parent == null) + { + throw new InvalidOperationException(CSharpSourceTextWriterResources.NotInCodeBlock); + } + + _context = _context.Parent; + return WriteLine(text); + } + + /// + /// Gets the value of this instance as a string. + /// + /// A string containing all text appended to the builder. + public override string ToString() => _buffer.ToString(); + + public SourceText ToSourceText() + { + return SourceText.From(_buffer.ToString(), Encoding); + } + + public CSharpSourceTextWriter WriteAttributeBlock(AttributeDescriptor attribute) + { + Write('['); + WriteTypeReference(attribute.Type); + + if (attribute.NamedArguments.Count > 0 || attribute.PositionalArguments.Length > 0) + { + Write('('); + + WriteLiteralList(attribute.PositionalArguments); + + var firstProperty = true; + foreach (var (name, value) in attribute.NamedArguments) + { + if (firstProperty) + { + if (!attribute.PositionalArguments.IsEmpty) + { + Write(", "); + } + + firstProperty = false; + } + else + { + Write(", "); + } + + Write(name).Write(" = ").WriterLiteral(value); + } + + Write(')'); + } + + Write(']'); + + return this; + } + + public CSharpSourceTextWriter WriteTypeReference(TypeIdentifier type) + { + return type switch + { + LocalTypeIdentifier localType => WriteTypeReference(localType), + QualifiedTypeIdentifier qualifiedType => WriteTypeReference(qualifiedType), + ArrayTypeIdentifier arrayType => WriteTypeReference(arrayType), + NestedTypeIdentifier nestedType => WriteTypeReference(nestedType), + _ => throw new NotImplementedException($"Appending references of type {type.GetType().Name} is not implemented.") + }; + } + + public CSharpSourceTextWriter WriteTypeReference(LocalTypeIdentifier type) + { + return type switch + { + SimpleTypeIdentifier simpleType => WriteTypeReference(simpleType), + GenericTypeIdentifier genericType => WriteTypeReference(genericType), + _ => throw new NotImplementedException($"Appending references of type {type.GetType().Name} is not implemented.") + }; + } + + public CSharpSourceTextWriter WriteTypeReference(NestedTypeIdentifier type) + { + return WriteTypeReference(type.EncapsulatingType).Write('.').WriteTypeReference(type.LocalType); + } + + public CSharpSourceTextWriter WriteTypeReference(SimpleTypeIdentifier type) + { + Write(type.Name); + + if (type.IsNullable) + { + Write('?'); + } + + return this; + } + + public CSharpSourceTextWriter WriteTypeReference(GenericTypeIdentifier type) + { + Write(type.Name); + + Write('<'); + + WriteTypeReference(type.TypeArguments[0]); + + for (var i = 1; i < type.TypeArguments.Length; i++) + { + Write(','); + WriteTypeReference(type.TypeArguments[i]); + } + + Write('>'); + + if (type.IsNullable) + { + Write('?'); + } + + return this; + } + + public CSharpSourceTextWriter WriteTypeReference(QualifiedTypeIdentifier type) + { + Write("global::").Write(type.Namespace).Write('.'); + + WriteTypeReference(type.LocalType); + + return this; + } + + public CSharpSourceTextWriter WriteTypeReference(ArrayTypeIdentifier type) + { + WriteTypeReference(type.ItemType).Write("[]"); + + if (type.IsNullable) + { + Write('?'); + } + + return this; + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSourceTextWriterResources.resx b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSourceTextWriterResources.resx new file mode 100644 index 000000000..a50b6fcb6 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSourceTextWriterResources.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The CSharpSourceBuilder is already in a code section. + + + Cannot append a directive unless at the start of a line. + + + The CSharpSourceBuilder is not in a code block. + + + The CSharpSourceBuilder is not in a code section. + + \ No newline at end of file diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSyntax.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSyntax.cs new file mode 100644 index 000000000..81e0d1e72 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpSyntax.cs @@ -0,0 +1,122 @@ +using Microsoft.CodeAnalysis.CSharp; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Globalization; +using System.Text; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +internal static class CSharpSyntax +{ + public static IdentifierString ExampleTagsParameterName { get; } = new IdentifierString("_exampleTags"); + + public static readonly Dictionary TypeAliases = new() + { + { typeof(byte), "byte" }, + { typeof(sbyte), "sbyte" }, + { typeof(short), "short" }, + { typeof(ushort), "ushort" }, + { typeof(int), "int" }, + { typeof(uint), "uint" }, + { typeof(long), "long" }, + { typeof(ulong), "ulong" }, + { typeof(float), "float" }, + { typeof(double), "double" }, + { typeof(decimal), "decimal" }, + { typeof(object), "object" }, + { typeof(bool), "bool" }, + { typeof(char), "char" }, + { typeof(string), "string" }, + { typeof(void), "void" } + }; + + public static IdentifierString GenerateTypeIdentifier(string s) => CreateIdentifier(s, capitalizeFirstWord: true); + + public static string CreateMethodIdentifier(string s) => CreateIdentifier(s, capitalizeFirstWord: true); + + public static IdentifierString GenerateParameterIdentifier(string s) => CreateIdentifier(s, capitalizeFirstWord: false); + + private static IdentifierString CreateIdentifier(string s, bool capitalizeFirstWord) + { + var sb = new StringBuilder(); + var newWord = true; + + foreach (char c in s) + { + if (char.IsWhiteSpace(c)) + { + newWord = true; + continue; + } + + if (!IsValidInIdentifier(c)) + { + continue; + } + + if (sb.Length == 0 && !IsValidAsFirstCharacterInIdentifier(c)) + { + sb.Append('_'); + sb.Append(c); + continue; + } + + if (newWord) + { + if (sb.Length == 0 && !capitalizeFirstWord) + { + sb.Append(c); + } + else + { + sb.Append(char.ToUpper(c)); + } + + newWord = false; + } + else + { + sb.Append(c); + } + } + + return new IdentifierString(sb.ToString()); + } + + private static bool IsValidAsFirstCharacterInIdentifier(char c) + { + if (c == '_') + { + return true; + } + + var category = char.GetUnicodeCategory(c); + + return category == UnicodeCategory.UppercaseLetter + || category == UnicodeCategory.LowercaseLetter + || category == UnicodeCategory.TitlecaseLetter + || category == UnicodeCategory.ModifierLetter + || category == UnicodeCategory.OtherLetter; + } + + private static bool IsValidInIdentifier(char c) + { + var category = char.GetUnicodeCategory(c); + + return category == UnicodeCategory.UppercaseLetter + || category == UnicodeCategory.LowercaseLetter + || category == UnicodeCategory.TitlecaseLetter + || category == UnicodeCategory.ModifierLetter + || category == UnicodeCategory.OtherLetter + || category == UnicodeCategory.LetterNumber + || category == UnicodeCategory.NonSpacingMark + || category == UnicodeCategory.SpacingCombiningMark + || category == UnicodeCategory.DecimalDigitNumber + || category == UnicodeCategory.ConnectorPunctuation + || category == UnicodeCategory.Format; + } + + internal static string FormatLiteral(string s) + { + return SymbolDisplay.FormatLiteral(s, true); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureClass.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureClass.cs new file mode 100644 index 000000000..e28d98b49 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureClass.cs @@ -0,0 +1,218 @@ +using System.Collections.Immutable; +using System.Text; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +/// +/// Represents a class which is a test fixture to execute the scenarios associated with a feature. +/// +public class CSharpTestFixtureClass : TestFixtureClass, IEquatable +{ + public CSharpTestFixtureClass( + QualifiedTypeIdentifier identifier, + string hintName, + FeatureInformation feature, + ImmutableArray attributes = default, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base( + identifier, + hintName, + feature, + attributes) + { + Methods = methods.IsDefault ? ImmutableArray.Empty : methods; + RenderingOptions = renderingOptions ?? new CSharpRenderingOptions(); + } + + public CSharpTestFixtureClass( + TestFixtureDescriptor descriptor, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) : base(descriptor) + { + Methods = methods.IsDefault ? ImmutableArray.Empty : methods; + RenderingOptions = renderingOptions ?? new CSharpRenderingOptions(); + } + + private static readonly Encoding Encoding = new UTF8Encoding(false); + + public ImmutableArray Methods { get; } + + public CSharpRenderingOptions RenderingOptions { get; } + + public virtual ImmutableArray NamespaceUsings { get; } = + ImmutableArray.Create(new NamespaceString("System.Linq")); + + public override IEnumerable GetMethods() => Methods; + + public override SourceText Render(CancellationToken cancellationToken = default) + { + var buffer = new CSharpSourceTextWriter(); + + RenderTo(buffer, cancellationToken); + + return SourceText.From(buffer.ToString(), Encoding); + } + + public void RenderTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken = default) + { + + writer.Write("namespace ").Write(Identifier.Namespace).WriteLine(); + writer.BeginBlock("{"); + + if (!NamespaceUsings.IsEmpty) + { + foreach (var import in NamespaceUsings) + { + writer.Write("using ").Write(import).WriteLine(";"); + } + + writer.WriteLine(); + } + + if (!Attributes.IsEmpty) + { + foreach (var attribute in Attributes) + { + cancellationToken.ThrowIfCancellationRequested(); + writer.WriteAttributeBlock(attribute); + writer.WriteLine(); + } + } + + if (RenderingOptions.EnableLineMapping && FeatureInformation.FilePath != null) + { + writer.WriteDirective("#line hidden"); + writer.WriteLine(); + } + + writer.Write("public partial class ").WriteTypeReference(Identifier.LocalType); + + if (!Interfaces.IsEmpty) + { + writer.Write(" :").WriteTypeReference(Interfaces[0]); + + for (var i = 1; i < Interfaces.Length; i++) + { + writer.Write(" ,").WriteTypeReference(Interfaces[i]); + } + } + + writer.WriteLine(); + + writer.BeginBlock("{"); + + RenderTestFixtureContentTo(writer, cancellationToken); + + writer.EndBlock("}"); + writer.EndBlock("}"); + } + + protected virtual void RenderTestFixtureContentTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + RenderFeatureInformationPropertiesTo(writer); + + RenderScenarioInitializeMethodTo(writer, cancellationToken); + + writer.WriteLine(); + + RenderMethodsTo(writer, cancellationToken); + } + + private void RenderFeatureInformationPropertiesTo(CSharpSourceTextWriter writer) + { + writer + .Write("private static readonly string[] FeatureTags = new string[] { ") + .WriteLiteralList(FeatureInformation.Tags) + .WriteLine(" };"); + + writer.WriteLine(); + + writer + .WriteLine("private static readonly global::Reqnroll.FeatureInfo FeatureInfo = new global::Reqnroll.FeatureInfo(") + .BeginBlock() + .Write("new global::System.Globalization.CultureInfo(").WriteLiteral(FeatureInformation.Language).WriteLine("), ") + .WriteLiteral(Path.GetDirectoryName(FeatureInformation.FilePath)).WriteLine(", ") + .WriteLiteral(FeatureInformation.Name).WriteLine(", ") + .WriteLiteral(FeatureInformation.Description).WriteLine(", ") + .WriteLine("global::Reqnroll.ProgrammingLanguage.CSharp, ") + .WriteLine("FeatureTags);") + .EndBlock(); + } + + private void RenderScenarioInitializeMethodTo( + CSharpSourceTextWriter writer, + CancellationToken cancellationToken) + { + writer.WriteLine( + "private global::System.Threading.Tasks.Task ScenarioInitialize(" + + "global::Reqnroll.ITestRunner testRunner, " + + "global::Reqnroll.ScenarioInfo scenarioInfo)"); + + writer.BeginBlock("{"); + + RenderScenarioInitializeMethodBodyTo(writer, cancellationToken); + + writer.WriteLine("return global::System.Threading.Tasks.Task.CompletedTask;"); + + writer.EndBlock("}"); + } + + protected virtual void RenderScenarioInitializeMethodBodyTo( + CSharpSourceTextWriter writer, + CancellationToken cancellationToken) + { + writer.WriteLine("testRunner.OnScenarioInitialize(scenarioInfo);"); + } + + protected virtual void RenderMethodsTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + var first = true; + foreach (var method in Methods) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!first) + { + writer.WriteLine(); + } + + method.RenderTo(writer, RenderingOptions); + + if (first) + { + first = false; + } + } + } + + public override bool Equals(object obj) => Equals(obj as CSharpTestFixtureClass); + + public override string ToString() => $"Identifier={Identifier}"; + + public bool Equals(CSharpTestFixtureClass? other) + { + if (!base.Equals(other)) + { + return false; + } + + return base.Equals(other) && + Methods.SequenceEqual(other!.Methods) && + RenderingOptions.Equals(other.RenderingOptions); + } + + public override int GetHashCode() + { + unchecked + { + var hash = base.GetHashCode(); + + hash *= 83155477 + Methods.GetSequenceHashCode(); + hash *= 83155477 + RenderingOptions.GetHashCode(); + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureGenerator.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureGenerator.cs new file mode 100644 index 000000000..dbc1d5b3f --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureGenerator.cs @@ -0,0 +1,214 @@ +using System.Collections.Immutable; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +/// +/// Provides a base for generating CSharp test fixtures. +/// +/// The test framework handler the generator is associated with. +/// The type of test fixture class produced by the generator. +/// The type of test method produced by the generator. +public abstract class CSharpTestFixtureGenerator(ITestFrameworkHandler testFrameworkHandler) : + ITestFixtureGenerator + where TTestFixtureClass : CSharpTestFixtureClass + where TTestMethod : CSharpTestMethod +{ + public ITestFrameworkHandler TestFrameworkHandler { get; } = testFrameworkHandler; + + protected virtual ImmutableArray GenerateTestFixtureClassAttributes( + TestFixtureGenerationContext context, + CancellationToken cancellationToken) + { + return ImmutableArray.Empty; + } + + protected virtual ImmutableArray GenerateTestMethodParameters( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + + // In the case the scenario defines no examples, we don't pass any parameters. + if (scenario.Examples.IsEmpty) + { + return ImmutableArray.Empty; + } + + // In the case there are examples, we expect to pass arguments to the method, + // the last one being any tags which apply to the example. + // We're assuming that every set defines the same columns, which means we'll only look at the first set. + var headings = scenario.Examples.First().Headings; + var parameters = headings + .Select(heading => new ParameterDescriptor(CSharpSyntax.GenerateParameterIdentifier(heading), CommonTypes.String)) + .ToList(); + + // Append the "example tags" parameter. + parameters.Add( + new ParameterDescriptor(CSharpSyntax.ExampleTagsParameterName, new ArrayTypeIdentifier(CommonTypes.String))); + + return parameters.ToImmutableArray(); + } + + protected abstract ImmutableArray GenerateTestMethodAttributes( + TestMethodGenerationContext context, + CancellationToken cancellationToken); + + public TTestMethod GenerateTestMethod( + TestMethodGenerationContext context, + CancellationToken cancellationToken = default) + { + var descriptor = new TestMethodDescriptor + { + Scenario = context.ScenarioInformation, + Identifier = CSharpSyntax.GenerateTypeIdentifier(context.ScenarioInformation.Name), + StepInvocations = GenerateStepInvocations(context, cancellationToken), + Attributes = GenerateTestMethodAttributes(context, cancellationToken), + Parameters = GenerateTestMethodParameters(context, cancellationToken), + ScenarioParameters = GenerateScenarioParameters(context, cancellationToken) + }; + + return CreateTestMethod(context, descriptor); + } + + protected virtual ImmutableArray GenerateStepInvocations( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + + // In the case the scenario defines no examples, we don't pass any parameters to steps. + if (scenario.Examples.IsEmpty) + { + return scenario.Steps + .Select(step => new StepInvocation( + step.StepType, + step.Position, + step.Keyword, + step.Text, + dataTableArgument: step.DataTableArgument, + docStringArgument: step.DocStringArgument)) + .ToImmutableArray(); + } + + var headings = scenario.Examples.First().Headings; + var argumentMap = headings.ToDictionary(heading => heading, CSharpSyntax.GenerateParameterIdentifier); + + var invocations = new List(); + + // Translate the steps into invocations with arguments where required. + foreach (var step in scenario.Steps) + { + // Look for any example placeholders in the step text to be converted to a format string with arguments. + var arguments = new List(); + var text = step.Text; + + foreach (var (heading, parameter) in argumentMap) + { + var placeholder = $"<{heading}>"; + + if (text.Contains(placeholder)) + { + var index = arguments.Count; + text = text.Replace(placeholder, $"{{{index}}}"); + arguments.Add(parameter); + } + } + + invocations.Add( + new StepInvocation( + step.StepType, + step.Position, + step.Keyword, + text, + arguments.ToImmutableArray(), + step.DataTableArgument, + step.DocStringArgument)); + } + + return invocations.ToImmutableArray(); + } + + protected virtual ImmutableArray> GenerateScenarioParameters( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + + // In the case the scenario defines no examples, we don't pass any parameters. + if (scenario.Examples.IsEmpty) + { + return ImmutableArray>.Empty; + } + + var headings = scenario.Examples.First().Headings; + + return headings + .Select(CSharpSyntax.GenerateParameterIdentifier) + .Select(identifier => new KeyValuePair(identifier, identifier)) + .ToImmutableArray(); + } + + protected abstract TTestMethod CreateTestMethod( + TestMethodGenerationContext context, + TestMethodDescriptor descriptor); + + public TTestFixtureClass GenerateTestFixtureClass( + TestFixtureGenerationContext context, + IEnumerable methods, + CancellationToken cancellationToken = default) + { + var feature = context.FeatureInformation; + var featureTitle = feature.Name; + if (!featureTitle.EndsWith(" Feature", StringComparison.OrdinalIgnoreCase)) + { + featureTitle += " Feature"; + } + + var className = CSharpSyntax.GenerateTypeIdentifier(featureTitle); + var qualifiedClassName = context.TestFixtureNamespace + new SimpleTypeIdentifier(className); + + var descriptor = new TestFixtureDescriptor + { + Identifier = qualifiedClassName, + Feature = feature, + Interfaces = GenerateTestFixtureInterfaces(context, qualifiedClassName, cancellationToken), + Attributes = GenerateTestFixtureClassAttributes(context, cancellationToken), + HintName = context.FeatureHintName + }; + + var generationOptions = new CSharpRenderingOptions( + UseNullableReferenceTypes: context.CompilationInformation.HasNullableReferencesEnabled); + + return CreateTestFixtureClass( + context, + descriptor, + methods.ToImmutableArray(), + generationOptions); + } + + protected virtual ImmutableArray GenerateTestFixtureInterfaces( + TestFixtureGenerationContext context, + QualifiedTypeIdentifier qualifiedClassName, + CancellationToken cancellationToken) + { + return ImmutableArray.Empty; + } + + protected abstract TTestFixtureClass CreateTestFixtureClass( + TestFixtureGenerationContext context, + TestFixtureDescriptor descriptor, + ImmutableArray methods, + CSharpRenderingOptions renderingOptions); + + TestFixtureClass ITestFixtureGenerator.GenerateTestFixtureClass( + TestFixtureGenerationContext context, + IEnumerable methods, + CancellationToken cancellationToken) => GenerateTestFixtureClass(context, methods.Cast(), cancellationToken); + + TestMethod ITestFixtureGenerator.GenerateTestMethod( + TestMethodGenerationContext context, + CancellationToken cancellationToken) => GenerateTestMethod(context, cancellationToken); + + public virtual bool CanGenerateForCompilation(CSharpCompilationInformation compilation) => true; +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureSourceGenerator.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureSourceGenerator.cs new file mode 100644 index 000000000..41a7e4a34 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestFixtureSourceGenerator.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis.CSharp; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +/// +/// A generator of Reqnroll test fixtures for the C# language. +/// +[Generator(LanguageNames.CSharp)] +public class CSharpTestFixtureSourceGenerator : TestFixtureSourceGenerator +{ + /// + /// Initializes a new instance of the class. + /// + public CSharpTestFixtureSourceGenerator() + { + } + + /// + /// Initializes a new instance of the class specifying + /// the test framework handlers to be used. + /// + /// The handlers to use. + internal CSharpTestFixtureSourceGenerator(params ITestFrameworkHandler[] handlers) : base(handlers.ToImmutableArray()) + { + } + + protected override CSharpCompilationInformation GetCompilationInformation(Compilation compilation) + { + var cSharpCompilation = (CSharpCompilation)compilation; + + return new CSharpCompilationInformation( + compilation.AssemblyName, + compilation.ReferencedAssemblyNames.ToImmutableArray(), + cSharpCompilation.LanguageVersion, + cSharpCompilation.Options.NullableContextOptions != NullableContextOptions.Disable); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestMethod.cs b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestMethod.cs new file mode 100644 index 000000000..e9b146fd2 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/CSharpTestMethod.cs @@ -0,0 +1,350 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +public class CSharpTestMethod : TestMethod, IEquatable +{ + public CSharpTestMethod( + IdentifierString identifier, + ScenarioInformation scenario, + ImmutableArray stepInvocations, + ImmutableArray attributes = default, + ImmutableArray parameters = default, + ImmutableArray> scenarioParameters = default) + : base(identifier, scenario, stepInvocations, attributes, parameters, scenarioParameters) + { + } + + public CSharpTestMethod(TestMethodDescriptor descriptor) : base(descriptor) + { + } + + public override bool Equals(object obj) => Equals(obj as CSharpTestMethod); + + public bool Equals(CSharpTestMethod? other) => base.Equals(other); + + public override int GetHashCode() => base.GetHashCode(); + + public void RenderTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken = default) + { + if (!Attributes.IsEmpty) + { + foreach (var attribute in Attributes) + { + cancellationToken.ThrowIfCancellationRequested(); + writer.WriteAttributeBlock(attribute); + writer.WriteLine(); + } + } + + // Our test methods are always asynchronous and never return a value. + writer.Write("public async Task ").Write(Identifier); + + if (!Parameters.IsEmpty) + { + writer.BeginBlock("("); + + var first = true; + foreach (var parameter in Parameters) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!first) + { + writer.WriteLine(","); + } + + writer + .WriteTypeReference(parameter.Type) + .Write(' ') + .Write(parameter.Name); + + first = false; + } + + writer.EndBlock(")"); + } + else + { + writer.WriteLine("()"); + } + + writer.BeginBlock("{"); + + RenderMethodBodyTo(writer, renderingOptions, cancellationToken); + + writer.EndBlock("}"); + } + + protected virtual void RenderMethodBodyTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + RenderTestRunnerLookupTo(writer, renderingOptions, cancellationToken); + writer.WriteLine(); + + RenderScenarioInfoTo(writer, renderingOptions, cancellationToken); + writer.WriteLine(); + + writer.WriteLine("try"); + writer.BeginBlock("{"); + + if (renderingOptions.EnableLineMapping) + { + writer.WriteLineDirective( + Scenario.KeywordAndNamePosition.StartLinePosition, + Scenario.KeywordAndNamePosition.EndLinePosition, + writer.NewLineOffset, + Scenario.KeywordAndNamePosition.Path); + } + + writer.WriteLine("await ScenarioInitialize(testRunner, scenarioInfo);"); + + if (renderingOptions.EnableLineMapping) + { + writer.WriteDirective("#line hidden"); + } + + writer.WriteLine(); + + writer.WriteLine("if (global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags))"); + writer.BeginBlock("{"); + writer.WriteLine("testRunner.SkipScenario();"); + writer.EndBlock("}"); + writer.WriteLine("else"); + writer.BeginBlock("{"); + writer.WriteLine("await testRunner.OnScenarioStartAsync();"); + writer.WriteLine(); + writer.WriteLine("// start: invocation of scenario steps"); + + for (var i = 0; i < StepInvocations.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var invocation = StepInvocations[i]; + RenderScenarioStepInvocationTo(invocation, i, writer, renderingOptions, cancellationToken); + } + + writer.WriteLine("// end: invocation of scenario steps"); + writer.EndBlock("}"); + writer.WriteLine(); + writer.WriteLine("// finishing the scenario"); + writer.WriteLine("await testRunner.CollectScenarioErrorsAsync();"); + + writer.EndBlock("}"); + writer.WriteLine("finally"); + writer.BeginBlock("{"); + writer.WriteLine("await testRunner.OnScenarioEndAsync();"); + writer.EndBlock("}"); + } + + protected virtual void RenderScenarioStepInvocationTo( + StepInvocation invocation, + int index, + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + var tableArgName = invocation.DataTableArgument is null ? "null" : $"step{index}DataTableArg"; + var docStringArgName = invocation.DocStringArgument is null ? "null" : $"step{index}DocStringArg"; + + if (invocation.DataTableArgument is not null) + { + writer.WriteLine($"var {tableArgName} = new global::Reqnroll.DataTable("); + writer.BeginBlock(); + var firstHeading = true; + foreach (var heading in invocation.DataTableArgument.Headings) + { + if (firstHeading) + { + firstHeading = false; + } + else + { + writer.WriteLine(","); + } + + writer.WriteLiteral(heading); + } + writer.WriteLine(");"); + writer.EndBlock(); + + foreach (var row in invocation.DataTableArgument.Rows) + { + writer.Write(tableArgName).WriteLine(".AddRow("); + writer.BeginBlock(); + var firstColumn = true; + foreach (var cell in row) + { + if (firstColumn) + { + firstColumn = false; + } + else + { + writer.WriteLine(","); + } + + writer.WriteLiteral(cell); + } + writer.WriteLine(");"); + writer.EndBlock(); + } + } + + if (invocation.DocStringArgument is not null) + { + writer.WriteLine($"var {tableArgName} = "); + writer.BeginBlock(); + + var firstLine = true; + + foreach (var line in invocation.DocStringArgument.Split()) + { + if (firstLine) + { + firstLine = false; + } + else + { + writer.WriteLine(" +"); + } + + writer.Write('"'); + writer.WriteLiteral(line); + writer.Write('"'); + } + + writer.WriteLine(";"); + + writer.EndBlock(); + } + + var position = invocation.Position; + + if (position.Path != null && renderingOptions.EnableLineMapping) + { + writer.WriteLineDirective( + position.StartLinePosition, + position.EndLinePosition, + writer.NewLineOffset, + position.Path); + } + + writer + .Write("await testRunner.") + .Write( + invocation.Type switch + { + StepType.Context => "Given", + StepType.Action => "When", + StepType.Outcome => "Then", + StepType.Conjunction => "And", + _ => throw new NotSupportedException() + }) + .Write("Async("); + + if (invocation.Arguments.IsEmpty) + { + writer.WriteLiteral(invocation.Text); + } + else + { + writer.Write("string.Format(").WriteLiteral(invocation.Text); + + foreach (var argument in invocation.Arguments) + { + writer.Write(", ").Write(argument); + } + + writer.Write(")"); + } + + writer + .Write(", null, ") + .Write(tableArgName) + .Write(", ") + .WriteLiteral(invocation.Keyword) + .WriteLine(");"); + + if (renderingOptions.EnableLineMapping) + { + writer.WriteDirective("#line hidden"); + } + } + + protected virtual void RenderScenarioInfoTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + writer.WriteLine("// start: calculate ScenarioInfo"); + writer + .Write("var tagsOfScenario = new string[] { ") + .WriteLiteralList(Scenario.Tags) + .Write(" }"); + + // If a parameter has been defined for passing tags from the example, include it in the scenario's tags. + var exampleTagsParameter = Parameters.FirstOrDefault(parameter => parameter.Name == CSharpSyntax.ExampleTagsParameterName); + if (exampleTagsParameter != null) + { + writer.Write(".Concat(").Write(exampleTagsParameter.Name).Write(").ToArray()"); + } + writer.WriteLine(";"); + + writer.WriteLine( + "var argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); // needed for scenario outlines"); + + foreach (var (name, value) in ParametersOfScenario) + { + writer + .Write("argumentsOfScenario.Add(").WriteLiteral(name).Write(", ").Write(value).Write(");"); + } + + if (Scenario.Rule == null) + { + writer.WriteLine("var inheritedTags = FeatureTags;"); + } + else + { + writer + .Write("var ruleTags = new string[] { ") + .WriteLiteralList(Scenario.Rule.Tags) + .WriteLine(" };"); + + writer.WriteLine("var inheritedTags = FeatureTags.Concat(ruleTags).ToArray();"); + } + + writer + .Write("var scenarioInfo = new global::Reqnroll.ScenarioInfo(") + .WriteLiteral(Scenario.Name) + .WriteLine(", null, tagsOfScenario, argumentsOfScenario, inheritedTags);"); + writer.WriteLine("// end: calculate ScenarioInfo"); + } + + /// + /// Renders the code to provide the test runner instance for the test method. + /// + /// The source builder to append the code to. + /// Options which control the rendering of the C# code. + /// A token used to signal when rendering should be canceled. + /// + /// Implementations of this method must append code to achieve the following: + /// + /// Declare a local variable named testRunner of a type which can be assigned to type Reqnroll.TestRunner. + /// Assign to the testRunner variable the instance to use as the test runner for the scenario. + /// + /// + protected virtual void RenderTestRunnerLookupTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + writer.WriteLine("var testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly();"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTest2CSharpTestFixtureGenerator.cs b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTest2CSharpTestFixtureGenerator.cs new file mode 100644 index 000000000..3acf8b2e0 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTest2CSharpTestFixtureGenerator.cs @@ -0,0 +1,73 @@ +using Reqnroll.FeatureSourceGenerator.MSTest; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.MSTest; + +/// +/// Performs generation of MSTest 2.x test fixtures in the C# language. +/// +internal class MSTest2CSharpTestFixtureGenerator(MSTestHandler frameworkHandler) : + CSharpTestFixtureGenerator(frameworkHandler) +{ + protected override MSTestCSharpTestFixtureClass CreateTestFixtureClass( + TestFixtureGenerationContext context, + TestFixtureDescriptor descriptor, + ImmutableArray methods, + CSharpRenderingOptions renderingOptions) + { + return new MSTestCSharpTestFixtureClass(descriptor, methods, renderingOptions); + } + + protected override MSTestCSharpTestMethod CreateTestMethod( + TestMethodGenerationContext context, + TestMethodDescriptor descriptor) + { + return new MSTestCSharpTestMethod(descriptor); + } + + protected override ImmutableArray GenerateTestFixtureClassAttributes( + TestFixtureGenerationContext context, + CancellationToken cancellationToken) + { + return ImmutableArray.Create(MSTestSyntax.TestClassAttribute()); + } + + protected override ImmutableArray GenerateTestMethodAttributes( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + var feature = context.FeatureInformation; + + var attributes = new List + { + MSTestSyntax.TestMethodAttribute(), + MSTestSyntax.DescriptionAttribute(scenario.Name), + MSTestSyntax.TestPropertyAttribute("FeatureTitle", feature.Name) + }; + + foreach (var set in scenario.Examples) + { + foreach (var example in set) + { + cancellationToken.ThrowIfCancellationRequested(); + + // DataRow's constructor is DataRow(object? data, params object?[] moreData) + // Because we often pass an array of strings as a second argument, we always wrap moreData + // in an explicit array to avoid the compiler mistaking our string array as the moreData value. + var values = example.Select(item => item.Value).ToList(); + var data = values.First(); + var moreData = values.Skip(1).ToList(); + + moreData.Add(set.Tags); + + var arguments = moreData.Count > 0 ? ImmutableArray.Create(data, moreData.ToImmutableArray()) : ImmutableArray.Create(data); + + attributes.Add(MSTestSyntax.DataRowAttribute(arguments)); + } + } + + return attributes.ToImmutableArray(); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTest3CSharpTestFixtureGenerator.cs b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTest3CSharpTestFixtureGenerator.cs new file mode 100644 index 000000000..75f0462eb --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTest3CSharpTestFixtureGenerator.cs @@ -0,0 +1,64 @@ +using Reqnroll.FeatureSourceGenerator.MSTest; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.MSTest; + +/// +/// Performs generation of MSTest 3.x test fixtures in the C# language. +/// +internal class MSTest3CSharpTestFixtureGenerator(MSTestHandler frameworkHandler) : + CSharpTestFixtureGenerator(frameworkHandler) +{ + protected override MSTestCSharpTestFixtureClass CreateTestFixtureClass( + TestFixtureGenerationContext context, + TestFixtureDescriptor descriptor, + ImmutableArray methods, + CSharpRenderingOptions renderingOptions) + { + return new MSTestCSharpTestFixtureClass(descriptor, methods, renderingOptions); + } + + protected override MSTestCSharpTestMethod CreateTestMethod( + TestMethodGenerationContext context, + TestMethodDescriptor descriptor) + { + return new MSTestCSharpTestMethod(descriptor); + } + + protected override ImmutableArray GenerateTestFixtureClassAttributes( + TestFixtureGenerationContext context, + CancellationToken cancellationToken) + { + return ImmutableArray.Create(MSTestSyntax.TestClassAttribute()); + } + + protected override ImmutableArray GenerateTestMethodAttributes( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + var feature = context.FeatureInformation; + + var attributes = new List + { + MSTestSyntax.TestMethodAttribute(), + MSTestSyntax.DescriptionAttribute(scenario.Name), + MSTestSyntax.TestPropertyAttribute("FeatureTitle", feature.Name) + }; + + foreach (var set in scenario.Examples) + { + foreach (var example in set) + { + cancellationToken.ThrowIfCancellationRequested(); + + var arguments = example.Select(item => item.Value).ToImmutableArray(); + + attributes.Add(MSTestSyntax.DataRowAttribute(arguments)); + } + } + + return attributes.ToImmutableArray(); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTestCSharpTestFixtureClass.cs b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTestCSharpTestFixtureClass.cs new file mode 100644 index 000000000..4d5bed339 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTestCSharpTestFixtureClass.cs @@ -0,0 +1,105 @@ +using System.Collections.Immutable; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.MSTest; +public class MSTestCSharpTestFixtureClass : CSharpTestFixtureClass +{ + public MSTestCSharpTestFixtureClass( + QualifiedTypeIdentifier identifier, + string hintName, + FeatureInformation feature, + ImmutableArray attributes = default, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base(identifier, hintName, feature, attributes, methods.CastArray(), renderingOptions) + { + } + + public MSTestCSharpTestFixtureClass( + TestFixtureDescriptor descriptor, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base(descriptor, methods.CastArray(), renderingOptions) + { + } + + protected override void RenderTestFixtureContentTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + RenderTestRunnerFieldTo(writer); + + writer.WriteLine(); + + RenderTestContextPropertyTo(writer); + + writer.WriteLine(); + + RenderClassInitializeMethodTo(writer, cancellationToken); + + writer.WriteLine(); + + RenderClassCleanupMethodTo(writer, cancellationToken); + + writer.WriteLine(); + + base.RenderTestFixtureContentTo(writer, cancellationToken); + } + + private void RenderTestRunnerFieldTo(CSharpSourceTextWriter writer) + { + writer.Write("private static global::Reqnroll.ITestRunner"); + if (RenderingOptions.UseNullableReferenceTypes) + { + writer.Write("?"); + } + writer.WriteLine(" TestRunner;"); + } + + private void RenderTestContextPropertyTo(CSharpSourceTextWriter writer) + { + writer.Write("public global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext"); + if (RenderingOptions.UseNullableReferenceTypes) + { + writer.Write("?"); + } + writer.WriteLine(" TestContext { get; set; }"); + } + + protected virtual void RenderClassInitializeMethodTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + writer + .WriteLine("[global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitialize]") + .WriteLine("public static Task InitializeFeatureAsync(global::Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext)") + .BeginBlock("{") + .WriteLine("TestRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly();") + .WriteLine("return TestRunner.OnFeatureStartAsync(FeatureInfo);") + .EndBlock("}"); + } + + protected virtual void RenderClassCleanupMethodTo( + CSharpSourceTextWriter writer, + CancellationToken cancellationToken) + { + writer + .WriteLine("[global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanup(" + + "Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)]") + .WriteLine("public static async Task TeardownFeatureAsync()") + .BeginBlock("{") + .Write("if (TestRunner == null)") + .BeginBlock("{") + .WriteLine("return;") + .EndBlock("}") + .WriteLine("await TestRunner.OnFeatureEndAsync();") + .WriteLine("global::Reqnroll.TestRunnerManager.ReleaseTestRunner(TestRunner);") + .WriteLine("TestRunner = null;") + .EndBlock("}"); + } + + protected override void RenderScenarioInitializeMethodBodyTo( + CSharpSourceTextWriter writer, + CancellationToken cancellationToken) + { + base.RenderScenarioInitializeMethodBodyTo(writer, cancellationToken); + + writer.WriteLine("testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(TestContext);"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTestCSharpTestMethod.cs b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTestCSharpTestMethod.cs new file mode 100644 index 000000000..11f690c65 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/MSTest/MSTestCSharpTestMethod.cs @@ -0,0 +1,43 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.MSTest; +public class MSTestCSharpTestMethod : CSharpTestMethod +{ + public MSTestCSharpTestMethod( + IdentifierString identifier, + ScenarioInformation scenario, + ImmutableArray stepInvocations, + ImmutableArray attributes = default, + ImmutableArray parameters = default, + ImmutableArray> scenarioParameters = default) + : base(identifier, scenario, stepInvocations, attributes, parameters, scenarioParameters) + { + } + + public MSTestCSharpTestMethod(TestMethodDescriptor descriptor) : base(descriptor) + { + } + + protected override void RenderTestRunnerLookupTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + // For MSTest we use a test-runner assigned to the class. + writer + .Write("global::Reqnroll.ITestRunner"); + + if (renderingOptions.UseNullableReferenceTypes) + { + writer.Write('?'); + } + + writer + .WriteLine(" testRunner = TestRunner;") + .WriteLine("if (testRunner == null)") + .BeginBlock("{") + .WriteLine("throw new global::System.InvalidOperationException(\"TestRunner has not been assigned to the test fixture.\");") + .EndBlock("}"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestFixtureClass.cs b/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestFixtureClass.cs new file mode 100644 index 000000000..9e8b5b039 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestFixtureClass.cs @@ -0,0 +1,91 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.NUnit; +public class NUnitCSharpTestFixtureClass : CSharpTestFixtureClass +{ + public NUnitCSharpTestFixtureClass( + QualifiedTypeIdentifier identifier, + string hintName, + FeatureInformation feature, + ImmutableArray attributes = default, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base(identifier, hintName, feature, attributes, methods.CastArray(), renderingOptions) + { + } + + public NUnitCSharpTestFixtureClass( + TestFixtureDescriptor descriptor, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base(descriptor, methods.CastArray(), renderingOptions) + { + } + + protected override void RenderTestFixtureContentTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + writer.Write("private global::Reqnroll.ITestRunner"); + + if (RenderingOptions.UseNullableReferenceTypes) + { + writer.Write('?'); + } + + writer.WriteLine(" _testRunner;"); + + writer.WriteLine(); + + RenderFeatureSetupMethodTo(writer); + + writer.WriteLine(); + + RenderFeatureTearDownMethodTo(writer); + + writer.WriteLine(); + + base.RenderTestFixtureContentTo(writer, cancellationToken); + } + + private void RenderFeatureSetupMethodTo(CSharpSourceTextWriter writer) + { + writer + .WriteLine("[global::NUnit.Framework.OneTimeSetUp]") + .WriteLine("public virtual global::System.Threading.Tasks.Task FeatureSetupAsync()") + .BeginBlock("{") + .WriteLine("_testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly();") + .WriteLine("return _testRunner.OnFeatureStartAsync(FeatureInfo);") + .EndBlock("}"); + } + + private void RenderFeatureTearDownMethodTo(CSharpSourceTextWriter writer) + { + writer + .WriteLine("[global::NUnit.Framework.OneTimeTearDown]") + .WriteLine("public async virtual global::System.Threading.Tasks.Task FeatureTearDownAsync()") + .BeginBlock("{"); + + writer.Write("await _testRunner"); + + if (RenderingOptions.UseNullableReferenceTypes) + { + writer.Write('!'); + } + + writer.WriteLine(".OnFeatureEndAsync();"); + + writer + .WriteLine("global::Reqnroll.TestRunnerManager.ReleaseTestRunner(_testRunner);") + .WriteLine("_testRunner = null;") + .EndBlock("}"); + } + + protected override void RenderScenarioInitializeMethodBodyTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + base.RenderScenarioInitializeMethodBodyTo(writer, cancellationToken); + + writer.WriteLine( + "testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(" + + "global::NUnit.Framework.TestContext.CurrentContext);"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestFixtureGenerator.cs b/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestFixtureGenerator.cs new file mode 100644 index 000000000..12a5d16e0 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestFixtureGenerator.cs @@ -0,0 +1,77 @@ +using Reqnroll.FeatureSourceGenerator.NUnit; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.NUnit; +internal class NUnitCSharpTestFixtureGenerator(NUnitHandler frameworkHandler) : + CSharpTestFixtureGenerator(frameworkHandler) +{ + protected override NUnitCSharpTestFixtureClass CreateTestFixtureClass( + TestFixtureGenerationContext context, + TestFixtureDescriptor descriptor, + ImmutableArray methods, + CSharpRenderingOptions renderingOptions) + { + return new NUnitCSharpTestFixtureClass(descriptor, methods, renderingOptions); + } + + protected override NUnitCSharpTestMethod CreateTestMethod( + TestMethodGenerationContext context, + TestMethodDescriptor descriptor) + { + return new NUnitCSharpTestMethod(descriptor); + } + + protected override ImmutableArray GenerateTestFixtureClassAttributes( + TestFixtureGenerationContext context, + CancellationToken cancellationToken) + { + return ImmutableArray.Create(NUnitSyntax.DescriptionAttribute(context.FeatureInformation.Name)); + } + + protected override ImmutableArray GenerateTestMethodAttributes( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + var feature = context.FeatureInformation; + + var attributes = new List(); + + if (scenario.Examples.IsEmpty) + { + attributes.Add(NUnitSyntax.TestAttribute()); + } + + attributes.Add(NUnitSyntax.DescriptionAttribute(context.ScenarioInformation.Name)); + + foreach (var tag in scenario.Tags) + { + attributes.Add(NUnitSyntax.CategoryAttribute(tag)); + } + + if (scenario.Tags.Contains("ignore")) + { + attributes.Add(NUnitSyntax.IgnoreAttribute("Ignored scenario")); + } + + foreach (var set in scenario.Examples) + { + foreach (var example in set) + { + cancellationToken.ThrowIfCancellationRequested(); + + var values = example.Select(exampleValue => (object?)exampleValue.Value).ToList(); + + values.Add(set.Tags); + + string? category = set.Tags.IsEmpty ? null : string.Join(",", set.Tags); + string? ignore = set.Tags.Contains("ignore", StringComparer.OrdinalIgnoreCase) ? "Ignored by @ignore tag" : null; + + attributes.Add(NUnitSyntax.TestCaseAttribute(values.ToImmutableArray(), category, ignore)); + } + } + + return attributes.ToImmutableArray(); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestMethod.cs b/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestMethod.cs new file mode 100644 index 000000000..b0e407fd6 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/NUnit/NUnitCSharpTestMethod.cs @@ -0,0 +1,43 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.NUnit; +public class NUnitCSharpTestMethod : CSharpTestMethod +{ + public NUnitCSharpTestMethod( + IdentifierString identifier, + ScenarioInformation scenario, + ImmutableArray stepInvocations, + ImmutableArray attributes = default, + ImmutableArray parameters = default, + ImmutableArray> scenarioParameters = default) + : base(identifier, scenario, stepInvocations, attributes, parameters, scenarioParameters) + { + } + + public NUnitCSharpTestMethod(TestMethodDescriptor descriptor) : base(descriptor) + { + } + + protected override void RenderTestRunnerLookupTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + // For NUnit we use a test-runner assigned to the class. + writer.Write("global::Reqnroll.ITestRunner"); + + if (renderingOptions.UseNullableReferenceTypes) + { + writer.Write('?'); + } + + writer.WriteLine(" testRunner = _testRunner;"); + + writer + .WriteLine("if (testRunner == null)") + .BeginBlock("{") + .WriteLine("throw new global::System.InvalidOperationException(\"TestRunner has not been assigned to the test fixture.\");") + .EndBlock("}"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/TestFixtureDescriptor.cs b/Reqnroll.FeatureSourceGenerator/CSharp/TestFixtureDescriptor.cs new file mode 100644 index 000000000..d95f80f6f --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/TestFixtureDescriptor.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; + +public class TestFixtureDescriptor +{ + public QualifiedTypeIdentifier? Identifier { get; set; } + + public string? HintName { get; set; } + + public FeatureInformation? Feature { get; set; } + + public ImmutableArray Interfaces { get; set; } + + public ImmutableArray Attributes { get; set; } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestFixtureClass.cs b/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestFixtureClass.cs new file mode 100644 index 000000000..724e6f7a6 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestFixtureClass.cs @@ -0,0 +1,134 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using Reqnroll.FeatureSourceGenerator.XUnit; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.XUnit; +public class XUnitCSharpTestFixtureClass : CSharpTestFixtureClass +{ + public XUnitCSharpTestFixtureClass( + QualifiedTypeIdentifier identifier, + string hintName, + FeatureInformation feature, + ImmutableArray attributes = default, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base(identifier, hintName, feature, attributes, methods.CastArray(), renderingOptions) + { + Interfaces = ImmutableArray.Create( + XUnitSyntax.LifetimeInterfaceType(Identifier)); + } + + public XUnitCSharpTestFixtureClass( + TestFixtureDescriptor descriptor, + ImmutableArray methods = default, + CSharpRenderingOptions? renderingOptions = null) + : base(descriptor, methods.CastArray(), renderingOptions) + { + Interfaces = ImmutableArray.Create( + XUnitSyntax.LifetimeInterfaceType(Identifier)); + } + + public override ImmutableArray Interfaces { get; } + + protected override void RenderTestFixtureContentTo( + CSharpSourceTextWriter writer, + CancellationToken cancellationToken) + { + RenderLifetimeClassTo(writer, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + writer.WriteLine("private readonly FeatureLifetime _lifetime;"); + writer.WriteLine(); + + writer.WriteLine("private readonly global::Xunit.Abstractions.ITestOutputHelper _testOutputHelper;"); + writer.WriteLine(); + + RenderConstructorTo(writer, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + writer.WriteLine(); + + base.RenderTestFixtureContentTo(writer, cancellationToken); + } + + protected virtual void RenderConstructorTo( + CSharpSourceTextWriter SourceBuilder, + CancellationToken cancellationToken) + { + var className = Identifier.LocalType switch + { + SimpleTypeIdentifier simple => simple.Name, + GenericTypeIdentifier generic => generic.Name, + _ => throw new NotImplementedException( + $"Writing constructor for {Identifier.GetType().Name} values is not implemented.") + }; + + // Lifetime class is initialzed once per feature, then passed to the constructor of each test class instance. + // Output helper is included to by registered in the container. + SourceBuilder.Write("public ").Write(className).WriteLine("(FeatureLifetime lifetime, " + + "global::Xunit.Abstractions.ITestOutputHelper testOutputHelper)"); + SourceBuilder.BeginBlock("{"); + SourceBuilder.WriteLine("_lifetime = lifetime;"); + SourceBuilder.WriteLine("_testOutputHelper = testOutputHelper;"); + SourceBuilder.EndBlock("}"); + } + + protected virtual void RenderLifetimeClassTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + // This class represents the feature lifetime in the xUnit framework. + writer.WriteLine("public class FeatureLifetime : global::Xunit.IAsyncLifetime"); + writer.BeginBlock("{"); + RenderLifetimeClassContentTo(writer, cancellationToken); + writer.EndBlock("}"); + writer.WriteLine(); + } + + private void RenderLifetimeClassContentTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + writer.Write("public global::Reqnroll.ITestRunner"); + + if (RenderingOptions.UseNullableReferenceTypes) + { + writer.Write('?'); + } + + writer.WriteLine(" TestRunner { get; private set; }"); + + writer.WriteLine(); + + writer.WriteLine("public global::System.Threading.Tasks.Task InitializeAsync()"); + writer.BeginBlock("{"); + writer.WriteLine("TestRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly();"); + writer.Write("return TestRunner.OnFeatureStartAsync(").WriteTypeReference(Identifier).Write(".FeatureInfo").WriteLine(");"); + writer.EndBlock("}"); + + writer.WriteLine(); + + writer.WriteLine("public async global::System.Threading.Tasks.Task DisposeAsync()"); + writer.BeginBlock("{"); + writer.Write("await TestRunner"); + + if (RenderingOptions.UseNullableReferenceTypes) + { + writer.Write('!'); + } + + writer.WriteLine(".OnFeatureEndAsync();"); + writer.WriteLine("global::Reqnroll.TestRunnerManager.ReleaseTestRunner(TestRunner);"); + writer.WriteLine("TestRunner = null;"); + writer.EndBlock("}"); + } + + protected override void RenderScenarioInitializeMethodBodyTo(CSharpSourceTextWriter writer, CancellationToken cancellationToken) + { + base.RenderScenarioInitializeMethodBodyTo(writer, cancellationToken); + + writer.WriteLine( + "testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs" + + "(_testOutputHelper);"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestFixtureGenerator.cs b/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestFixtureGenerator.cs new file mode 100644 index 000000000..515e7fb4a --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestFixtureGenerator.cs @@ -0,0 +1,68 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using Reqnroll.FeatureSourceGenerator.XUnit; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.XUnit; + +internal class XUnitCSharpTestFixtureGenerator(XUnitHandler testFrameworkHandler) : + CSharpTestFixtureGenerator(testFrameworkHandler) +{ + protected override XUnitCSharpTestFixtureClass CreateTestFixtureClass( + TestFixtureGenerationContext context, + TestFixtureDescriptor descriptor, + ImmutableArray methods, + CSharpRenderingOptions renderingOptions) + { + return new XUnitCSharpTestFixtureClass(descriptor, methods, renderingOptions); + } + + protected override XUnitCSharpTestMethod CreateTestMethod( + TestMethodGenerationContext context, + TestMethodDescriptor descriptor) + { + return new XUnitCSharpTestMethod(descriptor); + } + + protected override ImmutableArray GenerateTestMethodAttributes( + TestMethodGenerationContext context, + CancellationToken cancellationToken) + { + var scenario = context.ScenarioInformation; + var feature = context.FeatureInformation; + + var attributes = new List(); + + if (scenario.Examples.IsEmpty) + { + attributes.Add(XUnitSyntax.SkippableFactAttribute(context.ScenarioInformation.Name)); + } + else + { + attributes.Add(XUnitSyntax.SkippableTheoryAttribute(context.ScenarioInformation.Name)); + } + + attributes.Add(XUnitSyntax.TraitAttribute("FeatureTitle", context.FeatureInformation.Name)); + attributes.Add(XUnitSyntax.TraitAttribute("Description", context.ScenarioInformation.Name)); + + foreach (var tag in scenario.Tags) + { + attributes.Add(XUnitSyntax.TraitAttribute("Category", tag)); + } + + foreach (var set in scenario.Examples) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var example in set) + { + var values = example.Select(exampleValue => (object?)exampleValue.Value).ToList(); + + values.Add(set.Tags); + + attributes.Add(XUnitSyntax.InlineDataAttribute(values.ToImmutableArray())); + } + } + + return attributes.ToImmutableArray(); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestMethod.cs b/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestMethod.cs new file mode 100644 index 000000000..22901bfcd --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CSharp/XUnit/XUnitCSharpTestMethod.cs @@ -0,0 +1,43 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp.XUnit; +public class XUnitCSharpTestMethod : CSharpTestMethod +{ + public XUnitCSharpTestMethod( + IdentifierString identifier, + ScenarioInformation scenario, + ImmutableArray stepInvocations, + ImmutableArray attributes = default, + ImmutableArray parameters = default, + ImmutableArray> scenarioParameters = default) + : base(identifier, scenario, stepInvocations, attributes, parameters, scenarioParameters) + { + } + + public XUnitCSharpTestMethod(TestMethodDescriptor descriptor) : base(descriptor) + { + } + + protected override void RenderTestRunnerLookupTo( + CSharpSourceTextWriter writer, + CSharpRenderingOptions renderingOptions, + CancellationToken cancellationToken) + { + // For xUnit test runners are scoped to the whole feature execution lifetime + writer.Write("global::Reqnroll.ITestRunner"); + + if (renderingOptions.UseNullableReferenceTypes) + { + writer.Write('?'); + } + + writer.WriteLine(" testRunner = _lifetime.TestRunner;"); + + writer + .WriteLine("if (testRunner == null)") + .BeginBlock("{") + .WriteLine("throw new global::System.InvalidOperationException(\"The test fixture lifecycle has not been initialized.\");") + .EndBlock("}"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/CompilationInformation.cs b/Reqnroll.FeatureSourceGenerator/CompilationInformation.cs new file mode 100644 index 000000000..6babe53db --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/CompilationInformation.cs @@ -0,0 +1,41 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator; + +public abstract record CompilationInformation( + string? AssemblyName, + ImmutableArray ReferencedAssemblies) +{ + public virtual bool Equals(CompilationInformation other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return string.Equals(AssemblyName, other.AssemblyName, StringComparison.Ordinal) + && ReferencedAssemblies.SequenceEqual(other.ReferencedAssemblies); + } + + public override int GetHashCode() + { + unchecked // Overflow is fine, just wrap + { + int hash = 27; + + hash = hash * 43 + AssemblyName?.GetHashCode() ?? 0; + + foreach (var assembly in ReferencedAssemblies) + { + hash = hash * 43 + assembly.GetHashCode(); + } + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/DiagnosticIds.cs b/Reqnroll.FeatureSourceGenerator/DiagnosticIds.cs new file mode 100644 index 000000000..6e78c0eb8 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/DiagnosticIds.cs @@ -0,0 +1,10 @@ +namespace Reqnroll.FeatureSourceGenerator; + +internal static class DiagnosticIds +{ + public const string SyntaxError = "GH1001"; + + public const string NoTestFrameworkFound = "RQ1001"; + + public const string TestFrameworkNotSupported = "RQ1002"; +} diff --git a/Reqnroll.FeatureSourceGenerator/DotNetSyntax.cs b/Reqnroll.FeatureSourceGenerator/DotNetSyntax.cs new file mode 100644 index 000000000..8e4974f93 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/DotNetSyntax.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using System.Text; + +namespace Reqnroll.FeatureSourceGenerator; + +/// +/// Provides methods to support generating syntax following common .NET rules and conventions. +/// +internal static class DotNetSyntax +{ + /// + /// Creates an identifier which follows common .NET naming rules and conventions. + /// + /// The input string to use as the basis of the identifier. + /// A string which is safe to use as an identifier across .NET languages. + public static string CreateIdentifier(string s) + { + var sb = new StringBuilder(); + var newWord = true; + + foreach (char c in s) + { + if (char.IsWhiteSpace(c)) + { + newWord = true; + continue; + } + + if (!IsValidInIdentifier(c)) + { + continue; + } + + if (sb.Length == 0 && !IsValidAsFirstCharacterInIdentifier(c)) + { + sb.Append('_'); + sb.Append(c); + continue; + } + + if (newWord) + { + sb.Append(char.ToUpper(c)); + newWord = false; + } + else + { + sb.Append(c); + } + } + + return sb.ToString(); + } + + private static bool IsValidAsFirstCharacterInIdentifier(char c) + { + if (c == '_') + { + return true; + } + + var category = char.GetUnicodeCategory(c); + + return category == UnicodeCategory.UppercaseLetter + || category == UnicodeCategory.LowercaseLetter + || category == UnicodeCategory.TitlecaseLetter + || category == UnicodeCategory.ModifierLetter + || category == UnicodeCategory.OtherLetter; + } + + private static bool IsValidInIdentifier(char c) + { + var category = char.GetUnicodeCategory(c); + + return category == UnicodeCategory.UppercaseLetter + || category == UnicodeCategory.LowercaseLetter + || category == UnicodeCategory.TitlecaseLetter + || category == UnicodeCategory.ModifierLetter + || category == UnicodeCategory.OtherLetter + || category == UnicodeCategory.LetterNumber + || category == UnicodeCategory.NonSpacingMark + || category == UnicodeCategory.SpacingCombiningMark + || category == UnicodeCategory.DecimalDigitNumber + || category == UnicodeCategory.ConnectorPunctuation + || category == UnicodeCategory.Format; + } +} diff --git a/Reqnroll.FeatureSourceGenerator/EnumerableEqualityExtensions.cs b/Reqnroll.FeatureSourceGenerator/EnumerableEqualityExtensions.cs new file mode 100644 index 000000000..5ff2987c0 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/EnumerableEqualityExtensions.cs @@ -0,0 +1,102 @@ +namespace Reqnroll.FeatureSourceGenerator; + +internal static class EnumerableEqualityExtensions +{ + public static bool SetEquals(this IEnumerable source, IEnumerable other) where T : IEquatable + { + if (source is null && other is null) + { + return true; + } + + if (source is null || other is null) + { + return false; + } + + var otherItems = other.ToList(); + + foreach (var item in source) + { + if (!otherItems.Remove(item)) + { + return false; + } + } + + if (otherItems.Count != 0) + { + return false; + } + + return true; + } + + /// + /// Gets a hash code for a sequence where re-ordering the elements does not affect the hash code. + /// + /// The type of items in the sequence. + /// The sequence to treat as a set. + /// A hash code for the sequence. + public static int GetSetHashCode(this IEnumerable set) + { + unchecked + { + var hash = 23; + + foreach (var item in set) + { + hash *= 13 + item?.GetHashCode() ?? 0; + } + + return hash; + } + } + + public static int GetSequenceHashCode(this IEnumerable sequence) => + sequence.GetSequenceHashCode(EqualityComparer.Default); + + public static int GetSequenceHashCode(this IEnumerable sequence, IEqualityComparer itemComparer) + { + unchecked + { + var hash = 23; + + var index = 0; + foreach (var item in sequence) + { + hash *= 13 + index++ + (item is null ? 0 : itemComparer.GetHashCode(item)); + } + + return hash; + } + } + + public static bool SetEqual(this IEnumerable first, IEnumerable second) + { + if (first is null) + { + return second is null; + } + + if (ReferenceEquals(first, second)) + { + return true; + } + + var items = first.ToList(); + + var count = 0; + + foreach (var item in second) + { + count++; + if (!items.Remove(item)) + { + return false; + } + } + + return items.Count == count; + } +} diff --git a/Reqnroll.FeatureSourceGenerator/GeneratorInformation.cs b/Reqnroll.FeatureSourceGenerator/GeneratorInformation.cs new file mode 100644 index 000000000..3a717b33f --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/GeneratorInformation.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator; + +internal record GeneratorInformation( + ImmutableArray> CompatibleGenerators, + ImmutableArray> UseableGenerators, + ITestFixtureGenerator? DefaultGenerator) + where TCompilationInformation : CompilationInformation +{ + public override int GetHashCode() + { + unchecked + { + var hash = 47; + + hash *= 13 + CompatibleGenerators.GetSetHashCode(); + hash *= 13 + UseableGenerators.GetSetHashCode(); + hash *= 13 + DefaultGenerator?.GetHashCode() ?? 0; + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinDocumentComparer.cs b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinDocumentComparer.cs new file mode 100644 index 000000000..132f81db5 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinDocumentComparer.cs @@ -0,0 +1,24 @@ +using Gherkin.Ast; + +namespace Reqnroll.FeatureSourceGenerator.Gherkin; + +internal class GherkinDocumentComparer : IEqualityComparer +{ + public static GherkinDocumentComparer Default { get; } = new GherkinDocumentComparer(); + + public bool Equals(GherkinDocument? x, GherkinDocument? y) + { + return false; + } + + public int GetHashCode(GherkinDocument? obj) + { + if (obj == null) + { + return 0; + } + + return obj.GetHashCode(); + } +} + diff --git a/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxExtensions.cs b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxExtensions.cs new file mode 100644 index 000000000..e5f0dd020 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxExtensions.cs @@ -0,0 +1,10 @@ +namespace Reqnroll.FeatureSourceGenerator.Gherkin; +internal static class GherkinSyntaxExtensions +{ + public static LinePosition ToLinePosition(this global::Gherkin.Ast.Location location) + { + // Roslyn uses 0-based indexes for line and character-offset numbers. + // The Gherkin parser uses 1-based indexes for line and column numbers. + return new LinePosition(location.Line - 1, location.Column - 1); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxParser.cs b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxParser.cs new file mode 100644 index 000000000..20008a951 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxParser.cs @@ -0,0 +1,63 @@ +using Gherkin; +using Gherkin.Ast; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.Gherkin; + +using Location = global::Gherkin.Ast.Location; + +internal static class GherkinSyntaxParser +{ + public static readonly DiagnosticDescriptor SyntaxError = new( + id: DiagnosticIds.SyntaxError, + title: GherkinSyntaxParserResources.SyntaxErrorTitle, + messageFormat: GherkinSyntaxParserResources.SyntaxErrorMessage, + "Reqnroll.Gherkin", + DiagnosticSeverity.Error, + true); + + //public GherkinSyntaxTree Parse(SourceText text, string path, CancellationToken cancellationToken = default) + //{ + // var parser = new Parser { StopAtFirstError = false }; + + // GherkinDocument? document = null; + // ImmutableArray? diagnostics = null; + + // cancellationToken.ThrowIfCancellationRequested(); + + // try + // { + // // CONSIDER: Using a parser that doesn't throw exceptions for syntax errors. + // document = parser.Parse(new SourceTokenScanner(text)); + // } + // catch (CompositeParserException ex) + // { + // diagnostics = ex.Errors.Select(error => CreateGherkinDiagnostic(error, text, path)).ToImmutableArray(); + // } + + // return new GherkinSyntaxTree( + // document ?? new GherkinDocument(null, []), + // diagnostics ?? ImmutableArray.Empty, + // path); + //} + + public static Diagnostic CreateGherkinDiagnostic(ParserException exception, SourceText text, string path) + { + return Diagnostic.Create(SyntaxError, CreateLocation(exception.Location, text, path), exception.Message); + } + + private static Microsoft.CodeAnalysis.Location CreateLocation(Location location, SourceText text, string path) + { + // Roslyn uses 0-based indexes for line and character-offset numbers. + // The Gherkin parser uses 1-based indexes for line and column numbers. + var positionStart = location.ToLinePosition(); + var line = text.Lines[positionStart.Line]; + var lineLength = line.Span.Length; + var positionEnd = new LinePosition(positionStart.Line, lineLength); + + return Microsoft.CodeAnalysis.Location.Create( + path, + new TextSpan(line.Span.Start + positionStart.Character, lineLength - positionStart.Character), + new LinePositionSpan(positionStart, positionEnd)); ; + } +} diff --git a/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxParserResources.resx b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxParserResources.resx new file mode 100644 index 000000000..1c21ee457 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxParserResources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} + + + Syntax error in Gherkin document + + \ No newline at end of file diff --git a/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxTree.cs b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxTree.cs new file mode 100644 index 000000000..b2f57ac29 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Gherkin/GherkinSyntaxTree.cs @@ -0,0 +1,58 @@ +using Gherkin.Ast; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.Gherkin; +public class GherkinSyntaxTree : IEquatable +{ + private readonly GherkinDocument? _root; + private readonly ImmutableArray _diagnostics; + + internal GherkinSyntaxTree(GherkinDocument? root, ImmutableArray diagnostics, string? path) + { + _root = root; + _diagnostics = diagnostics; + FilePath = path; + } + + public string? FilePath { get; } + + public GherkinSyntaxTree WithPath(string? path) => new(_root, _diagnostics, path); + + public override bool Equals(object obj) => obj is GherkinSyntaxTree tree && Equals(tree); + + public bool Equals(GherkinSyntaxTree other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return _diagnostics.SetEquals(other._diagnostics) && GherkinDocumentComparer.Default.Equals(_root, other._root); + } + + public override int GetHashCode() + { + const int multiplier = 31; + + var hash = 17; + + hash *= multiplier + GherkinDocumentComparer.Default.GetHashCode(_root); + + foreach (var diagnostic in _diagnostics) + { + hash *= multiplier + diagnostic.GetHashCode(); + } + + return hash; + } + + public ImmutableArray GetDiagnostics() => _diagnostics; + + public GherkinDocument? GetRoot() => _root; +} + diff --git a/Reqnroll.FeatureSourceGenerator/Gherkin/SourceTokenScanner.cs b/Reqnroll.FeatureSourceGenerator/Gherkin/SourceTokenScanner.cs new file mode 100644 index 000000000..100d39d80 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Gherkin/SourceTokenScanner.cs @@ -0,0 +1,27 @@ +using Gherkin; + +namespace Reqnroll.FeatureSourceGenerator.Gherkin; + +using Location = global::Gherkin.Ast.Location; + +class SourceTokenScanner(SourceText source) : ITokenScanner +{ + private readonly SourceText _source = source; + + private int _lineNumber = -1; + + public Token Read() + { + var lineNumber = ++_lineNumber; + + if (lineNumber >= _source.Lines.Count) + { + return new Token(null, new Location(lineNumber + 1)); + } + + var line = _source.Lines[lineNumber]; + var location = new Location(line.LineNumber + 1); + + return new Token(new GherkinLine(_source.ToString(line.Span), line.LineNumber + 1), location); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/ITestFixtureGenerator.cs b/Reqnroll.FeatureSourceGenerator/ITestFixtureGenerator.cs new file mode 100644 index 000000000..393c4bc00 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/ITestFixtureGenerator.cs @@ -0,0 +1,38 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; + +/// +/// Defines a component which generates test-fixture classes. +/// +/// The type of compilation information handled by the generator. +public interface ITestFixtureGenerator + where TCompilationInformation : CompilationInformation +{ + /// + /// Gets the test framework handler this generator is associated with. + /// + ITestFrameworkHandler TestFrameworkHandler { get; } + + /// + /// Generates a test method for a scenario. + /// + /// The method-generation context. + /// A token used to signal when generation should be canceled. + /// A representing the generated method. + TestMethod GenerateTestMethod( + TestMethodGenerationContext context, + CancellationToken cancellationToken = default); + + /// + /// Generates a test fixture class for a feature, incorporating a set of generated methods. + /// + /// The feature-generation context. + /// The collection of methods to incorporate into the fixture. + /// A token used to signal when generation should be canceled. + /// A represented the generated test-fixture class. + TestFixtureClass GenerateTestFixtureClass( + TestFixtureGenerationContext context, + IEnumerable methods, + CancellationToken cancellationToken = default); +} diff --git a/Reqnroll.FeatureSourceGenerator/ITestFrameworkHandler.cs b/Reqnroll.FeatureSourceGenerator/ITestFrameworkHandler.cs new file mode 100644 index 000000000..3a863e8fb --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/ITestFrameworkHandler.cs @@ -0,0 +1,29 @@ +namespace Reqnroll.FeatureSourceGenerator; + +/// +/// Defines a component that handles the support for a test framework. +/// +public interface ITestFrameworkHandler +{ + /// + /// Gets the name of the test framework associated with the handler. + /// + string TestFrameworkName { get; } + + /// + /// Gets a value indicating whether the test framework associated with the handler is referenced by a compilation. + /// + /// The compilation to examine. + /// true if the test framework is referenced by the compilation; + /// otherwise false. + bool IsTestFrameworkReferenced(CompilationInformation compilation); + + /// + /// Gets a test-fixture generator for the test framework. + /// + /// The type of compilation to obtain a generator for. + /// The compilation to examine. + /// A test-fixture generator if one can be produced for the compilation type; otherwise null. + ITestFixtureGenerator? GetTestFixtureGenerator(TCompilationInformation compilation) + where TCompilationInformation : CompilationInformation; +} diff --git a/Reqnroll.FeatureSourceGenerator/ImmutableArrayEqualityComparer.cs b/Reqnroll.FeatureSourceGenerator/ImmutableArrayEqualityComparer.cs new file mode 100644 index 000000000..aa64040d4 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/ImmutableArrayEqualityComparer.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator; + +public class ImmutableArrayEqualityComparer(IEqualityComparer itemComparer) : IEqualityComparer> +{ + public ImmutableArrayEqualityComparer() : this(EqualityComparer.Default) + { + } + + public IEqualityComparer ItemComparer { get; } = itemComparer; + + public static ImmutableArrayEqualityComparer Default { get; } = new ImmutableArrayEqualityComparer(); + + public bool Equals(ImmutableArray x, ImmutableArray y) + { + if (x.Equals(y)) + { + return true; + } + + return x.SequenceEqual(y, ItemComparer); + } + + public int GetHashCode(ImmutableArray obj) => obj.GetSequenceHashCode(ItemComparer); +} diff --git a/Reqnroll.FeatureSourceGenerator/IsExternalInit.cs b/Reqnroll.FeatureSourceGenerator/IsExternalInit.cs new file mode 100644 index 000000000..e251a1ef9 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/IsExternalInit.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if NETSTANDARD2_0 || NETSTANDARD2_1 || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET6 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 + +using System.ComponentModel; + +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices +{ + /// + /// Reserved to be used by the compiler for tracking metadata. + /// This class should not be used by developers in source code. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal static class IsExternalInit + { + } +} + +#endif diff --git a/Reqnroll.FeatureSourceGenerator/KeyValuePairDeconstruct.cs b/Reqnroll.FeatureSourceGenerator/KeyValuePairDeconstruct.cs new file mode 100644 index 000000000..c49ac61df --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/KeyValuePairDeconstruct.cs @@ -0,0 +1,10 @@ +namespace Reqnroll.FeatureSourceGenerator; + +internal static class KeyValuePairDeconstruct +{ + public static void Deconstruct(this KeyValuePair tuple, out T1 key, out T2 value) + { + key = tuple.Key; + value = tuple.Value; + } +} diff --git a/Reqnroll.FeatureSourceGenerator/MSTest/MSTestHandler.cs b/Reqnroll.FeatureSourceGenerator/MSTest/MSTestHandler.cs new file mode 100644 index 000000000..6c0cf7138 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/MSTest/MSTestHandler.cs @@ -0,0 +1,39 @@ +using Reqnroll.FeatureSourceGenerator.CSharp; +using Reqnroll.FeatureSourceGenerator.CSharp.MSTest; + +namespace Reqnroll.FeatureSourceGenerator.MSTest; + +/// +/// The MSTest framework handler. +/// +public class MSTestHandler : ITestFrameworkHandler +{ + public string TestFrameworkName => "MSTest"; + + public bool IsTestFrameworkReferenced(CompilationInformation compilation) + { + return compilation.ReferencedAssemblies + .Any(assembly => assembly.Name == "Microsoft.VisualStudio.TestPlatform.TestFramework"); + } + + public ITestFixtureGenerator? GetTestFixtureGenerator( + TCompilationInformation compilation) + where TCompilationInformation : CompilationInformation + { + if (typeof(TCompilationInformation).IsAssignableFrom(typeof(CSharpCompilationInformation))) + { + var version = compilation.ReferencedAssemblies + .Where(assembly => assembly.Name == "Microsoft.VisualStudio.TestPlatform.TestFramework") + .Max(assembly => assembly.Version); + + if (version >= new Version(3, 0)) + { + return (ITestFixtureGenerator)new MSTest3CSharpTestFixtureGenerator(this); + } + + return (ITestFixtureGenerator)new MSTest2CSharpTestFixtureGenerator(this); + } + + return null; + } +} diff --git a/Reqnroll.FeatureSourceGenerator/MSTest/MSTestSyntax.cs b/Reqnroll.FeatureSourceGenerator/MSTest/MSTestSyntax.cs new file mode 100644 index 000000000..264818521 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/MSTest/MSTestSyntax.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.MSTest; +internal static class MSTestSyntax +{ + public static readonly NamespaceString MSTestNamespace = new("Microsoft.VisualStudio.TestTools.UnitTesting"); + public static AttributeDescriptor TestClassAttribute() => + new(MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestClass"))); + + public static AttributeDescriptor TestMethodAttribute() => + new(MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestMethod"))); + + public static AttributeDescriptor DescriptionAttribute(string description) => + new( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ImmutableArray.Create(description)); + + public static AttributeDescriptor TestPropertyAttribute(string propertyName, object? propertyValue) => + new( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestProperty")), + positionalArguments: ImmutableArray.Create(propertyName, propertyValue)); + + public static AttributeDescriptor DataRowAttribute(ImmutableArray values) => + new(MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("DataRow")), values); +} diff --git a/Reqnroll.FeatureSourceGenerator/NUnit/NUnitHandler.cs b/Reqnroll.FeatureSourceGenerator/NUnit/NUnitHandler.cs new file mode 100644 index 000000000..5db9b7e75 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/NUnit/NUnitHandler.cs @@ -0,0 +1,29 @@ +using Reqnroll.FeatureSourceGenerator.CSharp; +using Reqnroll.FeatureSourceGenerator.CSharp.NUnit; + +namespace Reqnroll.FeatureSourceGenerator.NUnit; + +/// +/// The handler for NUnit. +/// +public class NUnitHandler : ITestFrameworkHandler +{ + public string TestFrameworkName => "NUnit"; + + public ITestFixtureGenerator? GetTestFixtureGenerator( + TCompilationInformation compilation) + where TCompilationInformation : CompilationInformation + { + if (typeof(TCompilationInformation).IsAssignableFrom(typeof(CSharpCompilationInformation))) + { + return (ITestFixtureGenerator)new NUnitCSharpTestFixtureGenerator(this); + } + + return null; + } + + public bool IsTestFrameworkReferenced(CompilationInformation compilationInformation) + { + return compilationInformation.ReferencedAssemblies.Any(assembly => assembly.Name == "nunit.framework"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/NUnit/NUnitSyntax.cs b/Reqnroll.FeatureSourceGenerator/NUnit/NUnitSyntax.cs new file mode 100644 index 000000000..d6c4cd6b8 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/NUnit/NUnitSyntax.cs @@ -0,0 +1,58 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.NUnit; +internal static class NUnitSyntax +{ + public static readonly NamespaceString NUnitNamespace = new("NUnit.Framework"); + + internal static AttributeDescriptor CategoryAttribute(string tag) + { + return new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Category")), + ImmutableArray.Create(tag)); + } + + internal static AttributeDescriptor DescriptionAttribute(string description) + { + return new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ImmutableArray.Create(description)); + } + + internal static AttributeDescriptor IgnoreAttribute(string reason) + { + return new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Ignore")), + ImmutableArray.Create(reason)); + } + + internal static AttributeDescriptor TestAttribute() + { + return new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Test"))); + } + + internal static AttributeDescriptor TestCaseAttribute( + ImmutableArray values, + string? category = null, + string? ignoreReason = null) + { + var namedArguments = new Dictionary(); + + if (category != null) + { + namedArguments.Add(new IdentifierString("Category"), category); + } + + if (ignoreReason != null) + { + namedArguments.Add(new IdentifierString("IgnoreReason"), ignoreReason); + } + + return new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("TestCase")), + values, + namedArguments.ToImmutableDictionary()); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/README.md b/Reqnroll.FeatureSourceGenerator/README.md new file mode 100644 index 000000000..17d853686 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/README.md @@ -0,0 +1 @@ +# Roslyn Feature Source Generator diff --git a/Reqnroll.FeatureSourceGenerator/Reqnroll.FeatureSourceGenerator.csproj b/Reqnroll.FeatureSourceGenerator/Reqnroll.FeatureSourceGenerator.csproj new file mode 100644 index 000000000..8c2056c65 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/Reqnroll.FeatureSourceGenerator.csproj @@ -0,0 +1,70 @@ + + + + netstandard2.0 + 12.0 + true + true + enable + enable + + true + + + + + true + false + false + true + $(NoWarn);NU5128 + README.md + + + + + + + + + + + all + true + + + + + + + + + + + + + + + true + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + + diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/ArrayTypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/ArrayTypeIdentifier.cs new file mode 100644 index 000000000..0c5fb0a18 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/ArrayTypeIdentifier.cs @@ -0,0 +1,71 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class ArrayTypeIdentifier(TypeIdentifier itemType, bool isNullable = false) : + TypeIdentifier, IEquatable +{ + public TypeIdentifier ItemType { get; } = itemType; + + public override bool IsNullable { get; } = isNullable; + + public bool Equals(ArrayTypeIdentifier? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return ItemType.Equals(other.ItemType) && + IsNullable.Equals(other.IsNullable); + } + + public override bool Equals(object obj) => Equals(obj as ArrayTypeIdentifier); + + public override int GetHashCode() + { + unchecked + { + var hash = 36571313; + + hash *= 82795997 + ItemType.GetHashCode(); + hash *= 82795997 + IsNullable.GetHashCode(); + + return hash; + } + } + + public override string ToString() + { + var str = $"{ItemType}[]"; + + if (IsNullable) + { + str += '?'; + } + + return str; + } + + public static bool Equals(ArrayTypeIdentifier? first, ArrayTypeIdentifier? second) + { + if (ReferenceEquals(first, second)) + { + return true; + } + + if (first is null) + { + return false; + } + + return first.Equals(second); + } + + public static bool operator ==(ArrayTypeIdentifier? first, ArrayTypeIdentifier? second) => Equals(first, second); + + public static bool operator !=(ArrayTypeIdentifier? first, ArrayTypeIdentifier? second) => !Equals(first, second); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/AttributeDescriptor.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/AttributeDescriptor.cs new file mode 100644 index 000000000..8a41679e1 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/AttributeDescriptor.cs @@ -0,0 +1,372 @@ +using System.Collections; +using System.Collections.Immutable; +using System.ComponentModel; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +/// +/// Provides a description of a .NET attribute instance. +/// +/// The attribute's type. +/// The positional arguments of the attribute. +/// The named arguments of the attribute. +public class AttributeDescriptor( + QualifiedTypeIdentifier type, + ImmutableArray? positionalArguments = null, + ImmutableDictionary? namedArguments = null) + : IEquatable +{ + public QualifiedTypeIdentifier Type { get; } = type; + + public ImmutableArray PositionalArguments { get; } = + ThrowIfArgumentTypesNotValid( + positionalArguments.GetValueOrDefault(ImmutableArray.Empty), + nameof(positionalArguments)); + + public ImmutableDictionary NamedArguments { get; } = + ThrowIfArgumentTypesNotValid(namedArguments ?? ImmutableDictionary.Empty, nameof(namedArguments)); + + private int? _hashCode; + + private static ImmutableArray ThrowIfArgumentTypesNotValid( + ImmutableArray positionalArguments, + string paramName) + { + foreach (var item in positionalArguments) + { + ThrowIfArgumentTypeNotValid(item, paramName); + } + + return positionalArguments; + } + + private static void ThrowIfArgumentTypeNotValid(object? value, string paramName) + { + switch (value) + { + case null: + case bool _: + case byte _: + case char _: + case double _: + case float _: + case int _: + case long _: + case sbyte _: + case short _: + case string _: + case uint _: + case ulong _: + case ushort _: + case Type _: + case Enum _: + break; + + case Array _: + throw new ArgumentException( + "Mutable arrays may not be used with AttributeDescriptor. To represent an array use ImmutableArray instead.", + paramName); + + default: + var valueType = value.GetType(); + if (valueType == typeof(object)) + { + break; + } + + if (IsImmutableArrayType(valueType)) + { + var array = (IEnumerable)value; + foreach (var item in array) + { + ThrowIfArgumentTypeNotValid(item, paramName); + } + + break; + } + + throw new ArgumentException( + $"Instances of type {value.GetType()} cannot be used as attribute arguments.", + paramName); + } + } + + private static ImmutableDictionary ThrowIfArgumentTypesNotValid( + ImmutableDictionary namedArguments, + string paramName) + { + foreach (var item in namedArguments.Values) + { + ThrowIfArgumentTypeNotValid(item, paramName); + } + + return namedArguments; + } + + public override int GetHashCode() + { + _hashCode ??= CalculateHashCode(); + + return _hashCode.Value; + } + + private int CalculateHashCode() + { + unchecked + { + var hash = 47; + + hash *= 13 + Type.GetHashCode(); + + var index = 0; + foreach (var item in PositionalArguments) + { + hash *= 13 + index++ + GetItemHash(item); + } + + foreach (var (name, value) in NamedArguments) + { + hash *= 13 + name.GetHashCode() + GetItemHash(value); + } + + return hash; + } + } + + private int GetItemHash(object? item) + { + if (item is null) + { + return 0; + } + + if (IsImmutableArray(item)) + { + return GetSequenceHash((IEnumerable)item); + } + + return item.GetHashCode(); + } + + private int GetSequenceHash(IEnumerable sequence) + { + unchecked + { + var hash = 127; + var index = 0; + + foreach (var item in sequence) + { + hash *= 13 + index++ + GetItemHash(item); + } + + return hash; + } + } + + private static bool IsImmutableArray(object value) => IsImmutableArrayType(value.GetType()); + + private static bool IsImmutableArrayType(Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ImmutableArray<>); + + public override bool Equals(object obj) => Equals(obj as AttributeDescriptor); + + public virtual bool Equals(AttributeDescriptor? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(other, this)) + { + return true; + } + + if (GetHashCode() != other.GetHashCode()) + { + return false; + } + + return Type.Equals(other.Type) && + ArgumentsEqual(other); + } + + private bool ArgumentsEqual(AttributeDescriptor other) + { + return ArgumentSequenceEqual(PositionalArguments, other.PositionalArguments) && + ArgumentDictionaryEqual(NamedArguments, other.NamedArguments); + } + + private static bool ArgumentDictionaryEqual( + ImmutableDictionary first, + ImmutableDictionary second) + { + if (ReferenceEquals(first, second)) + { + return true; + } + + if (first.Count != second.Count) + { + return false; + } + + foreach (var (name, firstArgument) in first) + { + if (!second.TryGetValue(name, out var secondArgument)) + { + return false; + } + + if (!ArgumentEquals(firstArgument, secondArgument)) + { + return false; + } + } + + return true; + } + + private static bool ArgumentEquals(object? first, object? second) + { + if (ReferenceEquals(first, second)) + { + return true; + } + + if (first is null || second is null) + { + return false; + } + + var firstType = first.GetType(); + if (IsImmutableArrayType(firstType)) + { + if (second.GetType() != firstType) + { + return false; + } + + return ArgumentSequenceEqual((IList)first, (IList)second); + } + + return first.Equals(second); + } + + private static bool ArgumentSequenceEqual(IList first, IList second) + { + if (first.Count != second.Count) + { + return false; + } + + for (int i = 0; i < first.Count; i++) + { + if (!ArgumentEquals(first[i], second[i])) + { + return false; + } + } + + return true; + } + + private static bool ArgumentSequenceEqual(ImmutableArray first, ImmutableArray second) + { + if (first.Length != second.Length) + { + return false; + } + + for (int i = 0; i < first.Length; i++) + { + if (!ArgumentEquals(first[i], second[i])) + { + return false; + } + } + + return true; + } + + public override string ToString() + { + var positional = PositionalArguments.Length > 0 ? string.Join(", ", PositionalArguments.Select(ToLiteralString)) : null; + var named = NamedArguments.Count > 0 ? + string.Join(", ", NamedArguments.Select(kvp => $"{kvp.Key}={ToLiteralString(kvp.Value)}")) : + null; + + string argumentList; + + if (positional != null && named == null) + { + argumentList = $"({positional})"; + } + else if (positional == null && named != null) + { + argumentList = $"({named})"; + } + else if (positional != null && named != null) + { + argumentList = $"({positional}, {named})"; + } + else + { + argumentList = string.Empty; + } + + return $"[{Type}{argumentList}]"; + } + + private static string ToLiteralString(object? value) + { + if (value is null) + { + return "null"; + } + + if (value is string s) + { + return $"\"{s}\""; + } + + var type = value.GetType(); + if (IsImmutableArrayType(type)) + { + var itemType = type.GetGenericArguments()[0]; + var values = ((IEnumerable)value).Cast().Select(ToLiteralString); + + return $"new {GetTypeIdentifier(itemType)}[] {{{string.Join(", ", values)}}}"; + } + + return value.ToString(); + } + + private static string GetTypeIdentifier(Type type) + { + if (CSharp.CSharpSyntax.TypeAliases.TryGetValue(type, out var alias)) + { + return alias; + } + + return type.FullName; + } + + public AttributeDescriptor WithPositionalArguments(params object?[] positionalArguments) + { + return new AttributeDescriptor(Type, positionalArguments.ToImmutableArray(), NamedArguments); + } + + public AttributeDescriptor WithNamedArguments(object namedArguments) + { + var arguments = new Dictionary(); + + foreach (PropertyDescriptor propertyDescriptor in TypeDescriptor.GetProperties(namedArguments)) + { + arguments.Add(new IdentifierString(propertyDescriptor.Name), propertyDescriptor.GetValue(namedArguments)); + } + + return new AttributeDescriptor(Type, PositionalArguments, arguments.ToImmutableDictionary()); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/CommonTypes.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/CommonTypes.cs new file mode 100644 index 000000000..4c378c1a2 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/CommonTypes.cs @@ -0,0 +1,6 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; +internal static class CommonTypes +{ + public static readonly QualifiedTypeIdentifier String = + new(new NamespaceString("System"), new SimpleTypeIdentifier(new IdentifierString("String"))); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/DataTable.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/DataTable.cs new file mode 100644 index 000000000..bb0cc9907 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/DataTable.cs @@ -0,0 +1,60 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class DataTable(ImmutableArray headings, ImmutableArray> rows) : IEquatable +{ + public ImmutableArray Headings { get; } = headings.IsDefault ? ImmutableArray.Empty : headings; + + public ImmutableArray> Rows { get; } = rows.IsDefault ? ImmutableArray>.Empty : rows; + + public bool Equals(DataTable? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (!Headings.SequenceEqual(other.Headings) || Rows.Length != other.Rows.Length) + { + return false; + } + + for (var i = 0; i < Rows.Length; i++) + { + var row = Rows[i]; + var otherRow = other.Rows[i]; + + if (!row.SequenceEqual(otherRow)) + { + return false; + } + } + + return true; + } + + public override bool Equals(object obj) => Equals(obj as DataTable); + + public override int GetHashCode() + { + unchecked + { + var hash = 25979651; + + hash *= 33473171 + Rows.GetSequenceHashCode(); + + foreach (var row in Rows) + { + hash *= 33473171 + row.GetSequenceHashCode(); + } + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/FeatureInformation.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/FeatureInformation.cs new file mode 100644 index 000000000..8912041ef --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/FeatureInformation.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class FeatureInformation( + string name, + string? description, + string language, + ImmutableArray tags = default, + string? filePath = default) : IEquatable +{ + public string Name { get; } = + string.IsNullOrEmpty(name) ? + throw new ArgumentException("Value cannot be null or an empty string.", nameof(name)) : + name; + + public string? Description { get; } = description; + + public string Language { get; } = + string.IsNullOrEmpty(language) ? + throw new ArgumentException("Value cannot be null or an empty string.", nameof(language)) : + language; + + public ImmutableArray Tags { get; } = tags.IsDefault ? ImmutableArray.Empty : tags; + + public string? FilePath { get; } = filePath; + + public override bool Equals(object obj) => Equals(obj as FeatureInformation); + + public bool Equals(FeatureInformation? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name.Equals(other.Name) && + Language.Equals(other.Language) && + Tags.SetEquals(other.Tags) && + string.Equals(FilePath, other.FilePath, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 97533941; + + hash *= 37820603 + Name.GetHashCode(); + hash *= 37820603 + Language.GetHashCode(); + hash *= 37820603 + Tags.GetSetHashCode(); + hash *= 37820603 + FilePath == null ? 0 : StringComparer.Ordinal.GetHashCode(FilePath); + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/GenericTypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/GenericTypeIdentifier.cs new file mode 100644 index 000000000..e45390797 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/GenericTypeIdentifier.cs @@ -0,0 +1,89 @@ +using System.Collections.Immutable; +using System.Text; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class GenericTypeIdentifier( + IdentifierString name, ImmutableArray + typeArguments, + bool isNullable = false) : LocalTypeIdentifier(isNullable), IEquatable +{ + public IdentifierString Name { get; } = + name.IsEmpty ? throw new ArgumentException("Value cannot be an empty identifier.", nameof(name)) : name; + + public ImmutableArray TypeArguments { get; } = + typeArguments.IsDefaultOrEmpty ? + throw new ArgumentException("Value cannot be an empty array.", nameof(typeArguments)) : + typeArguments; + + public override bool Equals(object obj) => Equals(obj as GenericTypeIdentifier); + + public bool Equals(GenericTypeIdentifier? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name.Equals(other.Name) && + IsNullable.Equals(other.IsNullable) && + TypeArguments.SequenceEqual(other.TypeArguments); + } + + public static bool Equals(GenericTypeIdentifier? typeA, GenericTypeIdentifier? typeB) + { + if (ReferenceEquals(typeA, typeB)) + { + return true; + } + + if (typeA is null) + { + return false; + } + + return typeA.Equals(typeB); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 42650617; + + hash *= 57433771 + Name.GetHashCode(); + hash *= 57433771 + IsNullable.GetHashCode(); + hash *= 57433771 + TypeArguments.GetSequenceHashCode(); + + return hash; + } + } + + public override string ToString() + { + var sb = new StringBuilder(Name); + + sb.Append('<'); + + sb.Append(TypeArguments[0].ToString()); + + for (var i = 1; i < TypeArguments.Length; i++) + { + sb.Append(','); + sb.Append(TypeArguments[i].ToString()); + } + + sb.Append('>'); + + return sb.ToString(); + } + + public static bool operator ==(GenericTypeIdentifier? typeA, GenericTypeIdentifier? typeB) => Equals(typeA, typeB); + + public static bool operator !=(GenericTypeIdentifier? typeA, GenericTypeIdentifier? typeB) => !Equals(typeA, typeB); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/IHasAttributes.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/IHasAttributes.cs new file mode 100644 index 000000000..0bf9ec7c0 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/IHasAttributes.cs @@ -0,0 +1,14 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +/// +/// Defines a source element which has attributes. +/// +public interface IHasAttributes +{ + /// + /// Gets the attributes of the source element. + /// + ImmutableArray Attributes { get; } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/IdentifierString.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/IdentifierString.cs new file mode 100644 index 000000000..b231386be --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/IdentifierString.cs @@ -0,0 +1,125 @@ +using System.Globalization; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public readonly struct IdentifierString : IEquatable, IEquatable +{ + private readonly string? _value; + + public static readonly IdentifierString Empty = default; + + public IdentifierString(string? s) + { + if (string.IsNullOrEmpty(s)) + { + _value = null; + return; + } + + var atStartOfIdentifier = true; + + foreach (var c in s!) + { + if (atStartOfIdentifier) + { + if (!IsValidAsFirstCharacterInIdentifier(c)) + { + throw new ArgumentException( + $"'{c}' cannot appear as the first character in an identifier.", + nameof(s)); + } + + atStartOfIdentifier = false; + } + else + { + if (!IsValidInIdentifier(c)) + { + throw new ArgumentException( + $"'{c}' cannot appear in an identifier.", + nameof(s)); + } + } + } + + _value = s; + } + + public readonly bool IsEmpty => _value == null; + + internal static bool IsValidAsFirstCharacterInIdentifier(char c) + { + if (c == '_') + { + return true; + } + + var category = char.GetUnicodeCategory(c); + + return category == UnicodeCategory.UppercaseLetter + || category == UnicodeCategory.LowercaseLetter + || category == UnicodeCategory.TitlecaseLetter + || category == UnicodeCategory.ModifierLetter + || category == UnicodeCategory.OtherLetter; + } + + internal static bool IsValidInIdentifier(char c) + { + var category = char.GetUnicodeCategory(c); + + return category == UnicodeCategory.UppercaseLetter + || category == UnicodeCategory.LowercaseLetter + || category == UnicodeCategory.TitlecaseLetter + || category == UnicodeCategory.ModifierLetter + || category == UnicodeCategory.OtherLetter + || category == UnicodeCategory.LetterNumber + || category == UnicodeCategory.NonSpacingMark + || category == UnicodeCategory.SpacingCombiningMark + || category == UnicodeCategory.DecimalDigitNumber + || category == UnicodeCategory.ConnectorPunctuation + || category == UnicodeCategory.Format; + } + + public bool Equals(string? other) + { + if (string.IsNullOrEmpty(other)) + { + return IsEmpty; + } + + return string.Equals(_value, other, StringComparison.Ordinal); + } + + public bool Equals(IdentifierString other) => string.Equals(_value, other._value, StringComparison.Ordinal); + + public override string ToString() => _value ?? ""; + + public override bool Equals(object obj) + { + return obj switch + { + null => IsEmpty, + IdentifierString identifier => Equals(identifier), + string s => Equals(s), + _ => false + }; + } + + public override int GetHashCode() => _value?.GetHashCode() ?? 0; + + public static bool Equals(IdentifierString identifier, string? s) => identifier.Equals(s); + + public static bool Equals(IdentifierString identifier1, IdentifierString identifier2) => identifier1.Equals(identifier2); + + public static bool operator ==(IdentifierString identifier, string? s) => Equals(identifier, s); + + public static bool operator !=(IdentifierString identifier, string? s) => !Equals(identifier, s); + + public static bool operator ==(IdentifierString identifier1, IdentifierString identifier2) => + Equals(identifier1, identifier2); + + public static bool operator !=(IdentifierString identifier1, IdentifierString identifier2) => + !Equals(identifier1, identifier2); + + public static implicit operator string(IdentifierString identifier) => identifier.ToString(); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/LocalTypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/LocalTypeIdentifier.cs new file mode 100644 index 000000000..f8a3e0165 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/LocalTypeIdentifier.cs @@ -0,0 +1,6 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public abstract class LocalTypeIdentifier(bool isNullable = false) : TypeIdentifier +{ + public override bool IsNullable { get; } = isNullable; +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/NamespaceString.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/NamespaceString.cs new file mode 100644 index 000000000..3e3165868 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/NamespaceString.cs @@ -0,0 +1,120 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; + +public readonly struct NamespaceString : IEquatable, IEquatable +{ + private readonly string? _value; + + public static readonly NamespaceString Empty = default; + + public NamespaceString(string s) + { + if (string.IsNullOrEmpty(s)) + { + _value = string.Empty; + return; + } + + var atStartOfIdentifier = true; + + foreach (var c in s) + { + if (atStartOfIdentifier) + { + if (!IdentifierString.IsValidAsFirstCharacterInIdentifier(c)) + { + throw new ArgumentException( + $"'{c}' cannot appear as the first character in an identifier.", + nameof(s)); + } + + atStartOfIdentifier = false; + } + else if (c == '.') + { + atStartOfIdentifier = true; + } + else + { + if (!IdentifierString.IsValidInIdentifier(c)) + { + throw new ArgumentException( + $"'{c}' cannot appear in an identifier.", + nameof(s)); + } + } + } + + if (atStartOfIdentifier) + { + throw new ArgumentException( + $"'.' cannot appear as the last character in the namespace.", + nameof(s)); + } + + _value = s; + } + + public readonly bool IsEmpty => string.IsNullOrEmpty(_value); + + public override string ToString() => _value ?? ""; + + public override int GetHashCode() => _value?.GetHashCode() ?? 0; + + public override bool Equals(object obj) + { + return obj switch + { + null => IsEmpty, + NamespaceString ns => Equals(ns), + string s => Equals(s), + _ => false + }; + } + + public bool Equals(string other) + { + if (string.IsNullOrEmpty(other)) + { + return IsEmpty; + } + + return _value!.Equals(other, StringComparison.Ordinal); + } + + public bool Equals(NamespaceString other) + { + if (other.IsEmpty) + { + return IsEmpty; + } + + return _value!.Equals(other._value, StringComparison.Ordinal); + } + + public static bool operator ==(NamespaceString namespaceA, NamespaceString namespaceB) => Equals(namespaceA, namespaceB); + + public static bool operator !=(NamespaceString namespaceA, NamespaceString namespaceB) => !Equals(namespaceA, namespaceB); + + public static bool operator ==(NamespaceString ns, string s) => Equals(ns, s); + + public static bool operator !=(NamespaceString ns, string s) => !Equals(ns, s); + + public static QualifiedTypeIdentifier operator +(NamespaceString ns, LocalTypeIdentifier localType) + { + if (ns.IsEmpty) + { + throw new ArgumentException("Cannot qualify a type with an empty namespace.", nameof(ns)); + } + + if (localType is null) + { + throw new ArgumentNullException(nameof(localType)); + } + + return new QualifiedTypeIdentifier(ns, localType); + } + + public static implicit operator string(NamespaceString ns) => ns.ToString(); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/NestedTypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/NestedTypeIdentifier.cs new file mode 100644 index 000000000..3cd531f14 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/NestedTypeIdentifier.cs @@ -0,0 +1,42 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class NestedTypeIdentifier(TypeIdentifier encapsulatingType, LocalTypeIdentifier localType) : + TypeIdentifier, IEquatable +{ + public TypeIdentifier EncapsulatingType { get; } = encapsulatingType; + + public LocalTypeIdentifier LocalType { get; } = localType; + + public override bool IsNullable => LocalType.IsNullable; + + public bool Equals(NestedTypeIdentifier? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return EncapsulatingType.Equals(other.EncapsulatingType) && + LocalType.Equals(other.LocalType); + } + + public override bool Equals(object obj) => Equals(obj as NestedTypeIdentifier); + + public override int GetHashCode() + { + unchecked + { + var hash = 69067723; + + hash *= 81073471 + EncapsulatingType.GetHashCode(); + hash *= 81073471 + LocalType.GetHashCode(); + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/ParameterDescriptor.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/ParameterDescriptor.cs new file mode 100644 index 000000000..86105b1d3 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/ParameterDescriptor.cs @@ -0,0 +1,51 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; +public class ParameterDescriptor : IEquatable +{ + public ParameterDescriptor(IdentifierString name, TypeIdentifier type) + { + if (name.IsEmpty) + { + throw new ArgumentException("Value cannot be an empty identifier.", nameof(name)); + } + + Name = name; + Type = type; + } + + public IdentifierString Name { get; } + + public TypeIdentifier Type { get; } + + public bool Equals(ParameterDescriptor? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name.Equals(other.Name) && + Type.Equals(other.Type); + } + + public override bool Equals(object obj) => Equals(obj as ParameterDescriptor); + + public override int GetHashCode() + { + unchecked + { + var hash = 65362369; + + hash *= 45172373 + Name.GetHashCode(); + hash *= 45172373 + Type.GetHashCode(); + + return hash; + } + } + + public override string ToString() => $"{Type} {Name}"; +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/QualifiedTypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/QualifiedTypeIdentifier.cs new file mode 100644 index 000000000..2a955a7af --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/QualifiedTypeIdentifier.cs @@ -0,0 +1,66 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class QualifiedTypeIdentifier(NamespaceString ns, LocalTypeIdentifier localType) : + TypeIdentifier, IEquatable +{ + public NamespaceString Namespace { get; } = + ns.IsEmpty ? throw new ArgumentException("Value cannot be an empty namespace.", nameof(ns)) : ns; + + public LocalTypeIdentifier LocalType { get; } = localType; + + public override bool IsNullable => LocalType.IsNullable; + + public bool Equals(QualifiedTypeIdentifier? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Namespace.Equals(other.Namespace) && + LocalType.Equals(other.LocalType) && + IsNullable.Equals(other.IsNullable); + } + + public override bool Equals(object obj) => Equals(obj as QualifiedTypeIdentifier); + + public static bool Equals(QualifiedTypeIdentifier? typeA, QualifiedTypeIdentifier? typeB) + { + if (ReferenceEquals(typeA, typeB)) + { + return true; + } + + if (typeA is null) + { + return false; + } + + return typeA.Equals(typeB); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 20462011; + + hash *= 50825449 + Namespace.GetHashCode(); + hash *= 50825449 + LocalType.GetHashCode(); + hash *= 50825449 + IsNullable.GetHashCode(); + + return hash; + } + } + + public override string ToString() => $"{Namespace}.{LocalType}"; + + public static bool operator ==(QualifiedTypeIdentifier? typeA, QualifiedTypeIdentifier? typeB) => Equals(typeA, typeB); + + public static bool operator !=(QualifiedTypeIdentifier? typeA, QualifiedTypeIdentifier? typeB) => !Equals(typeA, typeB); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/RuleInformation.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/RuleInformation.cs new file mode 100644 index 000000000..360f9bf76 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/RuleInformation.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class RuleInformation(string name, ImmutableArray tags) : IEquatable +{ + public string Name { get; } = string.IsNullOrEmpty(name) ? + throw new ArgumentException("Value cannot be null or an empty string", nameof(name)) : + name; + + public ImmutableArray Tags { get; } = tags.IsDefault ? ImmutableArray.Empty : tags; + + public bool Equals(RuleInformation? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name.Equals(other.Name) && Tags.SetEqual(other.Tags); + } + + public static bool Equals(RuleInformation? first, RuleInformation? second) + { + if (first is null) + { + return false; + } + + return first.Equals(second); + } + + public override bool Equals(object obj) => Equals(obj as RuleInformation); + + public override int GetHashCode() + { + unchecked + { + var hash = 14353993; + + hash *= 50733161 + Name.GetHashCode(); + hash *= 50733161 + Tags.GetSetHashCode(); + + return hash; + } + } + + public override string ToString() => $"Name={Name}"; +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/ScenarioExampleSet.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/ScenarioExampleSet.cs new file mode 100644 index 000000000..72ee943be --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/ScenarioExampleSet.cs @@ -0,0 +1,84 @@ +using System.Collections; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class ScenarioExampleSet : IEnumerable>, IEquatable +{ + public ScenarioExampleSet( + ImmutableArray headings, + ImmutableArray> values, + ImmutableArray tags) + { + foreach (var set in values) + { + if (set.Length != headings.Length) + { + throw new ArgumentException( + "Values must contain sets with the same number of values as the headings.", + nameof(values)); + } + } + + Headings = headings; + Values = values; + Tags = tags; + } + + public ImmutableArray Headings { get; } + + public ImmutableArray> Values { get; } + + public ImmutableArray Tags { get; } + + public override bool Equals(object? obj) => Equals(obj as ScenarioExampleSet); + + public bool Equals(ScenarioExampleSet? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return (Headings.Equals(other.Headings) || Headings.SequenceEqual(other.Headings)) && + (Values.Equals(other.Values) || Values.SequenceEqual(other.Values, ImmutableArrayEqualityComparer.Default)) && + (Tags.Equals(other.Tags) || Tags.SequenceEqual(other.Tags)); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 43961407; + + hash *= 32360441 + Headings.GetSequenceHashCode(); + hash *= 32360441 + Values.GetSequenceHashCode(ImmutableArrayEqualityComparer.Default); + hash *= 32360441 + Tags.GetSequenceHashCode(); + + return hash; + } + } + + public IEnumerator> GetEnumerator() + { + foreach (var set in Values) + { + yield return GetAsRow(set); + } + } + + private IEnumerable<(string Name, string Value)> GetAsRow(ImmutableArray set) + { + for (var i = 0; i < Headings.Length; i++) + { + yield return (Name: Headings[i], Value: set[i]); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/ScenarioInformation.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/ScenarioInformation.cs new file mode 100644 index 000000000..826cb5e5a --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/ScenarioInformation.cs @@ -0,0 +1,69 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class ScenarioInformation( + string name, + FileLinePositionSpan keywordAndNamePosition, + ImmutableArray tags, + ImmutableArray steps, + ImmutableArray examples = default, + RuleInformation? rule = null) : IEquatable +{ + public string Name { get; } = string.IsNullOrEmpty(name) ? + throw new ArgumentException("Value cannot be null or an empty string", nameof(name)) : + name; + + public FileLinePositionSpan KeywordAndNamePosition { get; } = keywordAndNamePosition; + + public ImmutableArray Tags { get; } = tags.IsDefault ? ImmutableArray.Empty : tags; + + public ImmutableArray Steps { get; } = steps.IsDefault ? ImmutableArray.Empty : steps; + + public ImmutableArray Examples { get; } = examples.IsDefault ? ImmutableArray.Empty : examples; + + public RuleInformation? Rule { get; } = rule; + + public override bool Equals(object obj) => Equals(obj as ScenarioInformation); + + public bool Equals(ScenarioInformation? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name.Equals(other.Name) && + Tags.SetEqual(other.Tags) && + Steps.SequenceEqual(other.Steps) && + Examples.SequenceEqual(other.Examples) && + RuleInformation.Equals(Rule, other.Rule); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 42261493; + + hash *= 95921717 + Name.GetHashCode(); + hash *= 95921717 + Tags.GetSetHashCode(); + hash *= 95921717 + Steps.GetSequenceHashCode(); + hash *= 95921717 + Examples.GetSequenceHashCode(); + + if (Rule != null) + { + hash *= 95921717 + Rule.GetHashCode(); + } + + return hash; + } + } + + public override string ToString() => $"Name={Name}"; +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/SimpleTypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/SimpleTypeIdentifier.cs new file mode 100644 index 000000000..09be568f1 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/SimpleTypeIdentifier.cs @@ -0,0 +1,70 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class SimpleTypeIdentifier(IdentifierString name, bool isNullable = false) : + LocalTypeIdentifier(isNullable), IEquatable +{ + public IdentifierString Name { get; } = + name.IsEmpty ? throw new ArgumentException("Value cannot be an empty identifier.", nameof(name)) : name; + + public override bool Equals(object obj) => Equals(obj as SimpleTypeIdentifier); + + public bool Equals(SimpleTypeIdentifier? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Name.Equals(other.Name) && + IsNullable.Equals(other.IsNullable); + } + + public static bool Equals(SimpleTypeIdentifier? typeA, SimpleTypeIdentifier? typeB) + { + if (ReferenceEquals(typeA, typeB)) + { + return true; + } + + if (typeA is null) + { + return false; + } + + return typeA.Equals(typeB); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 62786819; + + hash *= 41806399 + Name.GetHashCode(); + hash *= 41806399 + IsNullable.GetHashCode(); + + return hash; + } + } + + public override string ToString() + { + if (IsNullable) + { + return Name + '?'; + } + else + { + return Name; + } + } + + public static bool operator ==(SimpleTypeIdentifier? typeA, SimpleTypeIdentifier? typeB) => Equals(typeA, typeB); + + public static bool operator !=(SimpleTypeIdentifier? typeA, SimpleTypeIdentifier? typeB) => !Equals(typeA, typeB); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/Step.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/Step.cs new file mode 100644 index 000000000..3cdac6875 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/Step.cs @@ -0,0 +1,9 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public record Step( + StepType StepType, + string Keyword, + string Text, + FileLinePositionSpan Position, + DataTable? DataTableArgument = default, + string? DocStringArgument = default); diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/StepInvocation.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/StepInvocation.cs new file mode 100644 index 000000000..83c2f6ed0 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/StepInvocation.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; +public sealed class StepInvocation( + StepType type, + FileLinePositionSpan position, + string keyword, + string text, + ImmutableArray arguments = default, + DataTable? dataTableArgument = default, + string? docStringArgument = default) : IEquatable +{ + public StepType Type { get; } = type; + + public FileLinePositionSpan Position { get; } = position; + + public string Keyword { get; } = keyword; + + public string Text { get; } = text; + + public ImmutableArray Arguments { get; } = + arguments.IsDefault ? ImmutableArray.Empty : arguments; + + public DataTable? DataTableArgument { get; } = dataTableArgument; + + public string? DocStringArgument { get; } = docStringArgument; + + public bool Equals(StepInvocation? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Type.Equals(other.Type) && + Position.Equals(other.Position) && + Keyword.Equals(other.Keyword) && + Text.Equals(other.Text) && + (Arguments.Equals(other.Arguments) || Arguments.SequenceEqual(other.Arguments)) && + (DataTableArgument is null && other.DataTableArgument is null || + DataTableArgument is not null && DataTableArgument.Equals(other.DataTableArgument)) && + string.Equals(DocStringArgument, other.DocStringArgument); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 92321497; + + hash *= 47886541 + Type.GetHashCode(); + hash *= 47886541 + Position.GetHashCode(); + hash *= 47886541 + Keyword.GetHashCode(); + hash *= 47886541 + Text.GetHashCode(); + hash *= 47886541 + Arguments.GetSequenceHashCode(); + + if (DataTableArgument != null) + { + hash *= 47886541 + DataTableArgument.GetHashCode(); + } + + if (DocStringArgument != null) + { + hash *= 47886541 + DocStringArgument.GetHashCode(); + } + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/StepType.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/StepType.cs new file mode 100644 index 000000000..ca82a6c6d --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/StepType.cs @@ -0,0 +1,27 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +/// +/// Specifies the type of a scenario step. +/// +public enum StepType +{ + /// + /// The step sets up the context of the scenario. Associated with the "Given" keyword. + /// + Context, + + /// + /// The step performs an action in the scenario. Associated with the "When" keyword. + /// + Action, + + /// + /// The step performs an assertion on the result of the scenario. Associated with the "Then" keyword. + /// + Outcome, + + /// + /// The step is a continuation of the previous step type. Associted with the "And" keyword. + /// + Conjunction +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/TestFixtureClass.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/TestFixtureClass.cs new file mode 100644 index 000000000..82433cfeb --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/TestFixtureClass.cs @@ -0,0 +1,124 @@ +using Reqnroll.FeatureSourceGenerator.CSharp; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +/// +/// Represents a Reqnroll text fixture class. +/// +public abstract class TestFixtureClass : IHasAttributes +{ + /// + /// Initializes a new instance of the test fixture class. + /// + /// The namespace and name of the class. + /// The hint name used to identify the test fixture within the compilation. This is usually + /// a virtual path and virtual filename that makes sense within the context of a project. The value must be unique + /// within the compilation. + /// The feature information that will be included in the test fixture. + /// The attributes which are applied to the class. + protected TestFixtureClass( + QualifiedTypeIdentifier identifier, + string hintName, + FeatureInformation featureInformation, + ImmutableArray attributes = default) + { + Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier)); + + if (string.IsNullOrEmpty(hintName)) + { + throw new ArgumentException("Value cannot be null or an empty string.", nameof(hintName)); + } + + HintName = hintName; + FeatureInformation = featureInformation; + + Attributes = attributes.IsDefault ? ImmutableArray.Empty : attributes; + } + + protected TestFixtureClass(TestFixtureDescriptor descriptor) : + this( + descriptor.Identifier ?? throw new ArgumentException($"{nameof(descriptor.Identifier)} cannot be null.", nameof(descriptor)), + descriptor.HintName ?? throw new ArgumentException($"{nameof(descriptor.HintName)} cannot be null.", nameof(descriptor)), + descriptor.Feature ?? throw new ArgumentException($"{nameof(descriptor.Feature)} cannot be null.", nameof(descriptor)), + descriptor.Attributes) + { + } + + /// + /// Gets the identifier of the class. + /// + public QualifiedTypeIdentifier Identifier { get; } + + /// + /// Gets the interfaces which are implemented by the class. + /// + public virtual ImmutableArray Interfaces => ImmutableArray.Empty; + + /// + /// Gets the attributes which are applied to the class. + /// + public ImmutableArray Attributes { get; } + + /// + /// Gets the hint name associated with the test class. + /// + public string HintName { get; } + + /// + /// Gets the feature information that will be included in the test fixture. + /// + public FeatureInformation FeatureInformation { get; } + + /// + /// Gets the test methods which make up the fixture. + /// + public abstract IEnumerable GetMethods(); + + public override bool Equals(object obj) => Equals(obj as TestFixtureClass); + + protected bool Equals(TestFixtureClass? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (GetType() != other.GetType()) + { + return false; + } + + return Identifier.Equals(other.Identifier) && + (Attributes.Equals(other.Attributes) || Attributes.SetEquals(other.Attributes)) && + HintName.Equals(other.HintName, StringComparison.Ordinal) && + FeatureInformation.Equals(other.FeatureInformation); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 83155477; + + hash *= 87057149 + Identifier.GetHashCode(); + hash *= 87057149 + Attributes.GetSetHashCode(); + hash *= 87057149 + StringComparer.Ordinal.GetHashCode(HintName); + hash *= 87057149 + FeatureInformation.GetHashCode(); + + return hash; + } + } + + /// + /// Renders the configured test fixture to source text. + /// + /// A token used to signal when rendering should be canceled. + /// A representing the rendered test fixture. + public abstract SourceText Render(CancellationToken cancellationToken = default); +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/TestMethod.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/TestMethod.cs new file mode 100644 index 000000000..b5bd132a5 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/TestMethod.cs @@ -0,0 +1,140 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +/// +/// Represents a test method that executes a scenario. +/// +public abstract class TestMethod : IEquatable, IHasAttributes +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the method. + /// The scenario associated with the method. + /// The step invocations performed by the method. + /// The attributes applied to the method. + /// The parameters defined by the method. + /// A map of scenario parameters to the identifiers that will be used to supply + /// a value for each parameter. + /// + /// is an empty identifier string. + /// + protected TestMethod( + IdentifierString identifier, + ScenarioInformation scenario, + ImmutableArray stepInvocations, + ImmutableArray attributes = default, + ImmutableArray parameters = default, + ImmutableArray> scenarioParameters = default) + { + if (identifier.IsEmpty) + { + throw new ArgumentException("Value cannot be an empty identifier.", nameof(identifier)); + } + + Identifier = identifier; + Scenario = scenario; + StepInvocations = stepInvocations.IsDefault ? ImmutableArray.Empty : stepInvocations; + Attributes = attributes.IsDefault ? ImmutableArray.Empty : attributes; + Parameters = parameters.IsDefault ? ImmutableArray.Empty : parameters; + ParametersOfScenario = scenarioParameters.IsDefault ? ImmutableArray>.Empty : scenarioParameters; + } + + /// + /// Initializes a new inastance of the class from a descriptor. + /// + /// A description of the method to create. + /// + /// The is incomplete. + /// + protected TestMethod(TestMethodDescriptor descriptor) + : this( + descriptor.Identifier, + descriptor.Scenario ?? throw new ArgumentException( + $"{nameof(descriptor.Scenario)} property cannot be null.", + nameof(descriptor)), + descriptor.StepInvocations, + descriptor.Attributes, + descriptor.Parameters, + descriptor.ScenarioParameters) + { + } + + /// + /// Gets the identifier of the test method. + /// + public IdentifierString Identifier { get; } + + /// + /// Gets the step invocations the method performs. + /// + public ImmutableArray StepInvocations { get; } + + /// + /// Gets the attributes applied to the method. + /// + public ImmutableArray Attributes { get; } + + /// + /// Gets the parameters which are defined by this method. + /// + public ImmutableArray Parameters { get; } + + /// + /// Gets the arguments of the scenario, as they map to parameters. + /// + public ImmutableArray> ParametersOfScenario { get; } + + /// + /// Gets information about the scenario associated with the test method. + /// + public ScenarioInformation Scenario { get; } + + public override bool Equals(object obj) => Equals(obj as TestMethod); + + public override int GetHashCode() + { + unchecked + { + var hash = 86434151; + + hash *= 83155477 + GetType().GetHashCode(); + + hash *= 83155477 + Identifier.GetHashCode(); + hash *= 83155477 + Scenario.GetHashCode(); + hash *= 83155477 + StepInvocations.GetSequenceHashCode(); + hash *= 83155477 + Attributes.GetSetHashCode(); + hash *= 83155477 + Parameters.GetSequenceHashCode(); + hash *= 83155477 + ParametersOfScenario.GetSequenceHashCode(); + + return hash; + } + } + + public virtual bool Equals(TestMethod? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (GetType() != other.GetType()) + { + return false; + } + + return + Identifier.Equals(other.Identifier) && + Scenario.Equals(other.Scenario) && + (StepInvocations.Equals(other.StepInvocations) || StepInvocations.SequenceEqual(other.StepInvocations)) && + (Attributes.Equals(other.Attributes) || Attributes.SetEquals(other.Attributes)) && + (Parameters.Equals(other.Parameters) || Parameters.SequenceEqual(other.Parameters)) && + (ParametersOfScenario.Equals(other.ParametersOfScenario) || ParametersOfScenario.SequenceEqual(other.ParametersOfScenario)); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/TestMethodDescriptor.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/TestMethodDescriptor.cs new file mode 100644 index 000000000..e4f0101ae --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/TestMethodDescriptor.cs @@ -0,0 +1,21 @@ +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +/// +/// A mutable description of a test method, used to help construct test method instances. +/// +public class TestMethodDescriptor +{ + public IdentifierString Identifier { get; set; } + + public ScenarioInformation? Scenario { get; set; } + + public ImmutableArray StepInvocations { get; set; } + + public ImmutableArray Attributes { get; set; } + + public ImmutableArray Parameters { get; set; } + + public ImmutableArray> ScenarioParameters { get; set; } +} diff --git a/Reqnroll.FeatureSourceGenerator/SourceModel/TypeIdentifier.cs b/Reqnroll.FeatureSourceGenerator/SourceModel/TypeIdentifier.cs new file mode 100644 index 000000000..42027d299 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/SourceModel/TypeIdentifier.cs @@ -0,0 +1,6 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public abstract class TypeIdentifier +{ + public abstract bool IsNullable { get; } +} diff --git a/Reqnroll.FeatureSourceGenerator/StepResult.cs b/Reqnroll.FeatureSourceGenerator/StepResult.cs new file mode 100644 index 000000000..07c4644c7 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/StepResult.cs @@ -0,0 +1,82 @@ +namespace Reqnroll.FeatureSourceGenerator; + +/// +/// A "discriminated union" that represents the result of a step in the generator, either the successful +/// output or a diagnostic explaining why the output could not be produced. +/// +/// The type of output from the step. +/// The value from the step, if one was produced. +/// The diagnostic of the step, if one was produced. +internal readonly struct StepResult : IEquatable> +{ + private readonly T? _value; + private readonly Diagnostic? _diagnostic; + + private StepResult(T? value = default, Diagnostic? diagnostic = default) + { + _value = value; + _diagnostic = diagnostic; + } + + public readonly bool IsSuccess => _diagnostic == null; + + public static implicit operator T (StepResult result) + { + if (!result.IsSuccess) + { + throw new InvalidCastException("Cannot cast a non-success result to a value."); + } + + return result._value!; + } + + public static implicit operator Diagnostic (StepResult result) + { + if (result.IsSuccess) + { + throw new InvalidCastException("Cannot cast a success result to a diagnostic."); + } + + return result._diagnostic!; + } + + public static implicit operator StepResult (T value) => new(value); + + public static implicit operator StepResult(Diagnostic diagniostic) => new(diagnostic: diagniostic); + + public override int GetHashCode() => IsSuccess ? _value?.GetHashCode() ?? 0 : _diagnostic!.GetHashCode(); + + public override bool Equals(object obj) + { + if (obj is StepResult other) + { + return Equals(other); + } + + return false; + } + + public override string ToString() => IsSuccess ? _value?.ToString() ?? "null" : _diagnostic!.ToString(); + + public bool Equals(StepResult other) + { + if (IsSuccess != other.IsSuccess) + { + return false; + } + + if (IsSuccess) + { + if (_value is null) + { + return other._value is null; + } + + return _value.Equals(other._value); + } + else + { + return _diagnostic!.Equals(other._diagnostic); + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/TestFixtureComposition.cs b/Reqnroll.FeatureSourceGenerator/TestFixtureComposition.cs new file mode 100644 index 000000000..b03866a38 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/TestFixtureComposition.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; + +internal record TestFixtureComposition( + TestFixtureGenerationContext Context, + ImmutableArray Methods) + where TCompilationInformation : CompilationInformation +{ + public override int GetHashCode() + { + unchecked + { + var hash = 49151149; + + hash *= 983819 + Context.GetHashCode(); + hash *= 983819 + Methods.GetSetHashCode(); + + return hash; + } + } + + public virtual bool Equals(TestFixtureComposition? other) + { + if (other is null) + { + return false; + } + + return Context.Equals(other.Context) && + (Methods.Equals(other.Methods) || Methods.SetEqual(other.Methods)); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/TestFixtureGenerationContext.cs b/Reqnroll.FeatureSourceGenerator/TestFixtureGenerationContext.cs new file mode 100644 index 000000000..0b91e1bc8 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/TestFixtureGenerationContext.cs @@ -0,0 +1,71 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator; + +public class TestFixtureGenerationContext( + FeatureInformation featureInformation, + ImmutableArray scenarioInformations, + string featureHintName, + NamespaceString testFixtureNamespace, + TCompilationInformation compilationInformation, + ITestFixtureGenerator testFixtureGenerator) + where TCompilationInformation : CompilationInformation +{ + public FeatureInformation FeatureInformation { get; } = featureInformation; + + public ImmutableArray ScenarioInformations { get; } = + scenarioInformations.IsDefault ? + ImmutableArray.Empty : + scenarioInformations; + + public string FeatureHintName { get; } = featureHintName; + + public NamespaceString TestFixtureNamespace { get; } = testFixtureNamespace; + + public TCompilationInformation CompilationInformation { get; } = compilationInformation; + + public ITestFrameworkHandler TestFrameworkHandler => TestFixtureGenerator.TestFrameworkHandler; + + public ITestFixtureGenerator TestFixtureGenerator { get; } = testFixtureGenerator; + + public bool Equals(TestFixtureGenerationContext? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(other, this)) + { + return true; + } + + return FeatureInformation.Equals(other.FeatureInformation) && + ScenarioInformations.SequenceEqual(other.ScenarioInformations) && + FeatureHintName.Equals(other.FeatureHintName, StringComparison.Ordinal) && + TestFixtureNamespace.Equals(other.TestFixtureNamespace) && + CompilationInformation.Equals(other.CompilationInformation) && + TestFixtureGenerator.Equals(other.TestFixtureGenerator); + } + + public override bool Equals(object obj) => Equals(obj as TestFixtureGenerationContext); + + public override int GetHashCode() + { + unchecked + { + var hash = 37970701; + + hash *= 99584197 + FeatureInformation.GetHashCode(); + hash *= 99584197 + ScenarioInformations.GetSequenceHashCode(); + hash *= 99584197 + FeatureHintName.GetHashCode(); + hash *= 99584197 + TestFixtureNamespace.GetHashCode(); + hash *= 99584197 + CompilationInformation.GetHashCode(); + hash *= 99584197 + TestFixtureGenerator.GetHashCode(); + + return hash; + } + } +} + diff --git a/Reqnroll.FeatureSourceGenerator/TestFixtureSourceGenerator.cs b/Reqnroll.FeatureSourceGenerator/TestFixtureSourceGenerator.cs new file mode 100644 index 000000000..da7bbf300 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/TestFixtureSourceGenerator.cs @@ -0,0 +1,493 @@ +using Gherkin; +using Gherkin.Ast; +using Reqnroll.FeatureSourceGenerator.Gherkin; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; +using System.Diagnostics; + +namespace Reqnroll.FeatureSourceGenerator; + +using Location = Microsoft.CodeAnalysis.Location; + +/// +/// Defines the basis of a source-generator which processes Gherkin feature files into test fixtures. +/// +public abstract class TestFixtureSourceGenerator( + ImmutableArray testFrameworkHandlers) : IIncrementalGenerator + where TCompilationInformation : CompilationInformation +{ + public static readonly DiagnosticDescriptor NoTestFrameworkFound = new( + id: DiagnosticIds.NoTestFrameworkFound, + title: TestFixtureSourceGeneratorResources.NoTestFrameworkFoundTitle, + messageFormat: TestFixtureSourceGeneratorResources.NoTestFrameworkFoundMessage, + "Reqnroll", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor TestFrameworkNotSupported = new( + id: DiagnosticIds.TestFrameworkNotSupported, + title: TestFixtureSourceGeneratorResources.TestFrameworkNotSupportedTitle, + messageFormat: TestFixtureSourceGeneratorResources.TestFrameworkNotSupportedMessage, + "Reqnroll", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private readonly ImmutableArray _testFrameworkHandlers = testFrameworkHandlers; + + protected TestFixtureSourceGenerator() : this( + ImmutableArray.Create( + BuiltInTestFrameworkHandlers.NUnit, + BuiltInTestFrameworkHandlers.MSTest, + BuiltInTestFrameworkHandlers.XUnit)) + { + } + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Get all feature files in the solution. + var featureFiles = context.AdditionalTextsProvider + .Where(text => text.Path.EndsWith(".feature", StringComparison.OrdinalIgnoreCase)); + + // Extract information about the compilation. + var compilationInformation = context.CompilationProvider + .Select((compilation, _) => GetCompilationInformation(compilation)); + + // Find compatible generator and choose a default based on referenced assemblies. + var generatorInformation = compilationInformation + .Select((compilationInfo, cancellationToken) => + { + var compatibleGenerators = _testFrameworkHandlers + .Select(handler => handler.GetTestFixtureGenerator(compilationInfo)!) + .Where(generator => generator != null) + .ToImmutableArray(); + + if (!compatibleGenerators.Any()) + { + // This condition should only be possible if Roslyn is compiling a language we have produced a generator for + // without also including a compatible test framework handler; it should never occur in practice. + throw new InvalidOperationException( + $"No test framework handlers are available which can generate code for the current compilation."); + } + + var useableGenerators = compatibleGenerators + .Where(generator => generator.TestFrameworkHandler.IsTestFrameworkReferenced(compilationInfo)) + .ToImmutableArray(); + + var defaultGenerator = useableGenerators.FirstOrDefault(); + + return new GeneratorInformation( + compatibleGenerators, + useableGenerators, + defaultGenerator); + }); + + // Obtain the options which will influence the generation of features and combine with all other information + // to produce parsed syntax ready for translation into test fixtures. + var testFixtureGenerationContextsOrErrors = featureFiles + .Combine(context.AnalyzerConfigOptionsProvider) + .Combine(compilationInformation) + .Combine(generatorInformation) + .SelectMany(static IEnumerable>> (input, cancellationToken) => + { + var (((featureFile, optionsProvider), compilationInfo), generatorInformation) = input; + + var options = optionsProvider.GetOptions(featureFile); + + // Launch a debugger if configured. + if (options.GetBooleanValue( + "reqnroll_feature_source_generator.launch_debugger", + "build_property.ReqnrollDebugGenerator") ?? false) + { + Debugger.Launch(); + } + + var source = featureFile.GetText(cancellationToken); + + // If there is no source text, we can skip this file completely. + if (source == null) + { + return []; + } + + // Select the generator from the following sources: + // 1. The reqnroll_feature_source_generator.target_test_framework value from .editorconfig + // 2. The ReqnrollTargetTestFramework from the build system properties (MSBuild project files or command-line argument) + // 3. The assemblies referenced by the compilation indicating the presence of a test framework. + ITestFixtureGenerator? generator; + var targetTestFrameworkIdentifier = options.GetStringValue( + "reqnroll_feature_source_generator.target_test_framework", + "build_property.ReqnrollTargetTestFramework"); + if(!string.IsNullOrEmpty(targetTestFrameworkIdentifier)) + { + // Select the target framework from the option specified. + generator = generatorInformation.CompatibleGenerators + .SingleOrDefault(generator => string.Equals( + generator.TestFrameworkHandler.TestFrameworkName, + targetTestFrameworkIdentifier, + StringComparison.OrdinalIgnoreCase)); + + if (generator == null) + { + // The properties specified a test framework which is not recognised or not supported for this language. + var frameworkNames = generatorInformation.CompatibleGenerators + .Select(generator => generator.TestFrameworkHandler.TestFrameworkName); + var frameworks = string.Join(", ", frameworkNames); + + return + [ + Diagnostic.Create( + TestFrameworkNotSupported, + Location.None, + targetTestFrameworkIdentifier, + frameworks) + ]; + } + } + else if (generatorInformation.DefaultGenerator != null) + { + // Use the default handler. + generator = generatorInformation.DefaultGenerator; + } + else + { + // Report that no suitable target test framework could be determined. + var frameworkNames = generatorInformation.CompatibleGenerators + .Select(generator => generator.TestFrameworkHandler.TestFrameworkName); + var frameworks = string.Join(", ", frameworkNames); + + return + [ + Diagnostic.Create( + NoTestFrameworkFound, + Location.None, + frameworks) + ]; + } + + // Determine the hint path and namespace for the generated test fixtured based on the project and the relative file path. + var rootNamespace = optionsProvider.GlobalOptions.GetStringValue("build_property.RootNamespace") ?? + compilationInfo.AssemblyName ?? + "ReqnrollFeatures"; + + var featureHintName = Path.GetFileNameWithoutExtension(featureFile.Path); + var testFixtureNamespace = rootNamespace; + var relativeDir = options.GetStringValue("build_metadata.AdditionalFiles.RelativeDir"); + if (!string.IsNullOrEmpty(relativeDir)) + { + var testFixtureNamespaceParts = relativeDir! + .Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.') + .Split(['.'], StringSplitOptions.RemoveEmptyEntries) + .Select(part => DotNetSyntax.CreateIdentifier(part)) + .ToList(); + + testFixtureNamespaceParts.Insert(0, testFixtureNamespace); + + testFixtureNamespace = string.Join(".", testFixtureNamespaceParts); + + featureHintName = relativeDir + featureHintName; + } + + // Parse the feature file and output the result. + var parser = new Parser { StopAtFirstError = false }; + + cancellationToken.ThrowIfCancellationRequested(); + + GherkinDocument document; + + try + { + // CONSIDER: Using a parser that doesn't throw exceptions for syntax errors. + // CONSIDER: Using a parser that uses Roslyn text data-types. + // CONSIDER: Using a parser that supports incremental parsing. + // CONSIDER: Using a parser that provides human-readable syntax errors. + document = parser.Parse(new SourceTokenScanner(source)); + } + catch (CompositeParserException ex) + { + // We can't report errors for specific files using the built-in diagnostic system + // https://github.com/dotnet/roslyn/issues/49531 + // Instead we will report the error by writing it as output. + // Use the diagnostic to convey the feature hint name we'll use to write the error. + var diagnostics = ex.Errors + .Select(error => GherkinSyntaxParser.CreateGherkinDiagnostic(error, source, featureFile.Path)); + + return [.. diagnostics]; + } + + // Determine whether we should include ignored examples in our sample sets. + var emitIgnoredExamples = options.GetBooleanValue( + "reqnroll_feature_source_generator.emit_ignored_examples", + "build_metadata.AdditionalFiles.EmitIgnoredExamples", + "build_property.ReqnrollEmitIgnoredExamples") ?? false; + + var feature = document.Feature; + + var featureInformation = new FeatureInformation( + feature.Name, + feature.Description, + feature.Language, + feature.Tags.Select(tag => tag.Name.TrimStart('@')).ToImmutableArray(), + featureFile.Path); + + var scenarioInformations = CreateScenarioInformations(featureFile.Path, feature, emitIgnoredExamples, cancellationToken); + + return + [ + new TestFixtureGenerationContext( + featureInformation, + scenarioInformations.ToImmutableArray(), + featureHintName, + new NamespaceString(testFixtureNamespace), + compilationInfo, + generator) + ]; + }); + + + // Filter contexts and errors. + var testFixtureGenerationContexts = testFixtureGenerationContextsOrErrors + .Where(result => result.IsSuccess) + .Select((result, _) => (TestFixtureGenerationContext)result); + var errors = testFixtureGenerationContextsOrErrors + .Where(result => !result.IsSuccess) + .Select((result, _) => (Diagnostic)result); + + // Set up test method generation contexts from feature generation contexts. + var testMethodGenerationContexts = testFixtureGenerationContexts + .SelectMany(static (context, cancellationToken) => + context.ScenarioInformations.Select(scenario => + new TestMethodGenerationContext(scenario, context))); + + // Generate test methods for each scenario. + var methods = testMethodGenerationContexts + .Select(static (context, cancellationToken) => + (Method: context.TestFixtureGenerator.GenerateTestMethod(context, cancellationToken), + Context: context)); + + // Generate test fixtures for each feature. + var fixtures = methods.Collect() + .WithComparer(ImmutableArrayEqualityComparer<(TestMethod Method, TestMethodGenerationContext Context)>.Default) + .SelectMany(static (methods, cancellationToken) => + methods + .GroupBy(item => item.Context.TestFixtureGenerationContext, item => item.Method) + .Select(group => new TestFixtureComposition(group.Key, group.ToImmutableArray()))) + .Select(static (composition, cancellationToken) => + composition.Context.TestFixtureGenerator.GenerateTestFixtureClass( + composition.Context, + composition.Methods, + cancellationToken)); + + // Emit errors. + context.RegisterSourceOutput( + errors, + static (context, error) => + { + // We can't report errors for specific files using the built-in diagnostic system + // https://github.com/dotnet/roslyn/issues/49531 + // Instead we will report the error by writing it as output. + + //if (error.Location == Location.None) + //{ + context.ReportDiagnostic(error); + //} + //else + //{ + // context.AddSource(error.Location.GetLineSpan().Path, SourceText.From("")) + //} + }); + + // Emit source files for fixtures. + context.RegisterSourceOutput( + fixtures, + static (context, fixture) => context.AddSource(fixture.HintName, fixture.Render(context.CancellationToken))); + } + + private static IEnumerable CreateScenarioInformations( + string sourceFilePath, + Feature feature, + bool emitIgnoredExamples, + CancellationToken cancellationToken) + { + var children = feature.Children.ToList(); + + var scenarios = new List(); + var backgroundSteps = new List(); + + if (children.FirstOrDefault() is Background background) + { + PopulateSteps(backgroundSteps, sourceFilePath, background, cancellationToken); + } + + foreach (var child in children) + { + cancellationToken.ThrowIfCancellationRequested(); + + switch (child) + { + case Scenario scenario: + scenarios.Add( + CreateScenarioInformation(sourceFilePath, scenario, backgroundSteps, emitIgnoredExamples, cancellationToken)); + break; + + case Rule rule: + scenarios.AddRange( + CreateScenarioInformations(sourceFilePath, rule, backgroundSteps, emitIgnoredExamples, cancellationToken)); + break; + } + } + + return scenarios; + } + + private static IEnumerable CreateScenarioInformations( + string sourceFilePath, + Rule rule, + IReadOnlyList backgroundSteps, + bool emitIgnoredExamples, + CancellationToken cancellationToken) + { + var tags = rule.Tags.Select(tag => tag.Name.TrimStart('@')).ToImmutableArray(); + + var children = rule.Children.ToList(); + + var scenarios = new List(); + var combinedBackgroundSteps = backgroundSteps.ToList(); + + if (children.FirstOrDefault() is Background background) + { + PopulateSteps(combinedBackgroundSteps, sourceFilePath, background, cancellationToken); + } + + foreach (var child in rule.Children) + { + cancellationToken.ThrowIfCancellationRequested(); + + switch (child) + { + case Scenario scenario: + yield return CreateScenarioInformation( + sourceFilePath, + scenario, + combinedBackgroundSteps, + new RuleInformation(rule.Name, tags), + emitIgnoredExamples, + cancellationToken); + break; + } + } + } + + private static ScenarioInformation CreateScenarioInformation( + string sourceFilePath, + Scenario scenario, + IReadOnlyList backgroundSteps, + bool emitIgnoredExamples, + CancellationToken cancellationToken) => CreateScenarioInformation( + sourceFilePath, + scenario, + backgroundSteps, + null, + emitIgnoredExamples, + cancellationToken); + + private static ScenarioInformation CreateScenarioInformation( + string sourceFilePath, + Scenario scenario, + IReadOnlyList backgroundSteps, + RuleInformation? rule, + bool emitIgnoredExamples, + CancellationToken cancellationToken) + { + var exampleSets = new List(); + + foreach (var example in scenario.Examples) + { + cancellationToken.ThrowIfCancellationRequested(); + + var examples = new ScenarioExampleSet( + example.TableHeader.Cells.Select(cell => cell.Value).ToImmutableArray(), + example.TableBody.Select(row => row.Cells.Select(cell => cell.Value).ToImmutableArray()).ToImmutableArray(), + example.Tags.Select(tag => tag.Name.TrimStart('@')).ToImmutableArray()); + + if (!emitIgnoredExamples && examples.Tags.Contains("ignore", StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + exampleSets.Add(examples); + } + + var steps = backgroundSteps.ToList(); + PopulateSteps(steps, sourceFilePath, scenario, cancellationToken); + + var keywordAndNameStartPosition = scenario.Location.ToLinePosition(); + // We assume as single character gap between keyword and scenario name; could be more. + var keywordAndNameEndPosition = new LinePosition( + scenario.Location.Line, + scenario.Location.Column + scenario.Keyword.Length + scenario.Name.Length + 1); + + return new ScenarioInformation( + scenario.Name, + new FileLinePositionSpan(sourceFilePath, keywordAndNameStartPosition, keywordAndNameEndPosition), + scenario.Tags.Select(tag => tag.Name.TrimStart('@')).ToImmutableArray(), + steps.ToImmutableArray(), + exampleSets.ToImmutableArray(), + rule); + } + + private static void PopulateSteps( + List steps, + string sourceFilePath, + StepsContainer container, + CancellationToken cancellationToken) + { + foreach (var step in container.Steps) + { + cancellationToken.ThrowIfCancellationRequested(); + + var startPosition = step.Location.ToLinePosition(); + // We assume as single character gap between keyword and step text; could be more. + var endPosition = new LinePosition(startPosition.Line, startPosition.Character + step.Keyword.Length + step.Text.Length + 1); + var position = new FileLinePositionSpan(sourceFilePath, new LinePositionSpan(startPosition, endPosition)); + + string? docStringArgument = null; + SourceModel.DataTable? dataTableArgument = null; + + if (step.Argument is DocString docString) + { + docStringArgument = docString.Content; + } + else if (step.Argument is global::Gherkin.Ast.DataTable dataTable) + { + var headings = dataTable.Rows.First().Cells + .Select(cell => cell.Value) + .ToImmutableArray(); + var rows = dataTable.Rows.Skip(1) + .Select(row => row.Cells.Select(cell => cell.Value).ToImmutableArray()) + .ToImmutableArray(); + + dataTableArgument = new SourceModel.DataTable(headings, rows); + } + + var scenarioStep = new SourceModel.Step( + step.KeywordType switch + { + StepKeywordType.Context => StepType.Context, + StepKeywordType.Action => StepType.Action, + StepKeywordType.Outcome => StepType.Outcome, + StepKeywordType.Conjunction => StepType.Conjunction, + _ => throw new NotSupportedException() + }, + step.Keyword, + step.Text, + position, + dataTableArgument, + docStringArgument); + + steps.Add(scenarioStep); + } + } + + protected abstract TCompilationInformation GetCompilationInformation(Compilation compilation); +} diff --git a/Reqnroll.FeatureSourceGenerator/TestFixtureSourceGeneratorResources.resx b/Reqnroll.FeatureSourceGenerator/TestFixtureSourceGeneratorResources.resx new file mode 100644 index 000000000..0f93cf687 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/TestFixtureSourceGeneratorResources.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + No compatible test framework is referenced by the project. Add a reference to one of the supported test frameworks: {0} + + + No test framework detected + + + The specified test framework "{0}" is not supported by the current compilation. Supported test framework identifiers: {1} + + + Test framework not supported + + \ No newline at end of file diff --git a/Reqnroll.FeatureSourceGenerator/TestFixtureSourceRenderingContext.cs b/Reqnroll.FeatureSourceGenerator/TestFixtureSourceRenderingContext.cs new file mode 100644 index 000000000..d5726fb15 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/TestFixtureSourceRenderingContext.cs @@ -0,0 +1,23 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Diagnostics; + +namespace Reqnroll.FeatureSourceGenerator; + +/// +/// Provides information about the rendering of a test-fixture's source. +/// +/// The feature that is the primary source of the test-fixture. +/// The hint name of the test-fixture source being rendered. +[DebuggerDisplay("OutputHintName={OutputHintName}")] +public class TestFixtureSourceRenderingContext(FeatureInformation feature, string outputHintName) +{ + /// + /// Gets the hint name of the test-fixture source being rendered. + /// + public string OutputHintName { get; } = outputHintName; + + /// + /// Gets the feature that is the primary source of the test-fixture. + /// + public FeatureInformation Feature { get; } = feature; +} diff --git a/Reqnroll.FeatureSourceGenerator/TestMethodGenerationContext.cs b/Reqnroll.FeatureSourceGenerator/TestMethodGenerationContext.cs new file mode 100644 index 000000000..207c26dad --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/TestMethodGenerationContext.cs @@ -0,0 +1,49 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; + +public class TestMethodGenerationContext( + ScenarioInformation scenarioInformation, + TestFixtureGenerationContext testFixtureGenerationContext) : + IEquatable?> + where TCompilationInformation : CompilationInformation +{ + public TestFixtureGenerationContext TestFixtureGenerationContext { get; } = testFixtureGenerationContext; + + public ScenarioInformation ScenarioInformation { get; } = scenarioInformation; + + public ITestFixtureGenerator TestFixtureGenerator => TestFixtureGenerationContext.TestFixtureGenerator; + + public FeatureInformation FeatureInformation => TestFixtureGenerationContext.FeatureInformation; + + public override bool Equals(object obj) => Equals(obj as TestMethodGenerationContext); + + public bool Equals(TestMethodGenerationContext? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return TestFixtureGenerationContext.Equals(other.TestFixtureGenerationContext) && + ScenarioInformation.Equals(other.ScenarioInformation); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 14353993; + + hash *= 50733161 + TestFixtureGenerationContext.GetHashCode(); + hash *= 50733161 + ScenarioInformation.GetHashCode(); + + return hash; + } + } +} diff --git a/Reqnroll.FeatureSourceGenerator/XUnit/XUnitHandler.cs b/Reqnroll.FeatureSourceGenerator/XUnit/XUnitHandler.cs new file mode 100644 index 000000000..892e87b0d --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/XUnit/XUnitHandler.cs @@ -0,0 +1,32 @@ +using Reqnroll.FeatureSourceGenerator.CSharp; +using Reqnroll.FeatureSourceGenerator.CSharp.XUnit; + +namespace Reqnroll.FeatureSourceGenerator.XUnit; + +/// +/// The handler for xUnit. +/// +public class XUnitHandler : ITestFrameworkHandler +{ + internal static readonly NamespaceString XUnitNamespace = new("Xunit"); + + public string TestFrameworkName => "xUnit"; + + public ITestFixtureGenerator? GetTestFixtureGenerator( + TCompilationInformation compilation) + where TCompilationInformation : CompilationInformation + { + if (typeof(TCompilationInformation).IsAssignableFrom(typeof(CSharpCompilationInformation))) + { + return (ITestFixtureGenerator)new XUnitCSharpTestFixtureGenerator(this); + } + + return null; + } + + public bool IsTestFrameworkReferenced(CompilationInformation compilationInformation) + { + return compilationInformation.ReferencedAssemblies + .Any(assembly => assembly.Name == "xunit.core"); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/XUnit/XUnitSyntax.cs b/Reqnroll.FeatureSourceGenerator/XUnit/XUnitSyntax.cs new file mode 100644 index 000000000..9ab9a8c58 --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/XUnit/XUnitSyntax.cs @@ -0,0 +1,61 @@ +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.XUnit; +internal static class XUnitSyntax +{ + public static readonly NamespaceString XUnitNamespace = new("Xunit"); + + internal static QualifiedTypeIdentifier AsyncLifetimeType() + { + return XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("IAsyncLifetime")); + } + + internal static AttributeDescriptor InlineDataAttribute(ImmutableArray values) + { + return new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("InlineData")), + values); + } + + internal static QualifiedTypeIdentifier LifetimeInterfaceType(QualifiedTypeIdentifier testFixtureType) + { + return XUnitNamespace + new GenericTypeIdentifier( + new IdentifierString("IClassFixture"), + ImmutableArray.Create( + new NestedTypeIdentifier( + testFixtureType.LocalType, + new SimpleTypeIdentifier(new IdentifierString("FeatureLifetime"))))); + } + + internal static AttributeDescriptor SkippableFactAttribute(string displayName) + { + return new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("SkippableFact")), + namedArguments: ImmutableDictionary.CreateRange( + [ + new KeyValuePair( + new IdentifierString("DisplayName"), + displayName) + ])); + } + + internal static AttributeDescriptor SkippableTheoryAttribute(string displayName) + { + return new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("SkippableTheory")), + namedArguments: ImmutableDictionary.CreateRange( + [ + new KeyValuePair( + new IdentifierString("DisplayName"), + displayName) + ])); + } + + internal static AttributeDescriptor TraitAttribute(string name, string value) + { + return new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Trait")), + ImmutableArray.Create(name, value)); + } +} diff --git a/Reqnroll.FeatureSourceGenerator/build/Reqnroll.FeatureSourceGenerator.props b/Reqnroll.FeatureSourceGenerator/build/Reqnroll.FeatureSourceGenerator.props new file mode 100644 index 000000000..e27e6754e --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/build/Reqnroll.FeatureSourceGenerator.props @@ -0,0 +1,18 @@ + + + false + + + + + + + + + + + + + + + diff --git a/Reqnroll.FeatureSourceGenerator/build/Reqnroll.FeatureSourceGenerator.targets b/Reqnroll.FeatureSourceGenerator/build/Reqnroll.FeatureSourceGenerator.targets new file mode 100644 index 000000000..914772a0b --- /dev/null +++ b/Reqnroll.FeatureSourceGenerator/build/Reqnroll.FeatureSourceGenerator.targets @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.props b/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.props index 7569d70ad..4046ff03c 100644 --- a/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.props +++ b/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.props @@ -1,11 +1,11 @@ + true $(MSBuildThisFileDirectory)CPS\Buildsystem\CpsExtension.DesignTime.targets - - + false diff --git a/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.targets b/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.targets index ffcd60e86..e0f2346f3 100644 --- a/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.targets +++ b/Reqnroll.Tools.MsBuild.Generation/build/Reqnroll.Tools.MsBuild.Generation.targets @@ -41,7 +41,7 @@ - + + Condition="'$(Reqnroll_EnableWarnForFeatureCodeBehindFilesWithoutCorrespondingFeatureFile)' == 'true' AND '$(ReqnrollEnableMSBuildGeneration)' == 'true'"> @@ -66,7 +66,8 @@ + DependsOnTargets="BeforeUpdateFeatureFilesInProject" + Condition="'$(ReqnrollEnableMSBuildGeneration)' == 'true'"> @@ -139,11 +140,11 @@ - + - + @@ -156,11 +157,11 @@ - + - + diff --git a/Reqnroll.sln b/Reqnroll.sln index 521df415a..f51690ed6 100644 --- a/Reqnroll.sln +++ b/Reqnroll.sln @@ -116,6 +116,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Resources", "Resources", "{ reqnroll.ico = reqnroll.ico EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Reqnroll.FeatureSourceGeneratorTests", "Tests\Reqnroll.FeatureSourceGeneratorTests\Reqnroll.FeatureSourceGeneratorTests.csproj", "{A470519A-EC44-40A9-9D1D-9667C1049F9D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator\Reqnroll.FeatureSourceGenerator.csproj", "{33DBD7B5-00CB-4463-9204-1E3B73E5B39D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -242,6 +246,14 @@ Global {6CBEB31D-5F98-460F-B193-578AEEA78CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CBEB31D-5F98-460F-B193-578AEEA78CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU {6CBEB31D-5F98-460F-B193-578AEEA78CF9}.Release|Any CPU.Build.0 = Release|Any CPU + {A470519A-EC44-40A9-9D1D-9667C1049F9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A470519A-EC44-40A9-9D1D-9667C1049F9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A470519A-EC44-40A9-9D1D-9667C1049F9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A470519A-EC44-40A9-9D1D-9667C1049F9D}.Release|Any CPU.Build.0 = Release|Any CPU + {33DBD7B5-00CB-4463-9204-1E3B73E5B39D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33DBD7B5-00CB-4463-9204-1E3B73E5B39D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33DBD7B5-00CB-4463-9204-1E3B73E5B39D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33DBD7B5-00CB-4463-9204-1E3B73E5B39D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -280,6 +292,7 @@ Global {C658B37D-FD36-4868-9070-4EB452FAE526} = {A10B5CD6-38EC-4D7E-9D1C-2EBA8017E437} {6CBEB31D-5F98-460F-B193-578AEEA78CF9} = {8BE0FE31-6A52-452E-BE71-B8C64A3ED402} {53475D78-4500-4399-9539-0D5403C13C7A} = {577A0375-1436-446C-802B-3C75C8CEF94F} + {A470519A-EC44-40A9-9D1D-9667C1049F9D} = {0359B7D7-7E29-48E9-8DF9-7D1FACFA5CFA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A4D0636F-0160-4FA5-81A3-9784C7E3B3A4} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/.editorconfig b/Tests/Reqnroll.FeatureSourceGeneratorTests/.editorconfig new file mode 100644 index 000000000..4c0a7ea39 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/.editorconfig @@ -0,0 +1,10 @@ +[*] +insert_final_newline = true +indent_size = 4 +indent_style = space + +[*.cs] +csharp_style_namespace_declarations = file_scoped + +[*.{csproj,targets,props}] +indent_size = 2 diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/AssertionExtensions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/AssertionExtensions.cs new file mode 100644 index 000000000..8af999d13 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/AssertionExtensions.cs @@ -0,0 +1,56 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Diagnostics.Contracts; + +namespace Reqnroll.FeatureSourceGenerator; +public static class AssertionExtensions +{ + /// + /// Returns an object that can be used to assert the + /// current . + /// + [Pure] + public static CSharpSyntaxAssertions Should(this TNode? actualValue) where TNode : CSharpSyntaxNode => + new(actualValue); + + /// + /// Returns an object that can be used to assert the + /// current . + /// + [Pure] + public static CSharpClassDeclarationAssertions Should(this ClassDeclarationSyntax? actualValue) => + new(actualValue); + + /// + /// Returns an object that can be used to assert the + /// current . + /// + [Pure] + public static CSharpNamespaceDeclarationAssertions Should(this NamespaceDeclarationSyntax? actualValue) => + new(actualValue); + + /// + /// Returns an object that can be used to assert the + /// current . + /// + [Pure] + public static CSharpMethodDeclarationAssertions Should(this MethodDeclarationSyntax? actualValue) => + new(actualValue); + + /// + /// Returns an object that can be used to assert the + /// subject with attributes. + /// + [Pure] + public static AttributeAssertions Should(this IHasAttributes? actualValue) => + new(actualValue); + + /// + /// Returns a object that can be used to assert the + /// subject with attributes. + /// + [Pure] + public static TestMethodAssertions Should(this TestMethod? actualValue) => + new(actualValue); +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeAssertions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeAssertions.cs new file mode 100644 index 000000000..d57146e5b --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeAssertions.cs @@ -0,0 +1,233 @@ +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; +public class AttributeAssertions(IHasAttributes? subject) : + AttributeAssertions(subject) +{ +} + +public class AttributeAssertions(TSubject subject) : + ReferenceTypeAssertions(subject) + where TSubject : IHasAttributes? + where TAssertions : AttributeAssertions +{ + protected override string Identifier => "subject"; + + ///// + ///// Expects the class declaration have only a single attribute with a specific identifier. + ///// + ///// + ///// The declared type of the attribute. + ///// + ///// + ///// A formatted phrase as is supported by explaining why the assertion + ///// is needed. If the phrase does not start with the word because, it is prepended automatically. + ///// + ///// + ///// Zero or more objects to format using the placeholders in . + ///// + //public AndWhichConstraint HaveSingleAttribute( + // string type, + // string because = "", + // params object[] becauseArgs) + //{ + // var expectation = "Expected {context:subject} to have a single attribute " + + // $"which is of type \"{type}\"{{reason}}"; + + // bool notNull = Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .ForCondition(Subject is not null) + // .FailWith(expectation + ", but found ."); + + // AttributeSyntax? match = default; + + // if (notNull) + // { + // var attributes = Subject!.Attributes; + + // switch (attributes.Length) + // { + // case 0: // Fail, Collection is empty + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .FailWith(expectation + ", but the class has no attributes."); + + // break; + // case 1: // Success Condition + // var single = attributes.Single(); + + // if (single.Name.ToString() != type) + // { + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .FailWith(expectation + ", but found the attribute \"{0}\".", single.Name); + // } + // else + // { + // match = single; + // } + + // break; + // default: // Fail, Collection contains more than a single item + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .FailWith(expectation + ", but found {0}.", attributes); + + // break; + // } + // } + + // return new AndWhichConstraint((TAssertions)this, match!); + //} + + //public AndWhichConstraint HaveAttribute( + // string type, + // string because = "", + // params object[] becauseArgs) + //{ + // var expectation = "Expected {context:subject} to have an attribute " + + // $"of type \"{type}\"{{reason}}"; + + // bool notNull = Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .ForCondition(Subject is not null) + // .FailWith(expectation + ", but found ."); + + // AttributeSyntax? match = default; + + // if (notNull) + // { + // var attributes = Subject!.AttributeLists.SelectMany(list => list.Attributes).ToList(); + + // if (attributes.Count == 0) + // { + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .FailWith(expectation + ", but the subject has no attributes."); + // } + // else + // { + // match = attributes.FirstOrDefault(attribute => attribute.Name.ToString() == type); + + // if (match == null) + // { + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .FailWith(expectation + ", but found {0}.", attributes); + // } + // } + // } + + // return new AndWhichConstraint((TAssertions)this, match!); + //} + + public AndConstraint HaveAttribuesEquivalentTo( + AttributeDescriptor[] expectation, + string because = "", + params object[] becauseArgs) => + HaveAttribuesEquivalentTo((IEnumerable)expectation, because, becauseArgs); + + public AndConstraint HaveAttribuesEquivalentTo( + IEnumerable expectation, + string because = "", + params object[] becauseArgs) + { + using var scope = new AssertionScope(); + + scope.FormattingOptions.UseLineBreaks = true; + + var assertion = Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:the subject} to have attributes equivalent to {0}", expectation) + .ForCondition(Subject is not null) + .FailWith("but {context:the subject} is ."); + + var expected = expectation.ToList(); + var actual = Subject!.Attributes; + + + var missing = expected.ToList(); + var extra = actual.ToList(); + + foreach (var item in expected) + { + if (extra.Remove(item)) + { + missing.Remove(item); + } + } + + if (missing.Count > 0) + { + if (extra.Count > 0) + { + assertion + .Then + .FailWith("but {context:the subject} is missing attributes {0} and has extra attributes {1}", missing, extra); + } + else + { + assertion + .Then + .FailWith("but {context:the subject} is missing attributes {0}", missing); + } + } + else if (extra.Count > 0) + { + assertion + .Then + .FailWith("but {context:the subject} has extra attributes {0}", extra); + } + + return new AndConstraint((TAssertions)this); + } + + //public AndConstraint HaveParametersEquivalentTo( + // IEnumerable expectation, + // string because = "", + // params object[] becauseArgs) + //{ + // using var scope = new AssertionScope(); + + // scope.FormattingOptions.UseLineBreaks = true; + + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .WithExpectation("Expected {context:the subject} to have parameters equivalent to {0} {reason}", expectation) + // .ForCondition(Subject is not null) + // .FailWith("but {context:the subject} is ."); + + // var expected = expectation.ToList(); + // var actual = Subject!.ParameterList.Parameters; + + // for (var i = 0; i < expected.Count; i++) + // { + // var expectedItem = expected[i]; + + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .WithExpectation("Expected {context:the subject} to have parameter {0}: \"{1}\" {reason}", i+1, expectedItem) + // .ForCondition(Subject.ParameterList.Parameters.Count != 0) + // .FailWith("but {context:the subject} has no parameters defined.", Subject.ParameterList.Parameters.Count) + // .Then + // .ForCondition(Subject.ParameterList.Parameters.Count > i) + // .FailWith("but {context:the subject} only has {0} parameters defined.", Subject.ParameterList.Parameters.Count) + // .Then + // .Given(() => Subject.ParameterList.Parameters[i]) + // .ForCondition(actual => false)// actual.IsEquivalentTo(expectedItem, true)) + // .FailWith("but found \"{0}\".", expectedItem); + // } + + // if (expected.Count < actual.Count) + // { + // Execute.Assertion + // .BecauseOf(because, becauseArgs) + // .WithExpectation("Expected {context:the subject} to have parameters equivalent to {0}{reason}", expectation) + // .FailWith("but {context:the subject} has extra parameters {0}.", actual.Skip(expected.Count)); + // } + + // return new AndConstraint((TAssertions)this); + //} +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeDescriptorFormatter.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeDescriptorFormatter.cs new file mode 100644 index 000000000..871cbd352 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeDescriptorFormatter.cs @@ -0,0 +1,23 @@ +using FluentAssertions.Formatting; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; + +internal class AttributeDescriptorFormatter : IValueFormatter +{ + public bool CanHandle(object value) => value is AttributeDescriptor; + + public void Format(object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild) + { + var descriptor = (AttributeDescriptor)value; + + if (context.UseLineBreaks) + { + formattedGraph.AddFragmentOnNewLine(descriptor.ToString()); + } + else + { + formattedGraph.AddFragment(descriptor.ToString()); + } + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeDescriptorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeDescriptorTests.cs new file mode 100644 index 000000000..28439bd11 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/AttributeDescriptorTests.cs @@ -0,0 +1,193 @@ +using FluentAssertions.Execution; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator; + +public class AttributeDescriptorTests +{ + public static IEnumerable StringRepresentationExamples { get; } = + [ + [ + new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))), + "[Foo.Bar]" + ], + [ + new AttributeDescriptor( + new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), + [ "Fizz" ]), + "[Foo.Bar(\"Fizz\")]" + ], + [ + new AttributeDescriptor( + new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), + [ "Fizz", "Buzz" ]), + "[Foo.Bar(\"Fizz\", \"Buzz\")]" + ], + [ + new AttributeDescriptor( + new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), + [ 1, 2 ]), + "[Foo.Bar(1, 2)]" + ], + [ + new AttributeDescriptor( + new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), + [ ImmutableArray.Create() ]), + "[Foo.Bar(new string[] {})]" + ], + [ + new AttributeDescriptor( + new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), + [ ImmutableArray.Create("potato", "pancakes") ]), + "[Foo.Bar(new string[] {\"potato\", \"pancakes\"})]" + ] + ]; + + [Theory] + [MemberData(nameof(StringRepresentationExamples))] + public void DescriptorsCanBeRepresntedAsStrings(AttributeDescriptor attribute, string expected) + { + attribute.ToString().Should().BeEquivalentTo(expected); + } + + public static IEnumerable DescriptorExamples { get; } = + [ + [ () => new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))) ], + [ () => new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), [ "Fizz" ]) ], + [ () => new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), [ "Fizz", "Buzz" ]) ], + [ () => new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), [ 1, 2 ]) ], + [ () => new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), [ ImmutableArray.Create() ]) ], + [ () => new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar")), [ ImmutableArray.Create("potato") ]) ] + ]; + + [Theory] + [MemberData(nameof(DescriptorExamples))] + public void DescriptorsAreEqualWhenTypeNamespaceAndArgumentsAreEquivalent(Func example) + { + var a = example(); + var b = example(); + + using var assertions = new AssertionScope(); + + a.GetHashCode().Should().Be(b.GetHashCode()); + a.Equals(b).Should().BeTrue(); + } + + [Theory] + [InlineData(true)] + [InlineData((byte)100)] + [InlineData('m')] + [InlineData(100.01d)] + [InlineData(100.01f)] + [InlineData(100)] + [InlineData(1000L)] + [InlineData((sbyte)100)] + [InlineData((short)100.01)] + [InlineData("muffins")] + [InlineData(100u)] + [InlineData(1000ul)] + [InlineData((ushort)100.01)] + public void DescriptorsCanBeCreatedWithSomeBuiltInTypesAsArguments(object? argument) + { + var attribute = new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))) + .WithPositionalArguments(argument) + .WithNamedArguments(new { Property = argument }); + + using var assertions = new AssertionScope(); + + attribute.PositionalArguments[0].Should().Be(argument); + attribute.NamedArguments[new IdentifierString("Property")].Should().Be(argument); + } + + [Theory] + [InlineData(AttributeTargets.Assembly)] + [InlineData(ConsoleKey.Add)] + public void DescriptorsCanBeCreatedWithEnumsAsArguments(object? argument) + { + var attribute = new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))) + .WithPositionalArguments(argument) + .WithNamedArguments(new { Property = argument }); + + using var assertions = new AssertionScope(); + + attribute.PositionalArguments[0].Should().Be(argument); + attribute.NamedArguments[new IdentifierString("Property")].Should().Be(argument); + } + + [Theory] + [InlineData(true)] + [InlineData((byte)100)] + [InlineData('m')] + [InlineData(100.01d)] + [InlineData(100.01f)] + [InlineData(100)] + [InlineData(1000L)] + [InlineData((sbyte)100)] + [InlineData((short)100.01)] + [InlineData("muffins")] + [InlineData(100u)] + [InlineData(1000ul)] + [InlineData((ushort)100.01)] + public void DescriptorsCanBeCreatedWithImmutableArraysOfSomeBuiltInTypesAsArguments(T value) + { + var argument = ImmutableArray.Create(value, value); + + var attribute = new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))) + .WithPositionalArguments(argument) + .WithNamedArguments(new { Property = argument }); + + using var assertions = new AssertionScope(); + + attribute.PositionalArguments[0].Should().Be(argument); + attribute.NamedArguments[new IdentifierString("Property")].Should().Be(argument); + } + + [Theory] + [InlineData(AttributeTargets.Assembly)] + [InlineData(ConsoleKey.Add)] + public void DescriptorsCanBeCreatedWithImmutableArraysOfEnumsAsArguments(T value) + { + var argument = ImmutableArray.Create(value, value); + + var attribute = new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))) + .WithPositionalArguments(argument) + .WithNamedArguments(new { Property = argument }); + + using var assertions = new AssertionScope(); + + attribute.PositionalArguments[0].Should().Be(argument); + attribute.NamedArguments[new IdentifierString("Property")].Should().Be(argument); + } + + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(AttributeDescriptorTests))] + public void DescriptorsCanBeCreatedWithTypesAsArguments(Type argument) + { + var attribute = new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))) + .WithPositionalArguments(argument) + .WithNamedArguments(new { Property = argument }); + + using var assertions = new AssertionScope(); + + attribute.PositionalArguments[0].Should().Be(argument); + attribute.NamedArguments[new IdentifierString("Property")].Should().Be(argument); + } + + [Fact] + public void DescriptorsCannotBeCreatedWithArraysAsArguments() + { + var argument = Array.Empty(); + + var attribute = new AttributeDescriptor(new NamespaceString("Foo") + new SimpleTypeIdentifier(new IdentifierString("Bar"))); + + attribute + .Invoking(attribute => attribute.WithPositionalArguments([ argument ])) + .Should().Throw(); + + attribute + .Invoking(attribute => attribute.WithNamedArguments(new { Property = argument })) + .Should().Throw(); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharp/CSharpTestFixtureGeneratorTestBase.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharp/CSharpTestFixtureGeneratorTestBase.cs new file mode 100644 index 000000000..05f1d9c47 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharp/CSharpTestFixtureGeneratorTestBase.cs @@ -0,0 +1,26 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; +public abstract class CSharpTestFixtureGeneratorTestBase where THandler : ITestFrameworkHandler +{ + public CSharpTestFixtureGeneratorTestBase(THandler handler) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(AssemblyIdentity.FromAssemblyDefinition); + + Compilation = new CSharpCompilationInformation( + "Test.dll", + references.ToImmutableArray(), + Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp11, + true); + + Generator = handler.GetTestFixtureGenerator(Compilation) ?? + throw new InvalidOperationException($"Handler for {handler.TestFrameworkName} does not support C# generation."); + } + + protected CSharpCompilationInformation Compilation { get; } + + protected ITestFixtureGenerator Generator { get; } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharp/CSharpTestFixtureSourceGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharp/CSharpTestFixtureSourceGeneratorTests.cs new file mode 100644 index 000000000..f24c742c6 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharp/CSharpTestFixtureSourceGeneratorTests.cs @@ -0,0 +1,198 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Moq; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator.CSharp; +public class CSharpTestFixtureSourceGeneratorTests +{ + private readonly Mock _mockHandler = new(); + + private readonly Mock> _mockGenerator = new(); + + public CSharpTestFixtureSourceGeneratorTests() + { + _mockHandler + .Setup(handler => handler.IsTestFrameworkReferenced(It.IsAny())) + .Returns(true); + + _mockHandler.SetupGet(handler => handler.TestFrameworkName).Returns("Mock"); + + _mockHandler + .Setup(handler => handler.GetTestFixtureGenerator(It.IsAny())) + .Returns(_mockGenerator.Object); + + + _mockGenerator.SetupGet(generator => generator.TestFrameworkHandler).Returns(_mockHandler.Object); + + //_mockGenerator + // .Setup(generator => generator.GenerateTestMethod( + // It.IsAny>(), + // It.IsAny())) + // .Returns(new CSharpTestMethod()) + + _mockGenerator + .Setup(generator => generator.GenerateTestFixtureClass( + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns(new CSharpTestFixtureClass( + new NamespaceString("Test") + new SimpleTypeIdentifier(new IdentifierString("Mock")), + "Mock.feature", + new FeatureInformation("Mock", null, "en"))); + } + + [Theory] + [InlineData("reqnroll.emit_ignored_examples")] + [InlineData("build_property.ReqnrollEmitIgnoredExamples")] + public void GeneratorEmitsIgnoredScenariosWhenOptionEnabled(string setting) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([_mockHandler.Object]); + + const string featureText = + """ + Feature: Sample Feature + + Scenario Outline: SO + When the step + Examples: + | result | + | passes | + | fails | + | is pending | + | is undefined | + @ignore + Examples: + | result | + | ignored | + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { setting, "true" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generate = _mockGenerator.Invocations + .Single(inv => inv.Method.Name == nameof(ITestFixtureGenerator.GenerateTestMethod)); + + var context = (TestMethodGenerationContext)generate.Arguments[0]; + + context.ScenarioInformation.Examples.Should().HaveCount(2); + } + + [Theory] + [InlineData("reqnroll.emit_ignored_examples")] + [InlineData("build_property.ReqnrollEmitIgnoredExamples")] + public void GeneratorOmitsIgnoredScenariosWhenOptionDisabled(string setting) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([_mockHandler.Object]); + + const string featureText = + """ + Feature: Sample Feature + + Scenario Outline: SO + When the step + Examples: + | result | + | passes | + | fails | + | is pending | + | is undefined | + @ignore + Examples: + | result | + | ignored | + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { setting, "false" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generate = _mockGenerator.Invocations + .Single(inv => inv.Method.Name == nameof(ITestFixtureGenerator.GenerateTestMethod)); + + var context = (TestMethodGenerationContext)generate.Arguments[0]; + + context.ScenarioInformation.Examples.Should().HaveCount(1); + } + + [Fact] + public void GeneratorOmitsIgnoredScenariosByDefault() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([_mockHandler.Object]); + + const string featureText = + """ + Feature: Sample Feature + + Scenario Outline: SO + When the step + Examples: + | result | + | passes | + | fails | + | is pending | + | is undefined | + @ignore + Examples: + | result | + | ignored | + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions([])); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generate = _mockGenerator.Invocations + .Single(inv => inv.Method.Name == nameof(ITestFixtureGenerator.GenerateTestMethod)); + + var context = (TestMethodGenerationContext)generate.Arguments[0]; + + context.ScenarioInformation.Examples.Should().HaveCount(1); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpClassDeclarationAssertions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpClassDeclarationAssertions.cs new file mode 100644 index 000000000..af9f13013 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpClassDeclarationAssertions.cs @@ -0,0 +1,123 @@ +using FluentAssertions.Execution; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Reqnroll.FeatureSourceGenerator; +public class CSharpClassDeclarationAssertions(ClassDeclarationSyntax? subject) : + CSharpClassDeclarationAssertions(subject) +{ +} + +public class CSharpClassDeclarationAssertions(ClassDeclarationSyntax? subject) : + CSharpSyntaxAssertions(subject) + where TAssertions : CSharpClassDeclarationAssertions +{ + protected override string Identifier => "class"; + + /// + /// Expects the class declaration have only a single attribute with a specific identifier. + /// + /// + /// The declared type of the attribute. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndWhichConstraint HaveSingleAttribute( + string type, + string because = "", + params object[] becauseArgs) + { + var expectation = "Expected {context:class} to have a single attribute " + + $"which is of type \"{type}\" {{reason}}"; + + bool notNull = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject is not null) + .FailWith(expectation + ", but found ."); + + AttributeSyntax? match = default; + + if (notNull) + { + var attributes = Subject!.AttributeLists.SelectMany(list => list.Attributes).ToList(); + + switch (attributes.Count) + { + case 0: // Fail, Collection is empty + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but the class has no attributes."); + + break; + case 1: // Success Condition + var single = attributes.Single(); + + if (single.Name.ToString() != type) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found the attribute \"{0}\".", single.Name); + } + else + { + match = single; + } + + break; + default: // Fail, Collection contains more than a single item + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", attributes); + + break; + } + } + + return new AndWhichConstraint((TAssertions)this, match!); + } + + public AndWhichConstraint ContainMethod( + string identifier, + string because = "", + params object[] becauseArgs) + { + var expectation = "Expected {context:class} to have a method " + + $"named \"{identifier}\" {{reason}}"; + + bool notNull = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject is not null) + .FailWith(expectation + ", but found ."); + + MethodDeclarationSyntax? match = default; + + if (notNull) + { + var methods = Subject!.Members.OfType().ToList(); + + if (methods.Count == 0) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but the class has no methods."); + } + else + { + match = methods.FirstOrDefault(method => method.Identifier.Text == identifier); + + if (match == null) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", methods); + } + } + } + + return new AndWhichConstraint((TAssertions)this, match!); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpMethodDeclarationAssertions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpMethodDeclarationAssertions.cs new file mode 100644 index 000000000..2e0d7d790 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpMethodDeclarationAssertions.cs @@ -0,0 +1,362 @@ +using FluentAssertions.Equivalency; +using FluentAssertions.Execution; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Drawing; +using System.Linq.Expressions; +using System.Reflection; +using System.Xml.Linq; +using static System.Formats.Asn1.AsnWriter; + +namespace Reqnroll.FeatureSourceGenerator; +public class CSharpMethodDeclarationAssertions(MethodDeclarationSyntax? subject) : + CSharpMethodDeclarationAssertions(subject) +{ +} + +public class CSharpMethodDeclarationAssertions(MethodDeclarationSyntax? subject) : + CSharpSyntaxAssertions(subject) + where TAssertions : CSharpMethodDeclarationAssertions +{ + protected override string Identifier => "method"; + + /// + /// Expects the class declaration have only a single attribute with a specific identifier. + /// + /// + /// The declared type of the attribute. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndWhichConstraint HaveSingleAttribute( + string type, + string because = "", + params object[] becauseArgs) + { + var expectation = "Expected {context:method} to have a single attribute " + + $"which is of type \"{type}\"{{reason}}"; + + bool notNull = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject is not null) + .FailWith(expectation + ", but found ."); + + AttributeSyntax? match = default; + + if (notNull) + { + var attributes = Subject!.AttributeLists.SelectMany(list => list.Attributes).ToList(); + + switch (attributes.Count) + { + case 0: // Fail, Collection is empty + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but the class has no attributes."); + + break; + case 1: // Success Condition + var single = attributes.Single(); + + if (single.Name.ToString() != type) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found the attribute \"{0}\".", single.Name); + } + else + { + match = single; + } + + break; + default: // Fail, Collection contains more than a single item + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", attributes); + + break; + } + } + + return new AndWhichConstraint((TAssertions)this, match!); + } + + public AndWhichConstraint HaveAttribute( + string type, + string because = "", + params object[] becauseArgs) + { + var expectation = "Expected {context:method} to have an attribute " + + $"of type \"{type}\"{{reason}}"; + + bool notNull = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject is not null) + .FailWith(expectation + ", but found ."); + + AttributeSyntax? match = default; + + if (notNull) + { + var attributes = Subject!.AttributeLists.SelectMany(list => list.Attributes).ToList(); + + if (attributes.Count == 0) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but the method has no attributes."); + } + else + { + match = attributes.FirstOrDefault(attribute => attribute.Name.ToString() == type); + + if (match == null) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", attributes); + } + } + } + + return new AndWhichConstraint((TAssertions)this, match!); + } + + public AndConstraint HaveAttribuesEquivalentTo( + AttributeDescriptor[] expectation, + string because = "", + params object[] becauseArgs) => + HaveAttribuesEquivalentTo((IEnumerable)expectation, because, becauseArgs); + + public AndConstraint HaveAttribuesEquivalentTo( + IEnumerable expectation, + string because = "", + params object[] becauseArgs) + { + using var scope = new AssertionScope(); + + scope.FormattingOptions.UseLineBreaks = true; + + var assertion = Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:the method} to have attributes equivalent to {0}", expectation) + .ForCondition(Subject is not null) + .FailWith("but {context:the method} is ."); + + var expected = expectation.ToList(); + var actual = Subject!.AttributeLists + .SelectMany(list => list.Attributes) + .Select(SyntaxInterpreter.GetAttributeDescriptor) + .ToList(); + + + var missing = expected.ToList(); + var extra = actual.ToList(); + + foreach (var item in expected) + { + if (extra.Remove(item)) + { + missing.Remove(item); + } + } + + if (missing.Count > 0) + { + if (extra.Count > 0) + { + assertion + .Then + .FailWith("but the method is missing attributes {0} and has extra attributes {1}", missing, extra); + } + else + { + assertion + .Then + .FailWith("but the method is missing attributes {0}", missing); + } + } + else if (extra.Count > 0) + { + assertion + .Then + .FailWith("but the method has extra attributes {0}", extra); + } + + return new AndConstraint((TAssertions)this); + } + + public AndConstraint HaveParametersEquivalentTo( + IEnumerable expectation, + string because = "", + params object[] becauseArgs) + { + using var scope = new AssertionScope(); + + scope.FormattingOptions.UseLineBreaks = true; + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:the method} to have parameters equivalent to {0} {reason}", expectation) + .ForCondition(Subject is not null) + .FailWith("but {context:the method} is ."); + + var expected = expectation.ToList(); + var actual = Subject!.ParameterList.Parameters; + + for (var i = 0; i < expected.Count; i++) + { + var expectedItem = expected[i]; + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:the method} to have parameter {0}: \"{1}\" {reason}", i+1, expectedItem) + .ForCondition(Subject.ParameterList.Parameters.Count != 0) + .FailWith("but {context:the method} has no parameters defined.", Subject.ParameterList.Parameters.Count) + .Then + .ForCondition(Subject.ParameterList.Parameters.Count > i) + .FailWith("but {context:the method} only has {0} parameters defined.", Subject.ParameterList.Parameters.Count) + .Then + .Given(() => Subject.ParameterList.Parameters[i]) + .ForCondition(actual => false)// actual.IsEquivalentTo(expectedItem, true)) + .FailWith("but found \"{0}\".", expectedItem); + } + + if (expected.Count < actual.Count) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected {context:the method} to have parameters equivalent to {0}{reason}", expectation) + .FailWith("but {context:the method} has extra parameters {0}.", actual.Skip(expected.Count)); + } + + return new AndConstraint((TAssertions)this); + } +} + +internal static class SyntaxInterpreter +{ + public static AttributeDescriptor GetAttributeDescriptor(AttributeSyntax attribute) + { + var type = attribute.Name switch + { + QualifiedNameSyntax qname => new QualifiedTypeIdentifier( + new NamespaceString( + qname.Left.ToString().StartsWith("global::") ? + qname.Left.ToString()[8..] : + qname.Left.ToString()), + new SimpleTypeIdentifier(new IdentifierString(qname.Right.ToString()))), + _ => throw new NotImplementedException() + }; + + ImmutableArray positionalArguments; + ImmutableDictionary namedArguments; + + if (attribute.ArgumentList == null) + { + positionalArguments = []; + namedArguments = ImmutableDictionary.Empty; + } + else + { + positionalArguments = attribute.ArgumentList.Arguments + .Where(arg => arg.NameEquals == null) + .Select(arg => GetLiteralValue(arg.Expression)) + .ToImmutableArray(); + + namedArguments = attribute.ArgumentList.Arguments + .Where(arg => arg.NameEquals != null) + .ToImmutableDictionary( + arg => new IdentifierString(arg.NameEquals!.Name.ToString()), + arg => GetLiteralValue(arg.Expression)); + } + + return new AttributeDescriptor( + type, + positionalArguments, + namedArguments); + } + + public static object? GetLiteralValue(ExpressionSyntax expression) => expression switch + { + LiteralExpressionSyntax literal => literal.Token.Value, + ArrayCreationExpressionSyntax arrayCreation => GetLiteralValue(arrayCreation), + _ => throw new NotSupportedException( + $"Obtaining literal value of expressions of type \"{expression.GetType().Name}\" is not supported.") + }; + + private static object GetLiteralValue(ArrayCreationExpressionSyntax expression) + { + if (expression.Type.RankSpecifiers.Count > 1) + { + throw new NotSupportedException( + "Getting literal values of mutli-dimensional arrays is not supported."); + } + + var rank = expression.Type.RankSpecifiers[0]; + + if (rank.Sizes.Count > 1) + { + throw new NotSupportedException( + "Getting literal values of mutli-dimensional arrays is not supported."); + } + + Type itemType = expression.Type.ElementType switch + { + PredefinedTypeSyntax predefined => predefined switch + { + { Keyword.Text: "string" } => typeof(string), + { Keyword.Text: "object" } => typeof(object), + _ => throw new NotSupportedException($"Getting array literals of predefined type {predefined} is not supported.") + }, + _ => throw new NotSupportedException($"Getting array literals of type {expression.Type.ElementType} is not supported.") + }; + + var size = rank.Sizes[0]; + + int? arrayLength = size switch + { + OmittedArraySizeExpressionSyntax => null, + LiteralExpressionSyntax literal => literal.Kind() == SyntaxKind.NumericLiteralExpression ? + (int)GetLiteralValue(literal)! : + throw new InvalidOperationException(), + _ => throw new NotSupportedException() + }; + + return expression.Initializer is null ? + CreateImmutableArray(itemType, arrayLength!.Value) : + CreateImmutableArray(itemType, expression.Initializer.Expressions.Select(GetLiteralValue)); + } + + private static object CreateImmutableArray(Type itemType, IEnumerable values) + { + return typeof(SyntaxInterpreter) + .GetMethod(nameof(CreateImmutableArrayFromValues), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(itemType) + .Invoke(null, [values])!; + } + + private static ImmutableArray CreateImmutableArrayFromValues(IEnumerable values) => + values.Cast().ToImmutableArray(); + + private static object CreateImmutableArray(Type itemType, int size) + { + return typeof(SyntaxInterpreter) + .GetMethod(nameof(CreateImmutableArrayOfSize), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(itemType) + .Invoke(null, [size])!; + } + + private static ImmutableArray CreateImmutableArrayOfSize(int size) => + new T[size].ToImmutableArray(); +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpNamespaceDeclarationAssertions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpNamespaceDeclarationAssertions.cs new file mode 100644 index 000000000..3eb0686f7 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpNamespaceDeclarationAssertions.cs @@ -0,0 +1,90 @@ +using FluentAssertions.Execution; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Reqnroll.FeatureSourceGenerator; + +public class CSharpNamespaceDeclarationAssertions(NamespaceDeclarationSyntax? subject) : + CSharpNamespaceDeclarationAssertions(subject) +{ +} + +public class CSharpNamespaceDeclarationAssertions(NamespaceDeclarationSyntax? subject) : + CSharpSyntaxAssertions(subject) + where TAssertions : CSharpNamespaceDeclarationAssertions +{ + protected override string Identifier => "namespace"; + + /// + /// Expects the namespace contain only a single child which is a class declaration with a specific identifier. + /// + /// + /// The identifier of the class. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndWhichConstraint ContainSingleClassDeclaration( + string identifier, + string because = "", + params object[] becauseArgs) + { + var expectation = "Expected {context:namespace} to contain a single child node " + + $"which is the declaration of the class \"{identifier}\" {{reason}}"; + + bool notNull = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject is not null) + .FailWith(expectation + ", but found ."); + + ClassDeclarationSyntax? match = default; + + if (notNull) + { + var members = Subject!.Members; + + switch (members.Count) + { + case 0: // Fail, Collection is empty + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but the node has no children."); + + break; + case 1: // Success Condition + var single = members.Single(); + + if (single is not ClassDeclarationSyntax declaration) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", Subject); + } + else if (declaration.Identifier.ToString() != identifier) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found the class \"{0}\".", declaration.Identifier.ToString()); + } + else + { + match = declaration; + } + + break; + default: // Fail, Collection contains more than a single item + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", members); + + break; + } + } + + return new AndWhichConstraint((TAssertions)this, match!); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpSyntaxAssertions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpSyntaxAssertions.cs new file mode 100644 index 000000000..9966a682b --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/CSharpSyntaxAssertions.cs @@ -0,0 +1,91 @@ +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Reqnroll.FeatureSourceGenerator; + +public class CSharpSyntaxAssertions(TNode? subject) : CSharpSyntaxAssertions>(subject) + where TNode : CSharpSyntaxNode +{ +} + +public class CSharpSyntaxAssertions(TNode? subject) : ReferenceTypeAssertions(subject!) + where TNode: CSharpSyntaxNode + where TAssertions : CSharpSyntaxAssertions +{ + protected override string Identifier => "node"; + + /// + /// Expects the node contain only a single child which is a namespace declaration with a specific identifier. + /// + /// + /// The identifier of the namespace. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndWhichConstraint ContainSingleNamespaceDeclaration( + string identifier, + string because = "", + params object[] becauseArgs) + { + var expectation = "Expected {context:node} to contain a single child node " + + $"which is the declaration of the namespace \"{identifier}\" {{reason}}"; + + bool notNull = Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject is not null) + .FailWith(expectation + ", but found ."); + + NamespaceDeclarationSyntax? match = default; + + if (notNull) + { + var actualChildNodes = Subject!.ChildNodes().Cast().ToList(); + + switch (actualChildNodes.Count) + { + case 0: // Fail, Collection is empty + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but the node has no children."); + + break; + case 1: // Success Condition + var single = actualChildNodes.Single(); + + if (single is not NamespaceDeclarationSyntax ns) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", Subject); + } + else if (ns.Name.ToString() != identifier) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found the namespace \"{0}\".", ns.Name.ToString()); + } + else + { + match = ns; + } + + break; + default: // Fail, Collection contains more than a single item + Execute.Assertion + .BecauseOf(because, becauseArgs) + .FailWith(expectation + ", but found {0}.", actualChildNodes); + + break; + } + } + + return new AndWhichConstraint((TAssertions)this, match!); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/FakeAnalyzerConfigOptionsProvider.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/FakeAnalyzerConfigOptionsProvider.cs new file mode 100644 index 000000000..a873ccff8 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/FakeAnalyzerConfigOptionsProvider.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Reqnroll.FeatureSourceGenerator; +internal class FakeAnalyzerConfigOptionsProvider(AnalyzerConfigOptions globalOptions) : AnalyzerConfigOptionsProvider +{ + public override AnalyzerConfigOptions GlobalOptions { get; } = globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions; +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/FeatureFile.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/FeatureFile.cs new file mode 100644 index 000000000..a309f21c0 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/FeatureFile.cs @@ -0,0 +1,16 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Reqnroll.FeatureSourceGenerator; + +internal class FeatureFile(string path, string content) : AdditionalText +{ + private readonly SourceText _sourceText = SourceText.From(content); + + public override string Path { get; } = path; + + public override SourceText? GetText(CancellationToken cancellationToken = default) + { + return _sourceText; + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/InMemoryAnalyzerConfigOptions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/InMemoryAnalyzerConfigOptions.cs new file mode 100644 index 000000000..dd2890062 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/InMemoryAnalyzerConfigOptions.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +namespace Reqnroll.FeatureSourceGenerator; + +public class InMemoryAnalyzerConfigOptions(IEnumerable> values) : AnalyzerConfigOptions +{ + private readonly ImmutableDictionary _values = values.ToImmutableDictionary(KeyComparer); + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) => _values.TryGetValue(key, out value); +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/Initializer.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/Initializer.cs new file mode 100644 index 000000000..f56fab7ee --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/Initializer.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace Reqnroll.FeatureSourceGenerator; +internal static class Initializer +{ + [ModuleInitializer] + public static void SetDefaults() + { + FluentAssertions.Formatting.Formatter.AddFormatter(new AttributeDescriptorFormatter()); + FluentAssertions.Formatting.Formatter.AddFormatter(new ParameterSyntaxFormatter()); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/MSTest/MSTestCSharpTestFixtureGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/MSTest/MSTestCSharpTestFixtureGeneratorTests.cs new file mode 100644 index 000000000..9c35b4834 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/MSTest/MSTestCSharpTestFixtureGeneratorTests.cs @@ -0,0 +1,190 @@ +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis; +using Reqnroll.FeatureSourceGenerator.CSharp; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.MSTest; +public class MSTestCSharpTestFixtureGeneratorTests() : CSharpTestFixtureGeneratorTestBase(new MSTestHandler()) +{ + private static readonly NamespaceString MSTestNamespace = new("Microsoft.VisualStudio.TestTools.UnitTesting"); + + [Fact] + public void GenerateTestFixture_CreatesClassForFeatureWithMsTestAttributes() + { + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + "foo happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ]); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [ scenarioInfo ], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testFixture = Generator.GenerateTestFixtureClass(testFixtureGenerationContext, []); + + testFixture.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor(MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestClass"))), + ]); + } + + [Fact] + public void GenerateTestMethod_CreatesParameterlessMethodForScenarioWithoutExamples() + { + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + "foo happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ]); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [ scenarioInfo ], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testMethodGenerationContext = new TestMethodGenerationContext( + scenarioInfo, + testFixtureGenerationContext); + + var method = Generator.GenerateTestMethod(testMethodGenerationContext); + + method.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor(MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestMethod"))), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ["Sample Scenario"]), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestProperty")), + positionalArguments: ["FeatureTitle", "Sample"]) + ]); + + method.Should().HaveNoParameters(); + + method.StepInvocations.Should().BeEquivalentTo( + [ + new StepInvocation( + StepType.Action, + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20)), + "When", + "foo happens") + ]); + } + + [Fact] + public void GenerateTestMethod_CreatesMethodWithMSTestDataRowsAttributesWhenScenarioHasExamples() + { + var exampleSet1 = new ScenarioExampleSet(["what"], [["foo"], ["bar"]], ["example_tag"]); + var exampleSet2 = new ScenarioExampleSet(["what"], [["baz"]], []); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario Outline", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + " happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ], + [ exampleSet1, exampleSet2 ]); + + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [scenarioInfo], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testMethodGenerationContext = new TestMethodGenerationContext( + scenarioInfo, + testFixtureGenerationContext); + + var method = Generator.GenerateTestMethod(testMethodGenerationContext); + + method.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor(MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestMethod"))), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ["Sample Scenario Outline"]), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("TestProperty")), + positionalArguments: ["FeatureTitle", "Sample"]), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("DataRow")), + ["foo", ImmutableArray.Create(ImmutableArray.Create("example_tag"))]), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("DataRow")), + ["bar", ImmutableArray.Create(ImmutableArray.Create("example_tag"))]), + new AttributeDescriptor( + MSTestNamespace + new SimpleTypeIdentifier(new IdentifierString("DataRow")), + ["baz", ImmutableArray.Create(ImmutableArray.Empty)]) + ]); + + method.Should().HaveParametersEquivalentTo( + [ + new ParameterDescriptor( + new IdentifierString("what"), + new NamespaceString("System") + new SimpleTypeIdentifier(new IdentifierString("String"))), + + new ParameterDescriptor( + new IdentifierString("_exampleTags"), + new ArrayTypeIdentifier(new NamespaceString("System") + new SimpleTypeIdentifier(new IdentifierString("String")))) + ]); + + method.StepInvocations.Should().BeEquivalentTo( + [ + new StepInvocation( + StepType.Action, + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20)), + "When", + "{0} happens", + [new IdentifierString("what")]) + ]); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/MSTest/MSTestFeatureSourceGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/MSTest/MSTestFeatureSourceGeneratorTests.cs new file mode 100644 index 000000000..8f148da11 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/MSTest/MSTestFeatureSourceGeneratorTests.cs @@ -0,0 +1,110 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Reqnroll.FeatureSourceGenerator; +using Reqnroll.FeatureSourceGenerator.CSharp; +using Xunit.Abstractions; + +namespace Reqnroll.FeatureSourceGenerator.MSTest; + +public class MSTestFeatureSourceGeneratorTests(ITestOutputHelper output) +{ + [Fact] + public void GeneratorProducesMSTestOutputWhenWhenBuildPropertyConfiguredForMSTest() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([BuiltInTestFrameworkHandlers.MSTest]); + + const string featureText = + """ + #language: en + @featureTag1 + Feature: Calculator + + @mytag + Scenario: Add two "simple" numbers + Given the first number is 50 + And the second number is 70 + When the two numbers are added + Then the result should be 120 + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { "build_property.ReqnrollTargetTestFramework", "MSTest" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generatedSyntaxTree = generatedCompilation.SyntaxTrees.Should().ContainSingle() + .Which.Should().BeAssignableTo().Subject!; + + generatedSyntaxTree.GetDiagnostics().Should().BeEmpty(); + + generatedSyntaxTree.GetRoot().Should().ContainSingleNamespaceDeclaration("test") + .Which.Should().ContainSingleClassDeclaration("CalculatorFeature") + .Which.Should().HaveSingleAttribute("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClass"); + } + + [Fact] + public void GeneratorProducesMSTestOutputWhenWhenEditorConfigConfiguredForMSTest() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([BuiltInTestFrameworkHandlers.MSTest]); + + const string featureText = + """ + #language: en + @featureTag1 + Feature: Calculator + + @mytag + Scenario: Add two numbers + Given the first number is 50 + And the second number is 70 + When the two numbers are added + Then the result should be 120 + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { "reqnroll.target_test_framework", "MSTest" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generatedSyntaxTree = generatedCompilation.SyntaxTrees.Should().ContainSingle() + .Which.Should().BeAssignableTo().Subject!; + + output.WriteLine($"Generated source:\n{generatedSyntaxTree}"); + + generatedSyntaxTree.GetDiagnostics().Should().BeEmpty(); + + generatedSyntaxTree.GetRoot().Should().ContainSingleNamespaceDeclaration("test") + .Which.Should().ContainSingleClassDeclaration("CalculatorFeature") + .Which.Should().HaveSingleAttribute("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClass"); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/NUnit/NUnitCSharpTestFixtureGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/NUnit/NUnitCSharpTestFixtureGeneratorTests.cs new file mode 100644 index 000000000..4a1431ffc --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/NUnit/NUnitCSharpTestFixtureGeneratorTests.cs @@ -0,0 +1,187 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Reqnroll.FeatureSourceGenerator.CSharp; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.NUnit; +public class NUnitCSharpTestFixtureGeneratorTests() : CSharpTestFixtureGeneratorTestBase(new NUnitHandler()) +{ + private static readonly NamespaceString NUnitNamespace = new("NUnit.Framework"); + + [Fact] + public void GenerateTestFixture_CreatesClassForFeatureWithNUnitAttributes() + { + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + "foo happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ]); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [ scenarioInfo ], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testFixture = Generator.GenerateTestFixtureClass(testFixtureGenerationContext, []); + + testFixture.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ["Sample"]), + ]); + } + + [Fact] + public void GenerateTestMethod_CreatesParameterlessMethodForScenarioWithoutExamples() + { + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + "foo happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ]); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [ scenarioInfo ], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testMethodGenerationContext = new TestMethodGenerationContext( + scenarioInfo, + testFixtureGenerationContext); + + var method = Generator.GenerateTestMethod(testMethodGenerationContext); + + method.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor(NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Test"))), + new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ["Sample Scenario"]) + ]); + + method.Should().HaveNoParameters(); + + method.StepInvocations.Should().BeEquivalentTo( + [ + new StepInvocation( + StepType.Action, + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20)), + "When", + "foo happens") + ]); + } + + [Fact] + public void GenerateTestMethod_CreatesMethodWithTestCaseAttributesWhenScenarioHasExamples() + { + var exampleSet1 = new ScenarioExampleSet(["what"], [["foo"], ["bar"]], ["example_tag"]); + var exampleSet2 = new ScenarioExampleSet(["what"], [["baz"]], []); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario Outline", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + " happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ], + [exampleSet1, exampleSet2]); + + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [scenarioInfo], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testMethodGenerationContext = new TestMethodGenerationContext( + scenarioInfo, + testFixtureGenerationContext); + + var method = Generator.GenerateTestMethod(testMethodGenerationContext); + + method.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Description")), + ["Sample Scenario Outline"]), + new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("TestCase")), + ["foo", ImmutableArray.Create("example_tag")], + new Dictionary{{ new IdentifierString("Category"), "example_tag" } }.ToImmutableDictionary()), + new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("TestCase")), + ["bar", ImmutableArray.Create("example_tag")], + new Dictionary{{ new IdentifierString("Category"), "example_tag" } }.ToImmutableDictionary()), + new AttributeDescriptor( + NUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("TestCase")), + ["baz", ImmutableArray.Empty]) + ]); + + method.Should().HaveParametersEquivalentTo( + [ + new ParameterDescriptor( + new IdentifierString("what"), + new NamespaceString("System") + new SimpleTypeIdentifier(new IdentifierString("String"))), + + new ParameterDescriptor( + new IdentifierString("_exampleTags"), + new ArrayTypeIdentifier(new NamespaceString("System") + new SimpleTypeIdentifier(new IdentifierString("String")))) + ]); + + method.StepInvocations.Should().BeEquivalentTo( + [ + new StepInvocation( + StepType.Action, + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20)), + "When", + "{0} happens", + [new IdentifierString("what")]) + ]); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/NUnit/NUnitFeatureSourceGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/NUnit/NUnitFeatureSourceGeneratorTests.cs new file mode 100644 index 000000000..14a9c4d30 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/NUnit/NUnitFeatureSourceGeneratorTests.cs @@ -0,0 +1,112 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Reqnroll.FeatureSourceGenerator; +using Reqnroll.FeatureSourceGenerator.CSharp; +using Xunit.Abstractions; + +namespace Reqnroll.FeatureSourceGenerator.NUnit; + +public class NUnitFeatureSourceGeneratorTests(ITestOutputHelper output) +{ + [Fact] + public void GeneratorProducesNUnitOutputWhenWhenBuildPropertyConfiguredForNUnit() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([BuiltInTestFrameworkHandlers.NUnit]); + + const string featureText = + """ + #language: en + @featureTag1 + Feature: Calculator + + @mytag + Scenario: Add two "simple" numbers + Given the first number is 50 + And the second number is 70 + When the two numbers are added + Then the result should be 120 + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { "build_property.ReqnrollTargetTestFramework", "NUnit" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + var generatedSyntaxTree = generatedCompilation.SyntaxTrees.Should().ContainSingle() + .Which.Should().BeAssignableTo().Subject!; + + output.WriteLine($"Generated source:\n{generatedSyntaxTree}"); + + diagnostics.Should().BeEmpty(); + + generatedSyntaxTree.GetDiagnostics().Should().BeEmpty(); + + generatedSyntaxTree.GetRoot().Should().ContainSingleNamespaceDeclaration("test") + .Which.Should().ContainSingleClassDeclaration("CalculatorFeature") + .Which.Should().HaveSingleAttribute("global::NUnit.Framework.Description"); + } + + [Fact] + public void GeneratorProducesNUnitOutputWhenWhenEditorConfigConfiguredForNUnit() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([BuiltInTestFrameworkHandlers.NUnit]); + + const string featureText = + """ + #language: en + @featureTag1 + Feature: Calculator + + @mytag + Scenario: Add two numbers + Given the first number is 50 + And the second number is 70 + When the two numbers are added + Then the result should be 120 + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { "reqnroll.target_test_framework", "NUnit" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + var generatedSyntaxTree = generatedCompilation.SyntaxTrees.Should().ContainSingle() + .Which.Should().BeAssignableTo().Subject!; + + output.WriteLine($"Generated source:\n{generatedSyntaxTree}"); + + diagnostics.Should().BeEmpty(); + + generatedSyntaxTree.GetDiagnostics().Should().BeEmpty(); + + generatedSyntaxTree.GetRoot().Should().ContainSingleNamespaceDeclaration("test") + .Which.Should().ContainSingleClassDeclaration("CalculatorFeature") + .Which.Should().HaveSingleAttribute("global::NUnit.Framework.Description"); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/ParameterSyntaxFormatter.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/ParameterSyntaxFormatter.cs new file mode 100644 index 000000000..ab07f6c01 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/ParameterSyntaxFormatter.cs @@ -0,0 +1,31 @@ +using FluentAssertions.Formatting; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Reqnroll.FeatureSourceGenerator; +internal class ParameterSyntaxFormatter : IValueFormatter +{ + public bool CanHandle(object value) => value is ParameterSyntax; + + public void Format(object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild) + { + var syntax = (ParameterSyntax)value; + + var typeString = syntax.Type switch + { + ArrayTypeSyntax array => $"{array.ElementType}[]", + not null => syntax.Type.ToString(), + null => "" + }; + + var s = $"{typeString} {syntax.Identifier}"; + + if (context.UseLineBreaks) + { + formattedGraph.AddFragmentOnNewLine(s); + } + else + { + formattedGraph.AddFragment(s); + } + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/Reqnroll.FeatureSourceGeneratorTests.csproj b/Tests/Reqnroll.FeatureSourceGeneratorTests/Reqnroll.FeatureSourceGeneratorTests.csproj new file mode 100644 index 000000000..5ae2185d5 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/Reqnroll.FeatureSourceGeneratorTests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + + false + true + Reqnroll.FeatureSourceGenerator + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/ArrayTypeIdentifierTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/ArrayTypeIdentifierTests.cs new file mode 100644 index 000000000..2dd77a68c --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/ArrayTypeIdentifierTests.cs @@ -0,0 +1,99 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class ArrayTypeIdentifierTests +{ + [Theory] + [InlineData("Parser", true, false)] + [InlineData("__Parser", false, false)] + [InlineData("X509", true, true)] + public void Constructor_CreatesArrayTypeFromType(string localName, bool itemIsNullable, bool arrayIsNullable) + { + var itemType = new SimpleTypeIdentifier(new IdentifierString(localName), itemIsNullable); + + var identifier = new ArrayTypeIdentifier(itemType, arrayIsNullable); + + identifier.ItemType.Should().Be(itemType); + identifier.IsNullable.Should().Be(arrayIsNullable); + } + + [Theory] + [InlineData("Parser", true, "Parser", true, true)] + [InlineData("_Parser", false, "_Parser", false, true)] + [InlineData("Parser", false, "Parser", true, false)] + [InlineData("Parser", true, "_Parser", false, false)] + public void Equals_ReturnsTrueWhenItemTypeAndNullabilityMatches( + string nameA, + bool arrayAIsNullable, + string nameB, + bool arrayBIsNullable, + bool expected) + { + var typeIdA = new SimpleTypeIdentifier(new IdentifierString(nameA)); + var typeIdB = new SimpleTypeIdentifier(new IdentifierString(nameB)); + + var arrayTypeA = new ArrayTypeIdentifier(typeIdA, arrayAIsNullable); + var arrayTypeB = new ArrayTypeIdentifier(typeIdB, arrayBIsNullable); + + arrayTypeA.Equals(arrayTypeB).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", true, "Parser", true)] + [InlineData("_Parser", false, "_Parser", false)] + public void GetHashCode_ReturnsSameValueForEquivalentValues( + string nameA, + bool arrayAIsNullable, + string nameB, + bool arrayBIsNullable) + { + var typeIdA = new SimpleTypeIdentifier(new IdentifierString(nameA)); + var typeIdB = new SimpleTypeIdentifier(new IdentifierString(nameB)); + + var arrayTypeA = new ArrayTypeIdentifier(typeIdA, arrayAIsNullable); + var arrayTypeB = new ArrayTypeIdentifier(typeIdB, arrayBIsNullable); + + arrayTypeA.GetHashCode().Should().Be(arrayTypeB.GetHashCode()); + } + + [Theory] + [InlineData("Parser", true, "Parser", true, true)] + [InlineData("_Parser", false, "_Parser", false, true)] + [InlineData("Parser", false, "Parser", true, false)] + [InlineData("Parser", true, "_Parser", false, false)] + public void EqualityOperatorWithArrayTypeIdentifier_ReturnsEquivalenceBasedOnItemTypeAndNullability( + string nameA, + bool arrayAIsNullable, + string nameB, + bool arrayBIsNullable, + bool expected) + { + var typeIdA = new SimpleTypeIdentifier(new IdentifierString(nameA)); + var typeIdB = new SimpleTypeIdentifier(new IdentifierString(nameB)); + + var arrayTypeA = new ArrayTypeIdentifier(typeIdA, arrayAIsNullable); + var arrayTypeB = new ArrayTypeIdentifier(typeIdB, arrayBIsNullable); + + (arrayTypeA == arrayTypeB).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", true, "Parser", true, false)] + [InlineData("_Parser", false, "_Parser", false, false)] + [InlineData("Parser", false, "Parser", true, true)] + [InlineData("Parser", true, "_Parser", false, true)] + public void InequalityOperatorWithArrayTypeIdentifier_ReturnsNonEquivalenceBasedOnItemTypeAndNullability( + string nameA, + bool arrayAIsNullable, + string nameB, + bool arrayBIsNullable, + bool expected) + { + var typeIdA = new SimpleTypeIdentifier(new IdentifierString(nameA)); + var typeIdB = new SimpleTypeIdentifier(new IdentifierString(nameB)); + + var arrayTypeA = new ArrayTypeIdentifier(typeIdA, arrayAIsNullable); + var arrayTypeB = new ArrayTypeIdentifier(typeIdB, arrayBIsNullable); + + (arrayTypeA != arrayTypeB).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/GenericTypeIdentifierTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/GenericTypeIdentifierTests.cs new file mode 100644 index 000000000..13ea06a2e --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/GenericTypeIdentifierTests.cs @@ -0,0 +1,148 @@ +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class GenericTypeIdentifierTests +{ + [Theory] + [InlineData("Parser")] + [InlineData("__Parser")] + [InlineData("X509")] + public void Constructor_CreatesGenericTypeIdentifierFromValidName(string name) + { + var nameIdentifier = new IdentifierString(name); + + var identifier = new GenericTypeIdentifier(nameIdentifier, [ new SimpleTypeIdentifier(new IdentifierString("string")) ]); + + identifier.Name.Should().Be(nameIdentifier); + identifier.IsNullable.Should().BeFalse(); + identifier.TypeArguments.Should().BeEquivalentTo([ new SimpleTypeIdentifier(new IdentifierString("string")) ]); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Constructor_ThrowsArgumentExceptionWhenUsingAnEmptyName(string name) + { + Func ctr = () => new GenericTypeIdentifier( + new IdentifierString(name), + [new SimpleTypeIdentifier(new IdentifierString("string"))]); + + ctr.Should().Throw(); + } + + [Fact] + public void Constructor_ThrowsArgumentExceptionWhenNoTypeArgumentsAreSpecified() + { + Func ctr = () => new GenericTypeIdentifier(new IdentifierString("Parser"), []); + + ctr.Should().Throw(); + } + + [Theory] + [InlineData("Parser", "Parser")] + [InlineData("_Parser", "_Parser")] + public void EqualsGenericTypeIdentifier_ReturnsTrueWhenNameAndGenericTypeArgumentsMatch( + string name1, + string name2) + { + var typeId1 = new GenericTypeIdentifier(new IdentifierString(name1), [new SimpleTypeIdentifier(new IdentifierString("string"))]); + var typeId2 = new GenericTypeIdentifier(new IdentifierString(name2), [new SimpleTypeIdentifier(new IdentifierString("string"))]); + + typeId1.Equals(typeId2).Should().BeTrue(); + } + + [Theory] + [InlineData("Parser", "parser")] + [InlineData("Parser", "_Parser")] + public void EqualsGenericTypeIdentifier_ReturnsFalseWhenLocalNameDoesNotMatch( + string name1, + string name2) + { + var typeId1 = new GenericTypeIdentifier( + new IdentifierString(name1), + [new SimpleTypeIdentifier(new IdentifierString("string"))]); + + var typeId2 = new GenericTypeIdentifier( + new IdentifierString(name2), + [new SimpleTypeIdentifier(new IdentifierString("string"))]); + + typeId1.Equals(typeId2).Should().BeFalse(); + } + + [Fact] + public void EqualsGenericTypeIdentifier_ReturnsFalseTypeArgumentsDoNotMatch() + { + var typeId1 = new GenericTypeIdentifier( + new IdentifierString("List"), + [new SimpleTypeIdentifier(new IdentifierString("string"))]); + + var typeId2 = new GenericTypeIdentifier( + new IdentifierString("List"), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + typeId1.Equals(typeId2).Should().BeFalse(); + } + + [Theory] + [InlineData("Parser", "Parser")] + [InlineData("Internal", "Internal")] + [InlineData("_1XYZ", "_1XYZ")] + [InlineData("__Internal", "__Internal")] + public void GetHashCode_ReturnsSameValueForEquivalentValues( + string name1, + string name2) + { + var typeId1 = new GenericTypeIdentifier( + new IdentifierString(name1), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + var typeId2 = new GenericTypeIdentifier( + new IdentifierString(name2), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + typeId1.GetHashCode().Should().Be(typeId2.GetHashCode()); + } + + [Theory] + [InlineData("Parser", "Parser", true)] + [InlineData("_Parser", "_Parser", true)] + [InlineData("_Parser", "_parser", false)] + [InlineData("Parser", "parser", false)] + [InlineData("Parser", "_Parser", false)] + public void EqualityOperatorWithTypeIdentifier_ReturnsEquivalence( + string name1, + string name2, + bool expected) + { + var typeId1 = new GenericTypeIdentifier( + new IdentifierString(name1), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + var typeId2 = new GenericTypeIdentifier( + new IdentifierString(name2), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + (typeId1 == typeId2).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", "Parser", false)] + [InlineData("_Parser", "_Parser", false)] + [InlineData("_Parser", "_parser", true)] + [InlineData("Parser", "parser", true)] + [InlineData("Parser", "_Parser", true)] + public void InequalityOperatorWithTypeIdentifier_ReturnsNonEquivalence( + string name1, + string name2, + bool expected) + { + var typeId1 = new GenericTypeIdentifier( + new IdentifierString(name1), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + var typeId2 = new GenericTypeIdentifier( + new IdentifierString(name2), + [new SimpleTypeIdentifier(new IdentifierString("int"))]); + + (typeId1 != typeId2).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/IdentifierStringTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/IdentifierStringTests.cs new file mode 100644 index 000000000..66ef70a33 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/IdentifierStringTests.cs @@ -0,0 +1,149 @@ +using Reqnroll.FeatureSourceGenerator; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class IdentifierStringTests +{ + [Fact] + public void DefaultValue_IsEmpty() + { + var identifier = default(IdentifierString); + + identifier.IsEmpty.Should().BeTrue(); + identifier.ToString().Should().Be(""); + } + + [Theory] + [InlineData("Parser")] + [InlineData("__Parser")] + [InlineData("X509")] + public void Constructor_CreatesIdentifierStringFromValidValue(string name) + { + var identifier = new IdentifierString(name); + + identifier.IsEmpty.Should().BeFalse(); + identifier.ToString().Should().Be(name); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Constructor_CreatesEmptyIdentifierStringFromEmptyValue(string name) + { + var identifier = new IdentifierString(name); + + identifier.IsEmpty.Should().BeTrue(); + identifier.ToString().Should().Be(""); + } + + [Theory] + [InlineData(".FeatureSourceGenerator")] + [InlineData("FeatureSourceGenerator.")] + [InlineData("Reqnroll.FeatureSourceGenerator")] + [InlineData("1FeatureSourceGenerator")] + public void Constructor_ThrowsArgumentExceptionWhenUsingAnInvalidLocalNameValue(string name) + { + Func ctr = () => new IdentifierString(name); + + ctr.Should().Throw(); + } + + [Theory] + [InlineData("Parser", "Parser")] + [InlineData("_Parser", "_Parser")] + [InlineData("", "")] + [InlineData(null, null)] + [InlineData(null, "")] + public void EqualsIdentifierString_ReturnsTrueWhenValuesMatch(string id1, string id2) + { + new IdentifierString(id1).Equals(new IdentifierString(id2)).Should().BeTrue(); + } + + [Theory] + [InlineData("Parser", "parser")] + [InlineData("Parser", "_Parser")] + public void EqualsIdentifierString_ReturnsFalseWhenValuesDoNotMatch(string id1, string id2) + { + new IdentifierString(id1).Equals(new IdentifierString(id2)).Should().BeFalse(); + } + + [Theory] + [InlineData("Parser", "Parser", true)] + [InlineData("_Parser", "_Parser", true)] + [InlineData("_Parser", "_parser", false)] + [InlineData(null, "", true)] + [InlineData(null, null, true)] + [InlineData("", null, true)] + public void EqualsString_ReturnsCaseSensitiveEquivalence(string? id, string? identifier, bool expected) + { + new IdentifierString(id).Equals(identifier).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", "Parser")] + [InlineData("Internal", "Internal")] + [InlineData("_1XYZ", "_1XYZ")] + [InlineData("__Internal", "__Internal")] + [InlineData("", "")] + [InlineData(null, "")] + public void GetHashCode_ReturnsSameValueForEquivalentValues(string? id1, string? id2) + { + new IdentifierString(id1).GetHashCode().Should().Be(new IdentifierString(id2).GetHashCode()); + } + + [Theory] + [InlineData("Parser", "Parser", true)] + [InlineData("Parser", "parser", false)] + [InlineData("_Parser", "_Parser", true)] + [InlineData("_Parser", "_parser", false)] + [InlineData(null, "", true)] + [InlineData(null, null, true)] + [InlineData("", null, true)] + public void EqualityOperatorWithString_ReturnsEquivalenceWithCaseSensitivity(string? id, string? identifier, bool expected) + { + (new IdentifierString(id) == identifier).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", "Parser", true)] + [InlineData("_Parser", "_Parser", true)] + [InlineData("_Parser", "_parser", false)] + [InlineData("Parser", "parser", false)] + [InlineData("Parser", "_Parser", false)] + public void EqualityOperatorWithIdentifierString_ReturnsEquivalenceWithCaseSensitivity(string? id1, string? id2, bool expected) + { + (new IdentifierString(id1) == new IdentifierString(id2)).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", "Parser", false)] + [InlineData("Parser", "parser", true)] + [InlineData("_Parser", "_Parser", false)] + [InlineData("_Parser", "_parser", true)] + [InlineData(null, "", false)] + [InlineData(null, null, false)] + [InlineData("", null, false)] + public void InequalityOperatorWithString_ReturnsNonEquivalenceWithCaseSensitivity( + string? id, + string? identifier, + bool expected) + { + (new IdentifierString(id) != identifier).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", "Parser", false)] + [InlineData("Parser", "parser", true)] + [InlineData("_Parser", "_Parser", false)] + [InlineData("_Parser", "_parser", true)] + [InlineData(null, "", false)] + [InlineData(null, null, false)] + [InlineData("", null, false)] + public void InequalityOperatorWithIdentifierString_ReturnsNonEquivalenceWithCaseSensitivity( + string? id1, + string? id2, + bool expected) + { + (new IdentifierString(id1) != new IdentifierString(id2)).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/NamespaceStringTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/NamespaceStringTests.cs new file mode 100644 index 000000000..e08592f9a --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/NamespaceStringTests.cs @@ -0,0 +1,134 @@ +using Reqnroll.FeatureSourceGenerator; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class NamespaceStringTests +{ + [Fact] + public void DefaultInstance_IsEmpty() + { + var ns = default(NamespaceString); + + ns.IsEmpty.Should().BeTrue(); + ns.ToString().Should().Be(""); + } + + [Theory] + [InlineData("Reqnoll")] + [InlineData("Reqnoll.FeatureSourceGenerator")] + [InlineData("__Internal")] + [InlineData("_1XYZ")] + [InlineData("Reqnroll.__Internal")] + public void Constructor_CreatesNamespaceValueFromValidString(string s) + { + var ns = new NamespaceString(s); + + ns.ToString().Should().Be(s); + ns.IsEmpty.Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Construtor_CreatesEmptyNamespaceValueFromEmptyString(string s) + { + var ns = new NamespaceString(s); + + ns.ToString().Should().Be(""); + ns.IsEmpty.Should().BeTrue(); + } + + [Theory] + [InlineData(".Reqnroll")] + [InlineData("Reqnroll.")] + [InlineData(".Reqnroll.FeatureSourceGenerator")] + [InlineData("Reqnroll.FeatureSourceGenerator.")] + [InlineData("Reqnroll.FeatureSourceGenerator.1")] + [InlineData("Reqnroll..FeatureSourceGenerator")] + public void Constructor_ThrowsArgumentExceptionWhenStringIsNotValidAsNamespace(string s) + { + Func ctr = () => new NamespaceString(s); + ctr.Should().Throw(); + } + + [Theory] + [InlineData("Reqnroll", "Reqnroll", true)] + [InlineData("Reqnroll", "reqnroll", false)] + [InlineData("Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator", true)] + [InlineData("__Internal", "__Internal", true)] + [InlineData("__Internal", "__internal", false)] + public void EqualsNamespaceString_ReturnsEquivalenceWithCaseSensitivity(string a, string b, bool expected) + { + new NamespaceString(a).Equals(new NamespaceString(b)).Should().Be(expected); + } + + [Theory] + [InlineData("Reqnroll", "Reqnroll", true)] + [InlineData("Reqnroll", "reqnroll", false)] + [InlineData("Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator", true)] + [InlineData("__Internal", "__Internal", true)] + [InlineData("__Internal", "__internal", false)] + [InlineData("__Internal", "1234", false)] + [InlineData("__Internal", ".Reqnroll", false)] + public void EqualsString_ReturnsEquivalenceWithCaseSensitivity(string a, string b, bool expected) + { + new NamespaceString(a).Equals(b).Should().Be(expected); + } + + [Theory] + [InlineData("Reqnoll")] + [InlineData("Reqnoll.FeatureSourceGenerator")] + [InlineData("__Internal")] + [InlineData("_1XYZ")] + [InlineData("Reqnroll.__Internal")] + [InlineData("")] + [InlineData(null)] + public void GetHashCode_ReturnsSameValueForEquivalentValues(string s) + { + new NamespaceString(s).GetHashCode().Should().Be(new NamespaceString(s).GetHashCode()); + } + + [Theory] + [InlineData("Reqnroll", "Reqnroll", true)] + [InlineData("Reqnroll", "reqnroll", false)] + [InlineData("Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator", true)] + [InlineData("__Internal", "__Internal", true)] + [InlineData("__Internal", "__internal", false)] + public void EqualityOperatorWithString_ReturnsEquivalenceWithCaseSensitivity(string a, string b, bool expected) + { + (new NamespaceString(a) == new NamespaceString(b)).Should().Be(expected); + } + + [Theory] + [InlineData("Reqnroll", "Reqnroll", true)] + [InlineData("Reqnroll", "reqnroll", false)] + [InlineData("Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator", true)] + [InlineData("__Internal", "__Internal", true)] + [InlineData("__Internal", "__internal", false)] + public void EqualityOperatorWithNamespaceString_ReturnsEquivalenceWithCaseSensitivity(string a, string b, bool expected) + { + (new NamespaceString(a) == b).Should().Be(expected); + } + + [Theory] + [InlineData("Reqnroll", "Reqnroll", false)] + [InlineData("Reqnroll", "reqnroll", true)] + [InlineData("Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator", false)] + [InlineData("__Internal", "__Internal", false)] + [InlineData("__Internal", "__internal", true)] + public void InequalityOperatorWithString_ReturnsNonEquivalenceWithCaseSensitivity(string a, string b, bool expected) + { + (new NamespaceString(a) != new NamespaceString(b)).Should().Be(expected); + } + + [Theory] + [InlineData("Reqnroll", "Reqnroll", false)] + [InlineData("Reqnroll", "reqnroll", true)] + [InlineData("Reqnroll.FeatureSourceGenerator", "Reqnroll.FeatureSourceGenerator", false)] + [InlineData("__Internal", "__Internal", false)] + [InlineData("__Internal", "__internal", true)] + public void InequalityOperatorWithNamespaceString_ReturnsNonEquivalenceWithCaseSensitivity(string a, string b, bool expected) + { + (new NamespaceString(a) != b).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/ParameterDescriptorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/ParameterDescriptorTests.cs new file mode 100644 index 000000000..c36edfbd2 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/ParameterDescriptorTests.cs @@ -0,0 +1,66 @@ +using Reqnroll.FeatureSourceGenerator; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; +public class ParameterDescriptorTests +{ + [Fact] + public void Constructor_ThrowsArgumentExceptionWhenNameIsEmpty() + { + Func ctr = () => + new ParameterDescriptor(IdentifierString.Empty, new SimpleTypeIdentifier(new IdentifierString("string"))); + + ctr.Should().Throw(); + } + + [Fact] + public void Constructor_InitializesProperties() + { + var name = new IdentifierString("potato"); + var type = new SimpleTypeIdentifier(new IdentifierString("string")); + + var descriptor = new ParameterDescriptor(name, type); + + descriptor.Name.Should().Be(name); + descriptor.Type.Should().Be(type); + } + + [Theory] + [InlineData("potato", "System", "String")] + [InlineData("foo", "System", "Int32")] + [InlineData("bar", "System", "Int64")] + public void GetHashCode_ReturnsSameValueForEquivalentValues(string name, string typeNamespace, string typeName) + { + var descriptorA = new ParameterDescriptor( + new IdentifierString(name), + new NamespaceString(typeNamespace) + new SimpleTypeIdentifier(new IdentifierString(typeName))); + + var descriptorB = new ParameterDescriptor( + new IdentifierString(name), + new NamespaceString(typeNamespace) + new SimpleTypeIdentifier(new IdentifierString(typeName))); + + descriptorA.GetHashCode().Should().Be(descriptorB.GetHashCode()); + } + + [Theory] + [InlineData("potato", "System", "String", "potato", "System", "String", true)] + [InlineData("potato", "System", "String", "foo", "System", "String", false)] + public void Equals_ReturnsEqualityBasedOnMatchingPropertyValues( + string nameA, + string typeNamespaceA, + string typeNameA, + string nameB, + string typeNamespaceB, + string typeNameB, + bool expected) + { + var descriptorA = new ParameterDescriptor( + new IdentifierString(nameA), + new NamespaceString(typeNamespaceA) + new SimpleTypeIdentifier(new IdentifierString(typeNameA))); + + var descriptorB = new ParameterDescriptor( + new IdentifierString(nameB), + new NamespaceString(typeNamespaceB) + new SimpleTypeIdentifier(new IdentifierString(typeNameB))); + + descriptorA.Equals(descriptorB).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/QualifiedTypeIdentifierTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/QualifiedTypeIdentifierTests.cs new file mode 100644 index 000000000..dd292ec9e --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/QualifiedTypeIdentifierTests.cs @@ -0,0 +1,145 @@ +using Reqnroll.FeatureSourceGenerator; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class QualifiedTypeIdentifierTests +{ + [Theory] + [InlineData("Reqnroll", "Parser")] + [InlineData("Reqnroll", "__Parser")] + [InlineData("Reqnroll", "X509")] + public void Constructor_CreatesQualifiedTypeIdentifierFromValidNameAndNamespace(string ns, string name) + { + var nsx = new NamespaceString(ns); + var localType = new SimpleTypeIdentifier(new IdentifierString(name)); + + var identifier = new QualifiedTypeIdentifier(nsx, localType); + + identifier.LocalType.Should().Be(localType); + identifier.Namespace.Should().Be(nsx); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "Reqnroll.Parser")] + [InlineData("Reqnroll", "__Parser", "Reqnroll.__Parser")] + [InlineData("Reqnroll", "X509", "Reqnroll.X509")] + public void ToString_ReturnsNamespaceAndLocalTypeSeparatedByADot(string ns, string name, string expected) + { + var nsx = new NamespaceString(ns); + var localType = new SimpleTypeIdentifier(new IdentifierString(name)); + var identifier = new QualifiedTypeIdentifier(nsx, localType); + + identifier.ToString().Should().Be(expected); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Constructor_ThrowsArgumentExceptionWhenUsingAnEmptyNamespace(string ns) + { + var localType = new SimpleTypeIdentifier(new IdentifierString("Parser")); + Func ctr = () => new QualifiedTypeIdentifier(new NamespaceString(ns), localType); + + ctr.Should().Throw(); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "Reqnroll", "Parser")] + [InlineData("Reqnroll", "_Parser", "Reqnroll", "_Parser")] + public void EqualsTypeIdentifier_ReturnsTrueWhenNamespacesAndLocalTypeMatches( + string ns1, + string name1, + string ns2, + string name2) + { + var typeId1 = new QualifiedTypeIdentifier(new NamespaceString(ns1), new SimpleTypeIdentifier(new IdentifierString(name1))); + var typeId2 = new QualifiedTypeIdentifier(new NamespaceString(ns2), new SimpleTypeIdentifier(new IdentifierString(name2))); + + typeId1.Equals(typeId2).Should().BeTrue(); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "System", "Parser")] + [InlineData("Reqnroll", "_Parser", "System", "_Parser")] + public void EqualsTypeIdentifier_ReturnsFalseWhenNamespaceDoesNotMatch( + string ns1, + string name1, + string ns2, + string name2) + { + var typeId1 = new QualifiedTypeIdentifier(new NamespaceString(ns1), new SimpleTypeIdentifier(new IdentifierString(name1))); + var typeId2 = new QualifiedTypeIdentifier(new NamespaceString(ns2), new SimpleTypeIdentifier(new IdentifierString(name2))); + + typeId1.Equals(typeId2).Should().BeFalse(); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "Reqnroll", "parser")] + [InlineData("Reqnroll", "Parser", "Reqnroll", "_Parser")] + public void EqualsTypeIdentifier_ReturnsFalseWhenLocalTypeDoesNotMatch( + string ns1, + string name1, + string ns2, + string name2) + { + var typeId1 = new QualifiedTypeIdentifier(new NamespaceString(ns1), new SimpleTypeIdentifier(new IdentifierString(name1))); + var typeId2 = new QualifiedTypeIdentifier(new NamespaceString(ns2), new SimpleTypeIdentifier(new IdentifierString(name2))); + + typeId1.Equals(typeId2).Should().BeFalse(); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "Reqnroll", "Parser")] + [InlineData("Reqnroll", "Internal", "Reqnroll", "Internal")] + public void GetHashCode_ReturnsSameValueForEquivalentValues( + string ns1, + string name1, + string ns2, + string name2) + { + var typeId1 = new QualifiedTypeIdentifier(new NamespaceString(ns1), new SimpleTypeIdentifier(new IdentifierString(name1))); + var typeId2 = new QualifiedTypeIdentifier(new NamespaceString(ns2), new SimpleTypeIdentifier(new IdentifierString(name2))); + + typeId1.GetHashCode().Should().Be(typeId2.GetHashCode()); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "Reqnroll", "Parser", true)] + [InlineData("Reqnroll", "_Parser", "Reqnroll", "_Parser", true)] + [InlineData("Reqnroll", "Parser", "System", "Parser", false)] + [InlineData("Reqnroll", "_Parser", "System", "_Parser", false)] + [InlineData("Reqnroll", "Parser", "Reqnroll", "parser", false)] + [InlineData("Reqnroll", "Parser", "Reqnroll", "_Parser", false)] + public void EqualityOperatorWithTypeIdentifier_ReturnsEquivalenceWithCaseSensitivity( + string ns1, + string name1, + string ns2, + string name2, + bool expected) + { + var typeId1 = new QualifiedTypeIdentifier(new NamespaceString(ns1), new SimpleTypeIdentifier(new IdentifierString(name1))); + var typeId2 = new QualifiedTypeIdentifier(new NamespaceString(ns2), new SimpleTypeIdentifier(new IdentifierString(name2))); + + (typeId1 == typeId2).Should().Be(expected); + } + + [Theory] + [InlineData("Reqnroll", "Parser", "Reqnroll", "Parser", false)] + [InlineData("Reqnroll", "_Parser", "Reqnroll", "_Parser", false)] + [InlineData("Reqnroll", "Parser", "System", "Parser", true)] + [InlineData("Reqnroll", "_Parser", "System", "_Parser", true)] + [InlineData("Reqnroll", "Parser", "Reqnroll", "parser", true)] + [InlineData("Reqnroll", "Parser", "Reqnroll", "_Parser", true)] + public void InequalityOperatorWithTypeIdentifier_ReturnsNonEquivalenceWithCaseSensitivity( + string ns1, + string name1, + string ns2, + string name2, + bool expected) + { + var typeId1 = new QualifiedTypeIdentifier(new NamespaceString(ns1), new SimpleTypeIdentifier(new IdentifierString(name1))); + var typeId2 = new QualifiedTypeIdentifier(new NamespaceString(ns2), new SimpleTypeIdentifier(new IdentifierString(name2))); + + (typeId1 != typeId2).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/SimpleTypeIdentifierTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/SimpleTypeIdentifierTests.cs new file mode 100644 index 000000000..7e7fffac0 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/SourceModel/SimpleTypeIdentifierTests.cs @@ -0,0 +1,104 @@ +using Reqnroll.FeatureSourceGenerator; + +namespace Reqnroll.FeatureSourceGenerator.SourceModel; + +public class SimpleTypeIdentifierTests +{ + [Theory] + [InlineData("Parser")] + [InlineData("__Parser")] + [InlineData("X509")] + public void Constructor_CreatesSimpleTypeIdentifierFromValidName(string name) + { + var nameIdentifier = new IdentifierString(name); + + var identifier = new SimpleTypeIdentifier(nameIdentifier); + + identifier.Name.Should().Be(nameIdentifier); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Constructor_ThrowsArgumentExceptionWhenUsingAnEmptyName(string name) + { + Func ctr = () => new SimpleTypeIdentifier(new IdentifierString(name)); + + ctr.Should().Throw(); + } + + [Theory] + [InlineData("Parser", "Parser")] + [InlineData("_Parser", "_Parser")] + public void EqualsTypeIdentifier_ReturnsTrueWhenLocalNamesMatche( + string name1, + string name2) + { + var typeId1 = new SimpleTypeIdentifier(new IdentifierString(name1)); + var typeId2 = new SimpleTypeIdentifier(new IdentifierString(name2)); + + typeId1.Equals(typeId2).Should().BeTrue(); + } + + [Theory] + [InlineData("Parser", "parser")] + [InlineData("Parser", "_Parser")] + public void EqualsTypeIdentifier_ReturnsFalseWhenLocalNameDoesNotMatch( + string name1, + string name2) + { + var typeId1 = new SimpleTypeIdentifier(new IdentifierString(name1)); + var typeId2 = new SimpleTypeIdentifier(new IdentifierString(name2)); + + typeId1.Equals(typeId2).Should().BeFalse(); + } + + [Theory] + [InlineData("Parser", "Parser")] + [InlineData("Internal", "Internal")] + [InlineData("_1XYZ", "_1XYZ")] + [InlineData("__Internal", "__Internal")] + public void GetHashCode_ReturnsSameValueForEquivalentValues( + string name1, + string name2) + { + var typeId1 = new SimpleTypeIdentifier(new IdentifierString(name1)); + var typeId2 = new SimpleTypeIdentifier(new IdentifierString(name2)); + + typeId1.GetHashCode().Should().Be(typeId2.GetHashCode()); + } + + [Theory] + [InlineData("Parser", "Parser", true)] + [InlineData("_Parser", "_Parser", true)] + [InlineData("_Parser", "_parser", false)] + [InlineData("Parser", "parser", false)] + [InlineData("Parser", "_Parser", false)] + public void EqualityOperatorWithTypeIdentifier_ReturnsEquivalenceWithCaseSensitivity( + string name1, + string name2, + bool expected) + { + var typeId1 = new SimpleTypeIdentifier(new IdentifierString(name1)); + var typeId2 = new SimpleTypeIdentifier(new IdentifierString(name2)); + + (typeId1 == typeId2).Should().Be(expected); + } + + [Theory] + [InlineData("Parser", "Parser", false)] + [InlineData("_Parser", "_Parser", false)] + [InlineData("_Parser", "_parser", true)] + [InlineData("Parser", "parser", true)] + [InlineData("Parser", "_Parser", true)] + public void InequalityOperatorWithTypeIdentifier_ReturnsNonEquivalenceWithCaseSensitivity( + string name1, + string name2, + bool expected) + { + var typeId1 = new SimpleTypeIdentifier(new IdentifierString(name1)); + var typeId2 = new SimpleTypeIdentifier(new IdentifierString(name2)); + + (typeId1 != typeId2).Should().Be(expected); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/TestMethodAssertions.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/TestMethodAssertions.cs new file mode 100644 index 000000000..bb1471487 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/TestMethodAssertions.cs @@ -0,0 +1,89 @@ +using FluentAssertions.Execution; +using Reqnroll.FeatureSourceGenerator.SourceModel; + +namespace Reqnroll.FeatureSourceGenerator; + +public class TestMethodAssertions(TestMethod? subject) : + TestMethodAssertions(subject) +{ +} + +public class TestMethodAssertions(TestMethod? subject) : + AttributeAssertions(subject) + where TSubject: TestMethod? + where TAssertions : TestMethodAssertions +{ + protected override string Identifier => "method"; + + public AndConstraint HaveNoParameters( + string because = "", + params object[] becauseArgs) => + HaveParametersEquivalentTo([], because, becauseArgs); + + public AndConstraint HaveParametersEquivalentTo( + ParameterDescriptor[] expectation, + string because = "", + params object[] becauseArgs) => + HaveParametersEquivalentTo((IEnumerable)expectation, because, becauseArgs); + + public AndConstraint HaveParametersEquivalentTo( + IEnumerable expectation, + string because = "", + params object[] becauseArgs) + { + using var scope = new AssertionScope(); + + scope.FormattingOptions.UseLineBreaks = true; + + var setup = Execute.Assertion + .BecauseOf(because, becauseArgs); + + var expected = expectation.ToList(); + + var assertion = (expected.Count == 0 ? + setup.WithExpectation("Expected {context:the subject} to have no parameters") : + setup.WithExpectation("Expected {context:the subject} to have parameters equivalent to {0}", expected)) + .ForCondition(Subject is not null) + .FailWith("but {context:the subject} is ."); + + var actual = Subject!.Parameters; + + if (expected.Count == 0 && actual.Length != 0) + { + assertion + .Then + .FailWith("but {context:the subject} has parameters defined"); + } + else + { + for (var i = 0; i < expected.Count; i++) + { + var expectedItem = expected[i]; + var itemAssertion = Execute.Assertion + .BecauseOf(because, becauseArgs) + .WithExpectation("Expected parameter {0} to be \"{1}\" ", i, expectedItem) + .ForCondition(actual.Length > i) + .FailWith(" but {context:the subject} does not define a parameter {0}.", i); + + if (actual.Length >= i) + { + var actualItem = actual[i]; + + itemAssertion + .Then + .ForCondition(actualItem.Equals(expectedItem)) + .FailWith("but found \"{0}\".", actual[i]); + } + } + + if (actual.Length > expected.Count) + { + assertion + .Then + .FailWith("but {context:the subject} has additional parameters {0}.", actual.Skip(expected.Count)); + } + } + + return new AndConstraint((TAssertions)this); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/XUnit/XUnitCSharpTestFixtureGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/XUnit/XUnitCSharpTestFixtureGeneratorTests.cs new file mode 100644 index 000000000..5862ad1d3 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/XUnit/XUnitCSharpTestFixtureGeneratorTests.cs @@ -0,0 +1,204 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Reqnroll.FeatureSourceGenerator.CSharp; +using Reqnroll.FeatureSourceGenerator.SourceModel; +using System.Collections.Immutable; + +namespace Reqnroll.FeatureSourceGenerator.XUnit; +public class XUnitCSharpTestFixtureGeneratorTests() : CSharpTestFixtureGeneratorTestBase(new XUnitHandler()) +{ + private static readonly NamespaceString XUnitNamespace = new("Xunit"); + + [Fact] + public void GenerateTestFixture_CreatesClassForFeatureWithXUnitLifetimeInterface() + { + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + "foo happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ]); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [scenarioInfo], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testFixture = Generator.GenerateTestFixtureClass(testFixtureGenerationContext, []); + + testFixture.Interfaces.Should().BeEquivalentTo( + [ + XUnitNamespace + new GenericTypeIdentifier( + new IdentifierString("IClassFixture"), + [ + new NestedTypeIdentifier( + new SimpleTypeIdentifier(new IdentifierString("SampleFeature")), + new SimpleTypeIdentifier(new IdentifierString("FeatureLifetime"))) + ]) + ]); + } + + [Fact] + public void GenerateTestMethod_CreatesParameterlessMethodForScenarioWithoutExamples() + { + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + "foo happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ]); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [scenarioInfo], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testMethodGenerationContext = new TestMethodGenerationContext( + scenarioInfo, + testFixtureGenerationContext); + + var method = Generator.GenerateTestMethod(testMethodGenerationContext); + + method.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("SkippableFact")), + namedArguments: new Dictionary{ + { new IdentifierString("DisplayName"), "Sample Scenario" } + }.ToImmutableDictionary()), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Trait")), + ["FeatureTitle", "Sample"]), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Trait")), + ["Description", "Sample Scenario"]) + ]); + + method.Should().HaveNoParameters(); + + method.StepInvocations.Should().BeEquivalentTo( + [ + new StepInvocation( + StepType.Action, + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20)), + "When", + "foo happens") + ]); + } + + [Fact] + public void GenerateTestMethod_CreatesMethodWithInlineDataAttributesWhenScenarioHasExamples() + { + var exampleSet1 = new ScenarioExampleSet(["what"], [["foo"], ["bar"]], ["example_tag"]); + var exampleSet2 = new ScenarioExampleSet(["what"], [["baz"]], []); + + var scenarioInfo = new ScenarioInformation( + "Sample Scenario Outline", + new FileLinePositionSpan("Sample.feature", new LinePosition(3, 0), new LinePosition(3, 24)), + [], + [ + new Step( + StepType.Action, + "When", + " happens", + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20))) + ], + [exampleSet1, exampleSet2]); + + var featureInfo = new FeatureInformation( + "Sample", + null, + "en", + ["featureTag1"], + null); + + var testFixtureGenerationContext = new TestFixtureGenerationContext( + featureInfo, + [scenarioInfo], + "Sample.feature", + new NamespaceString("Reqnroll.Tests"), + Compilation, + Generator); + + var testMethodGenerationContext = new TestMethodGenerationContext( + scenarioInfo, + testFixtureGenerationContext); + + var method = Generator.GenerateTestMethod(testMethodGenerationContext); + + method.Should().HaveAttribuesEquivalentTo( + [ + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("SkippableTheory")), + namedArguments: new Dictionary{ + { new IdentifierString("DisplayName"), "Sample Scenario Outline" } + }.ToImmutableDictionary()), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Trait")), + ["FeatureTitle", "Sample"]), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("Trait")), + ["Description", "Sample Scenario Outline"]), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("InlineData")), + ["foo", ImmutableArray.Create("example_tag")]), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("InlineData")), + ["bar", ImmutableArray.Create("example_tag")]), + new AttributeDescriptor( + XUnitNamespace + new SimpleTypeIdentifier(new IdentifierString("InlineData")), + ["baz", ImmutableArray.Empty]) + ]); + + method.Should().HaveParametersEquivalentTo( + [ + new ParameterDescriptor( + new IdentifierString("what"), + new NamespaceString("System") + new SimpleTypeIdentifier(new IdentifierString("String"))), + + new ParameterDescriptor( + new IdentifierString("_exampleTags"), + new ArrayTypeIdentifier(new NamespaceString("System") + new SimpleTypeIdentifier(new IdentifierString("String")))) + ]); + + method.StepInvocations.Should().BeEquivalentTo( + [ + new StepInvocation( + StepType.Action, + new FileLinePositionSpan("Sample.feature", new LinePosition(4, 4), new LinePosition(4, 20)), + "When", + "{0} happens", + [new IdentifierString("what")]) + ]); + } +} diff --git a/Tests/Reqnroll.FeatureSourceGeneratorTests/XUnit/XUnitFeatureSourceGeneratorTests.cs b/Tests/Reqnroll.FeatureSourceGeneratorTests/XUnit/XUnitFeatureSourceGeneratorTests.cs new file mode 100644 index 000000000..906666bd0 --- /dev/null +++ b/Tests/Reqnroll.FeatureSourceGeneratorTests/XUnit/XUnitFeatureSourceGeneratorTests.cs @@ -0,0 +1,114 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Reqnroll.FeatureSourceGenerator; +using Reqnroll.FeatureSourceGenerator.CSharp; +using Xunit.Abstractions; + +namespace Reqnroll.FeatureSourceGenerator.XUnit; + +public class XUnitFeatureSourceGeneratorTests(ITestOutputHelper output) +{ + [Fact] + public void GeneratorProducesXUnitOutputWhenWhenBuildPropertyConfiguredForXUnit() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([BuiltInTestFrameworkHandlers.XUnit]); + + const string featureText = + """ + #language: en + @featureTag1 + Feature: Calculator + + @mytag + Scenario: Add two numbers + Given the first number is 50 + And the second number is 70 + When the two numbers are added + Then the result should be 120 + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { "build_property.ReqnrollTargetTestFramework", "xunit" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generatedSyntaxTree = generatedCompilation.SyntaxTrees.Should().ContainSingle() + .Which.Should().BeAssignableTo().Subject!; + + output.WriteLine($"Generated source:\n{generatedSyntaxTree}"); + + generatedSyntaxTree.GetDiagnostics().Should().BeEmpty(); + + generatedSyntaxTree.GetRoot().Should().ContainSingleNamespaceDeclaration("test") + .Which.Should().ContainSingleClassDeclaration("CalculatorFeature") + .Which.Should().ContainMethod("AddTwoNumbers") + .Which.Should().HaveAttribute("global::Xunit.SkippableFact"); + } + + [Fact] + public void GeneratorProducesXUnitOutputWhenWhenEditorConfigConfiguredForXUnit() + { + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(asm => !asm.IsDynamic) + .Select(asm => MetadataReference.CreateFromFile(asm.Location)); + + var compilation = CSharpCompilation.Create("test", references: references); + + var generator = new CSharpTestFixtureSourceGenerator([BuiltInTestFrameworkHandlers.XUnit]); + + const string featureText = + """ + #language: en + @featureTag1 + Feature: Calculator + + @mytag + Scenario: Add two numbers + Given the first number is 50 + And the second number is 70 + When the two numbers are added + Then the result should be 120 + """; + + var optionsProvider = new FakeAnalyzerConfigOptionsProvider( + new InMemoryAnalyzerConfigOptions(new Dictionary + { + { "reqnroll.target_test_framework", "xunit" } + })); + + var driver = CSharpGeneratorDriver + .Create(generator) + .AddAdditionalTexts([new FeatureFile("Calculator.feature", featureText)]) + .WithUpdatedAnalyzerConfigOptions(optionsProvider) + .RunGeneratorsAndUpdateCompilation(compilation, out var generatedCompilation, out var diagnostics); + + diagnostics.Should().BeEmpty(); + + var generatedSyntaxTree = generatedCompilation.SyntaxTrees.Should().ContainSingle() + .Which.Should().BeAssignableTo().Subject!; + + output.WriteLine($"Generated source:\n{generatedSyntaxTree}"); + + generatedSyntaxTree.GetDiagnostics().Should().BeEmpty(); + + generatedSyntaxTree.GetRoot().Should().ContainSingleNamespaceDeclaration("test") + .Which.Should().ContainSingleClassDeclaration("CalculatorFeature") + .Which.Should().ContainMethod("AddTwoNumbers") + .Which.Should().HaveAttribute("global::Xunit.SkippableFact"); + } +} diff --git a/Tests/Reqnroll.SystemTests/Generation/GenerationTestBase.cs b/Tests/Reqnroll.SystemTests/Generation/GenerationTestBase.cs index e90eaae0d..c145706cb 100644 --- a/Tests/Reqnroll.SystemTests/Generation/GenerationTestBase.cs +++ b/Tests/Reqnroll.SystemTests/Generation/GenerationTestBase.cs @@ -2,6 +2,9 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using FluentAssertions; using Reqnroll.TestProjectGenerator; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Collections.Generic; namespace Reqnroll.SystemTests.Generation; @@ -345,18 +348,18 @@ public async Task WhenScenarioStepIsCalled() var results = _vsTestExecutionDriver.LastTestExecutionResult.LeafTestResults; results.Should().ContainSingle(tr => tr.TestName == "Background Scenario Steps" || tr.TestName == "BackgroundScenarioSteps") - .Which.Steps.Should().BeEquivalentTo( + .Which.Steps.Select(result => result.Step).Should().BeEquivalentTo( [ - new TestStepResult { Step = "Given background step 1 is called" }, - new TestStepResult { Step = "When scenario step is called" } + "Given background step 1 is called", + "When scenario step is called" ]); results.Should().ContainSingle(tr => tr.TestName == "Rule Background Scenario Steps" || tr.TestName == "RuleBackgroundScenarioSteps") - .Which.Steps.Should().BeEquivalentTo( + .Which.Steps.Select(result => result.Step).Should().BeEquivalentTo( [ - new TestStepResult { Step = "Given background step 1 is called" }, - new TestStepResult { Step = "Given background step 2 is called" }, - new TestStepResult { Step = "When scenario step is called" } + "Given background step 1 is called", + "Given background step 2 is called", + "When scenario step is called" ]); ShouldAllScenariosPass(); @@ -364,5 +367,69 @@ public async Task WhenScenarioStepIsCalled() #endregion + #region Test tables arguments are processed + + [TestMethod] + public void Table_arguments_are_passed_to_steps() + { + AddScenario( + """ + Scenario: Using tables with steps + When this table is processed + | Example | + | A | + | B | + | C | + """); + + AddBindingClass( + """ + namespace TableArguments.StepDefinitions + { + [Binding] + public class TableArgumentSteps + { + [When("this table is processed")] + public async Task WhenThisTableIsProcessed(DataTable table) + { + var tableData = new + { + headings = table.Header.ToList(), + rows = table.Rows.Select(row => row.Select(kvp => kvp.Value)).ToList() + }; + + Log.LogCustom("argument", $"table = {System.Text.Json.JsonSerializer.Serialize(tableData)}"); + } + } + } + """); + + ExecuteTests(); + + var arguments = _bindingDriver.GetActualLogLines("argument").ToList(); + + arguments.Should().NotBeEmpty(); + + arguments[0].Should().StartWith("-> argument: table = "); + var tableSource = arguments[0]; + var tableJson = tableSource[tableSource.IndexOf('{')..(tableSource.LastIndexOf('}')+1)]; + var tableData = JsonSerializer.Deserialize(tableJson); + + var actualHeadings = tableData + .GetProperty("headings") + .EnumerateArray() + .Select(item => item.ToString()); + + var actualRows = tableData + .GetProperty("rows") + .EnumerateArray() + .Select(item => item.EnumerateArray().Select(data => data.ToString()).ToList()); + + actualHeadings.Should().BeEquivalentTo(["Example"]); + actualRows.Should().BeEquivalentTo(new List> { new() { "A" }, new() { "B" }, new() { "C" } }); + } + + #endregion + //TODO: test parallel execution (details TBD) - maybe this should be in a separate test class } diff --git a/Tests/Reqnroll.SystemTests/Generation/MsTestRoslynGenerationTest.cs b/Tests/Reqnroll.SystemTests/Generation/MsTestRoslynGenerationTest.cs new file mode 100644 index 000000000..bfcf65ed9 --- /dev/null +++ b/Tests/Reqnroll.SystemTests/Generation/MsTestRoslynGenerationTest.cs @@ -0,0 +1,16 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll.TestProjectGenerator; + +namespace Reqnroll.SystemTests.Generation; + +[TestClass] +[TestCategory("MsTest")] +[TestCategory("Roslyn")] +public class MsTestRoslynGenerationTest : MsTestGenerationTest +{ + protected override void TestInitialize() + { + base.TestInitialize(); + _testRunConfiguration.SourceGenerator = SourceGeneratorPlatform.Roslyn; + } +} diff --git a/Tests/Reqnroll.SystemTests/Generation/NUnitRoslynGenerationTest.cs b/Tests/Reqnroll.SystemTests/Generation/NUnitRoslynGenerationTest.cs new file mode 100644 index 000000000..7dc050cfd --- /dev/null +++ b/Tests/Reqnroll.SystemTests/Generation/NUnitRoslynGenerationTest.cs @@ -0,0 +1,16 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll.TestProjectGenerator; + +namespace Reqnroll.SystemTests.Generation; + +[TestClass] +[TestCategory("NUnit")] +[TestCategory("Roslyn")] +public class NUnitRoslynGenerationTest : NUnitGenerationTest +{ + protected override void TestInitialize() + { + base.TestInitialize(); + _testRunConfiguration.SourceGenerator = SourceGeneratorPlatform.Roslyn; + } +} diff --git a/Tests/Reqnroll.SystemTests/Generation/XUnitRoslynGenerationTest.cs b/Tests/Reqnroll.SystemTests/Generation/XUnitRoslynGenerationTest.cs new file mode 100644 index 000000000..e1622112a --- /dev/null +++ b/Tests/Reqnroll.SystemTests/Generation/XUnitRoslynGenerationTest.cs @@ -0,0 +1,16 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll.TestProjectGenerator; + +namespace Reqnroll.SystemTests.Generation; + +[TestClass] +[TestCategory("xUnit")] +[TestCategory("Roslyn")] +public class XUnitRoslynGenerationTest : XUnitGenerationTest +{ + protected override void TestInitialize() + { + base.TestInitialize(); + _testRunConfiguration.SourceGenerator = SourceGeneratorPlatform.Roslyn; + } +} diff --git a/Tests/Reqnroll.SystemTests/Reqnroll.SystemTests.csproj b/Tests/Reqnroll.SystemTests/Reqnroll.SystemTests.csproj index e4d382dce..d46dd61ec 100644 --- a/Tests/Reqnroll.SystemTests/Reqnroll.SystemTests.csproj +++ b/Tests/Reqnroll.SystemTests/Reqnroll.SystemTests.csproj @@ -24,6 +24,9 @@ + + false + false diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/NuGetPackage.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/NuGetPackage.cs index 01d8c3460..4c1603137 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/NuGetPackage.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/NuGetPackage.cs @@ -13,17 +13,32 @@ public NuGetPackage(string name, string version, params NuGetPackageAssembly[] a Assemblies = new ReadOnlyCollection(assemblies.Where(a => a != null).ToList()); } - public NuGetPackage(string name, string version, string allowedVersions, params NuGetPackageAssembly[] assemblies) + public NuGetPackage( + string name, + string version, + string allowedVersions = null, + bool isDevelopmentDependency = false, + params NuGetPackageAssembly[] assemblies) { Name = name; Version = version; AllowedVersions = allowedVersions; + IsDevelopmentDependency = isDevelopmentDependency; Assemblies = new ReadOnlyCollection(assemblies.Where(a => a != null).ToList()); } public string Name { get; } public string Version { get; } public string AllowedVersions { get; } + public bool IsDevelopmentDependency { get; } public IReadOnlyList Assemblies { get; } } + + public class PackageReference(string include, string version, string privateAssets = null, string includeAssets = null) + { + public string Include { get; } = include; + public string Version { get; } = version; + public string PrivateAssets { get; } = privateAssets; + public string IncludeAssets { get; } = includeAssets; + } } \ No newline at end of file diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/Project.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/Project.cs index c3576fa57..96c3ac2d2 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/Project.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Data/Project.cs @@ -68,7 +68,8 @@ public void AddNuGetPackage(string name, string version, string allowedVersions) { if (_nuGetPackages.All(n => n.Name != name)) { - _nuGetPackages.Add(new NuGetPackage(name, version, allowedVersions, KnownAssemblyNames.Get(name, version))); + _nuGetPackages.Add( + new NuGetPackage(name, version, allowedVersions: allowedVersions, assemblies: KnownAssemblyNames.Get(name, version))); } } @@ -80,11 +81,17 @@ public void AddNuGetPackage(string name, string version, params NuGetPackageAsse } } - public void AddNuGetPackage(string name, string version, string allowedVersions, params NuGetPackageAssembly[] assemblies) + public void AddNuGetPackage( + string name, + string version, + string allowedVersions = null, + bool isDevelopmentDependency = false, + params NuGetPackageAssembly[] assemblies) { if (_nuGetPackages.All(n => n.Name != name)) { - _nuGetPackages.Add(new NuGetPackage(name, version, allowedVersions, assemblies)); + _nuGetPackages.Add( + new NuGetPackage(name, version, allowedVersions, isDevelopmentDependency, assemblies)); } } diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Factories/ProjectBuilderFactory.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Factories/ProjectBuilderFactory.cs index 500ac8098..461a6f62c 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Factories/ProjectBuilderFactory.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/Factories/ProjectBuilderFactory.cs @@ -112,7 +112,16 @@ protected virtual ProjectBuilder CreateProjectBuilder() { var configuration = new Configuration(); - return new ProjectBuilder(_testProjectFolders, _featureFileGenerator, _bindingsGeneratorFactory, _configurationGeneratorFactory, configuration, _currentVersionDriver, _folders, _targetFrameworkMonikerStringBuilder); + return new ProjectBuilder( + _testProjectFolders, + _featureFileGenerator, + _bindingsGeneratorFactory, + _configurationGeneratorFactory, + configuration, + _currentVersionDriver, + _folders, + _targetFrameworkMonikerStringBuilder, + _testRunConfiguration.SourceGenerator); } } } diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/FilesystemWriter/NewFormatProjectWriter.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/FilesystemWriter/NewFormatProjectWriter.cs index 4ee1c87cc..15e1d0737 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/FilesystemWriter/NewFormatProjectWriter.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/FilesystemWriter/NewFormatProjectWriter.cs @@ -165,6 +165,12 @@ private void WritePackageReference(XmlWriter xw, NuGetPackage nuGetPackage) xw.WriteAttributeString("Version", nuGetPackage.Version); } + if (nuGetPackage.IsDevelopmentDependency) + { + xw.WriteElementString("PrivateAssets", "all"); + xw.WriteElementString("IncludeAssets", "runtime; build; native; contentfiles; analyzers"); + } + xw.WriteEndElement(); } diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/PackagesConfigGenerator.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/PackagesConfigGenerator.cs index d0c4364d4..25f2e5f6f 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/PackagesConfigGenerator.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/PackagesConfigGenerator.cs @@ -38,6 +38,11 @@ public ProjectFile Generate(IEnumerable nuGetPackages, TargetFrame xw.WriteAttributeString("allowedVersions", package.AllowedVersions); } + if (package.IsDevelopmentDependency) + { + xw.WriteAttributeString("developmentDependency", "true"); + } + if (!(tfm is null)) { xw.WriteAttributeString("targetFramework", tfm); diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/ProjectBuilder.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/ProjectBuilder.cs index 0578b355a..a9b50eef2 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/ProjectBuilder.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/ProjectBuilder.cs @@ -37,7 +37,8 @@ public ProjectBuilder( Configuration configuration, CurrentVersionDriver currentVersionDriver, Folders folders, - TargetFrameworkMonikerStringBuilder targetFrameworkMonikerStringBuilder) + TargetFrameworkMonikerStringBuilder targetFrameworkMonikerStringBuilder, + SourceGeneratorPlatform sourceGenerator) { _testProjectFolders = testProjectFolders; _featureFileGenerator = featureFileGenerator; @@ -47,12 +48,14 @@ public ProjectBuilder( _currentVersionDriver = currentVersionDriver; _folders = folders; _targetFrameworkMonikerStringBuilder = targetFrameworkMonikerStringBuilder; + SourceGenerator = sourceGenerator; var projectGuidString = $"{ProjectGuid:N}".Substring(24); ProjectName = $"TestProj_{projectGuidString}"; } public Guid ProjectGuid { get; } = Guid.NewGuid(); public Configuration Configuration { get; } + public SourceGeneratorPlatform SourceGenerator { get; } public string ProjectName { get; set; } public ProgrammingLanguage Language { get; set; } = ProgrammingLanguage.CSharp; public TargetFramework TargetFramework { get; set; } = TargetFramework.Netcoreapp31; @@ -256,11 +259,6 @@ private void EnsureProjectExists() break; } - if (IsReqnrollFeatureProject) - { - _project.AddNuGetPackage("Reqnroll.Tools.MsBuild.Generation", _currentVersionDriver.ReqnrollNuGetVersion); - } - switch (Configuration.UnitTestProvider) { case UnitTestProvider.MSTest: @@ -290,6 +288,11 @@ private void ConfigureNUnit() if (IsReqnrollFeatureProject) { + if (SourceGenerator == SourceGeneratorPlatform.Roslyn) + { + ConfigureFeatureSourceGenerator(); + } + _project.AddNuGetPackage("Reqnroll.NUnit", _currentVersionDriver.ReqnrollNuGetVersion, new NuGetPackageAssembly(GetReqnrollPublicAssemblyName("Reqnroll.NUnit.ReqnrollPlugin.dll"), "net462\\Reqnroll.NUnit.ReqnrollPlugin.dll")); Configuration.Plugins.Add(new ReqnrollPlugin("Reqnroll.NUnit", ReqnrollPluginType.Runtime)); @@ -325,12 +328,28 @@ private void ConfigureXUnit() if (IsReqnrollFeatureProject) { + if (SourceGenerator == SourceGeneratorPlatform.Roslyn) + { + ConfigureFeatureSourceGenerator(); + } + _project.AddNuGetPackage("Reqnroll.xUnit", _currentVersionDriver.ReqnrollNuGetVersion, new NuGetPackageAssembly(GetReqnrollPublicAssemblyName("Reqnroll.xUnit.ReqnrollPlugin.dll"), "net462\\Reqnroll.xUnit.ReqnrollPlugin.dll")); Configuration.Plugins.Add(new ReqnrollPlugin("Reqnroll.xUnit", ReqnrollPluginType.Runtime)); } } + private void ConfigureFeatureSourceGenerator() + { + _project.AddNuGetPackage( + "Reqnroll.FeatureSourceGenerator", + _currentVersionDriver.ReqnrollNuGetVersion, + isDevelopmentDependency: true, + assemblies: new NuGetPackageAssembly( + GetReqnrollPublicAssemblyName("Reqnroll.FeatureSourceGenerator.dll"), + "netstandard2.0\\Reqnroll.FeatureSourceGenerator.dll")); + } + private void ConfigureMSTest() { _project.AddNuGetPackage("MSTest.TestAdapter", MSTestPackageVersion); @@ -346,6 +365,11 @@ private void ConfigureMSTest() if (IsReqnrollFeatureProject) { + if (SourceGenerator == SourceGeneratorPlatform.Roslyn) + { + ConfigureFeatureSourceGenerator(); + } + _project.AddNuGetPackage("Reqnroll.MSTest", _currentVersionDriver.ReqnrollNuGetVersion, new NuGetPackageAssembly(GetReqnrollPublicAssemblyName("Reqnroll.MSTest.ReqnrollPlugin.dll"), "net462\\Reqnroll.MSTest.ReqnrollPlugin.dll")); Configuration.Plugins.Add(new ReqnrollPlugin("Reqnroll.MSTest", ReqnrollPluginType.Runtime)); diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/SourceGeneratorPlatform.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/SourceGeneratorPlatform.cs new file mode 100644 index 000000000..3366accab --- /dev/null +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/SourceGeneratorPlatform.cs @@ -0,0 +1,8 @@ +namespace Reqnroll.TestProjectGenerator +{ + public enum SourceGeneratorPlatform + { + MSBuild, + Roslyn + } +} \ No newline at end of file diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TRXParser.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TRXParser.cs index 02d2f2fe1..7e7fd7c13 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TRXParser.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TRXParser.cs @@ -154,7 +154,7 @@ private List GetTestResultsInternal(XElement testRunResultsElement, TestName = testNameAttribute.Value, Outcome = outcomeAttribute.Value, StdOut = stdOutElement?.Value, - Steps = steps, + Steps = steps.ToList(), ErrorMessage = errorMessage?.Value, InnerResults = GetTestResultsInternal(innerResultsElement, xmlns) }; @@ -162,11 +162,11 @@ private List GetTestResultsInternal(XElement testRunResultsElement, return testResults.ToList(); } - private static List ParseTestOutput(XElement stdOutElement) + private static IEnumerable ParseTestOutput(XElement stdOutElement) { if (stdOutElement == null) { - return null; + yield break; } var stdOutText = stdOutElement.Value; @@ -175,13 +175,32 @@ private static List ParseTestOutput(XElement stdOutElement) var primaryOutput = logLines.TakeWhile(line => line != "TestContext Messages:"); - var steps = primaryOutput - .Where(line => line.StartsWith("Given ") || - line.StartsWith("When ") || - line.StartsWith("Then ") || - line.StartsWith("And ")); + TestStepResult step = null; - return steps.Select(step => new TestStepResult { Step = step }).ToList(); + foreach (var line in primaryOutput) + { + if (line.StartsWith("Given ") || + line.StartsWith("When ") || + line.StartsWith("Then ") || + line.StartsWith("And ")) + { + if (step != null) + { + yield return step; + } + + step = new TestStepResult { Step = line }; + } + else + { + step?.Output.Add(line); + } + } + + if (step != null) + { + yield return step; + } } private int GetNUnitIgnoredCount(TestExecutionResult testExecutionResult) diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestExecutionResult.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestExecutionResult.cs index e8e8522f0..1c9680b29 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestExecutionResult.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestExecutionResult.cs @@ -46,5 +46,6 @@ public class TestStepResult public string Step { get; set; } public string Error { get; set; } public string Result { get; set; } + public List Output { get; } = []; } } \ No newline at end of file diff --git a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestRunConfiguration.cs b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestRunConfiguration.cs index f90f76e5a..0e24687d3 100644 --- a/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestRunConfiguration.cs +++ b/Tests/TestProjectGenerator/Reqnroll.TestProjectGenerator/TestRunConfiguration.cs @@ -13,7 +13,7 @@ public class TestRunConfiguration public string ReqnrollVersion { get; set; } public string ReqnrollNuGetVersion { get; set; } public ConfigurationFormat ConfigurationFormat { get; set; } - + public SourceGeneratorPlatform SourceGenerator { get; set; } public string ReqnrollAllowedNuGetVersion { get; set; } } } \ No newline at end of file