diff --git a/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs b/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs new file mode 100644 index 0000000..e1188ee --- /dev/null +++ b/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using QueryByShape.Analyzer.Diagnostics; +using System; +using System.Collections.Immutable; +using System.Linq; + + +namespace QueryByShape.Analyzer.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ArgumentAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => [InvalidArgumentNameDiagnostic.Descriptor, DuplicateArgumentDiagnostic.Descriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.Attribute); + } + + private static void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + if (context.Node is not AttributeSyntax attributeSyntax) + { + return; + } + + var name = attributeSyntax.Name.ExtractName(); + + if (name is not "Argument" or nameof(ArgumentAttribute)) + { + return; + } + + var attributeNamedType = context.Compilation.ResolveNamedType(); + var attributes = context.ContainingSymbol!.GetAttributes(); + var activeAttribute = attributes.Single(a => a.ApplicationSyntaxReference?.SyntaxTree == attributeSyntax.SyntaxTree && a.ApplicationSyntaxReference?.Span == attributeSyntax.Span); + + if (activeAttribute.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) != true) + { + return; + } + + var activeName = activeAttribute.GetConstructorArgument(); + ReportInvalidName(activeName, context); + ReportDuplicateNames(activeName, activeAttribute, attributeNamedType, attributes, context); + + } + + private static void ReportInvalidName(string name, SyntaxNodeAnalysisContext context) + { + if (GraphQLHelpers.IsValidName(name.AsSpan(), out var problems) == false) + { + context.ReportDiagnostic( + InvalidArgumentNameDiagnostic.Create(name, [.. problems], context.Node.GetLocation()) + ); + } + } + + private static void ReportDuplicateNames(string name, AttributeData activeAttribute, INamedTypeSymbol attributeNamedType, ImmutableArray attributes, SyntaxNodeAnalysisContext context) + { + var dupes = attributes.Where(a => a.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) == true && a.GetConstructorArgument() == name); + + if (dupes.First() != activeAttribute) + { + context.ReportDiagnostic( + DuplicateArgumentDiagnostic.Create(name, activeAttribute.GetLocation()) + ); + } + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Analyzers/QueryAnalyzer.cs b/src/QueryByShape.Analyzer/Analyzers/QueryAnalyzer.cs new file mode 100644 index 0000000..7d68714 --- /dev/null +++ b/src/QueryByShape.Analyzer/Analyzers/QueryAnalyzer.cs @@ -0,0 +1,72 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using QueryByShape.Analyzer.Diagnostics; +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Runtime.InteropServices; +using System.Xml.Linq; + + +namespace QueryByShape.Analyzer.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class QueryAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => [InvalidOperationNameDiagnostic.Descriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.Attribute); + } + + private static void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + if (context.Node is not AttributeSyntax attributeSyntax) + { + return; + } + + var name = attributeSyntax.Name.ExtractName(); + + if (name is not "Query" or nameof(QueryAttribute)) + { + return; + } + + var attributeNamedType = context.Compilation.ResolveNamedType(); + var attributes = context.ContainingSymbol!.GetAttributes(); + var activeAttribute = attributes.Single(a => a.ApplicationSyntaxReference?.SyntaxTree == attributeSyntax.SyntaxTree && a.ApplicationSyntaxReference?.Span == attributeSyntax.Span); + + if (activeAttribute.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) != true) + { + return; + } + + ReportInvalidName(activeAttribute, context); + } + + private static void ReportInvalidName(AttributeData attribute, SyntaxNodeAnalysisContext context) + { + var arguments = attribute.NamedArguments.Where(n => n.Key == nameof(QueryAttribute.OperationName)).ToArray(); + + if (arguments.Length == 0) + { + return; + } + + var operationName = arguments[0].Value.Value as string; + + if (GraphQLHelpers.IsValidName(operationName.AsSpan(), out var problems) == false) + { + context.ReportDiagnostic( + InvalidOperationNameDiagnostic.Create(operationName!, [.. problems], context.Node.GetLocation()) + ); + } + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Analyzers/QueryDeclarationAnalyzer.cs b/src/QueryByShape.Analyzer/Analyzers/QueryDeclarationAnalyzer.cs new file mode 100644 index 0000000..2ddb121 --- /dev/null +++ b/src/QueryByShape.Analyzer/Analyzers/QueryDeclarationAnalyzer.cs @@ -0,0 +1,68 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using QueryByShape.Analyzer.Diagnostics; +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace QueryByShape.Analyzer.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class QueryDeclarationAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => [QueryMustImplementDiagnostic.Descriptor, QueryMustBePartialDiagnostic.Descriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.ClassDeclaration, SyntaxKind.StructDeclaration); + + } + + private void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + if (context.Node is not TypeDeclarationSyntax typeSyntax) + { + return; + } + + var attributeNamedType = context.Compilation.ResolveNamedType(); + var symbol = context.ContainingSymbol as INamedTypeSymbol; + var attributes = symbol!.GetAttributes(); + var isQuery = attributes.Any(a => a.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) == true); + + if (isQuery == false) + { + return; + } + + ReportNotImplementing(symbol, context); + ReportNotPartial(typeSyntax, symbol, context); + } + + private static void ReportNotImplementing(INamedTypeSymbol symbol, SyntaxNodeAnalysisContext context) + { + var interfaceType = context.Compilation.GetTypeByMetadataName("QueryByShape.IGeneratedQuery")!; + + if (interfaceType.IsAssignableFrom(symbol) == false) + { + context.ReportDiagnostic( + QueryMustImplementDiagnostic.Create(symbol.Name, symbol.Locations[0]) + ); + } + } + + private static void ReportNotPartial(TypeDeclarationSyntax typeSyntax, INamedTypeSymbol symbol, SyntaxNodeAnalysisContext context) + { + if (typeSyntax.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)) == false) + { + context.ReportDiagnostic( + QueryMustBePartialDiagnostic.Create(symbol.Name, symbol.Locations[0]) + ); + } + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs b/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs new file mode 100644 index 0000000..1dbc67f --- /dev/null +++ b/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs @@ -0,0 +1,86 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using QueryByShape.Analyzer.Diagnostics; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace QueryByShape.Analyzer.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class VariableAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => [InvalidVariableNameDiagnostic.Descriptor, DuplicateVariableDiagnostic.Descriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.Attribute); + } + + private static void AnalyzeNode(SyntaxNodeAnalysisContext context) + { + if (context.Node is not AttributeSyntax attributeSyntax) + { + return; + } + + var name = attributeSyntax.Name.ExtractName(); + + if (name is not "Variable" or nameof(VariableAttribute)) + { + return; + } + + var attributeNamedType = context.Compilation.ResolveNamedType(); + var attributes = context.ContainingSymbol!.GetAttributes(); + var activeAttribute = attributes.Single(a => a.ApplicationSyntaxReference?.SyntaxTree == attributeSyntax.SyntaxTree && a.ApplicationSyntaxReference?.Span == attributeSyntax.Span); + + if (activeAttribute.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) != true) + { + return; + } + + var activeName = activeAttribute.GetConstructorArgument(); + ReportInvalidName(activeName, context); + ReportDuplicateNames(activeName, activeAttribute, attributeNamedType, attributes, context); + } + + private static void ReportInvalidName(string name, SyntaxNodeAnalysisContext context) + { + var problems = new List(); + + if (name[0] != '$') + { + problems.Add("Must start with $"); + } + else + { + GraphQLHelpers.IsValidName(name.AsSpan()[1..], out problems); + } + + if (problems.Count > 0) + { + context.ReportDiagnostic( + InvalidVariableNameDiagnostic.Create(name, [.. problems], context.Node.GetLocation()) + ); + } + } + + private static void ReportDuplicateNames(string name, AttributeData activeAttribute, INamedTypeSymbol attributeNamedType, ImmutableArray attributes, SyntaxNodeAnalysisContext context) + { + var dupes = attributes.Where(a => a.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) == true && a.GetConstructorArgument() == name); + + if (dupes.First() != activeAttribute) + { + context.ReportDiagnostic( + DuplicateVariableDiagnostic.Create(name, activeAttribute.GetLocation()) + ); + } + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs b/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs index d5a9a16..43972a1 100644 --- a/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs +++ b/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs @@ -1,7 +1,10 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using System; +using System.Diagnostics; using System.Linq; using System.Text.Json.Serialization; +using System.Xml.Linq; namespace QueryByShape.Analyzer { @@ -13,6 +16,25 @@ public static string ToFullName(this AttributeData attribute) return $"{attributeClass.GetNamespace()}.{attributeClass.Name}"; } + public static string ExtractName(this NameSyntax nameSyntax) + { + return nameSyntax switch + { + SimpleNameSyntax ins => ins.Identifier.Text, + QualifiedNameSyntax qns => qns.Right.Identifier.Text, + _ => throw new NotSupportedException() + }; + } + + public static AttributeData? GetAttributeData(this AttributeSyntax syntax, ISymbol parentSymbol) + { + var parentAttributes = parentSymbol.GetAttributes(); + var syntaxRef = syntax.SyntaxTree; + var syntaxSpan = syntax.Span; + + return parentAttributes.FirstOrDefault(a => a.ApplicationSyntaxReference?.SyntaxTree == syntaxRef && a.ApplicationSyntaxReference?.Span == syntaxSpan); + } + public static Location GetLocation(this SyntaxReference? reference) { return reference?.SyntaxTree.GetLocation(reference.Span) ?? Location.None; @@ -23,6 +45,11 @@ public static Location GetLocation(this AttributeData attribute) return attribute.ApplicationSyntaxReference?.GetLocation() ?? Location.None; } + public static INamedTypeSymbol ResolveNamedType(this Compilation compilation) + { + return compilation.GetTypeByMetadataName(typeof(T).FullName)!; + } + public static string GetConstructorArgument(this AttributeData attribute) => GetConstructorArguments(attribute)[0]; public static string[] GetConstructorArguments(this AttributeData attribute) => attribute.ConstructorArguments.Select(c => c.Value?.ToString() ?? "").ToArray(); @@ -34,7 +61,7 @@ public static bool TryGetNamedArgument(this AttributeData attribute, string n if (arguments.Any()) { - value = (T)arguments.First().Value.Value; + value = (T?)arguments.First().Value.Value; return true; } @@ -45,7 +72,7 @@ public static string GetNamespace(this INamedTypeSymbol symbol) { return string.Join(".", GetNamespace_Internal(symbol.ContainingNamespace)); - string[] GetNamespace_Internal(INamespaceSymbol symbol, int index = 0) + static string[] GetNamespace_Internal(INamespaceSymbol symbol, int index = 0) { if (symbol.ContainingNamespace == null) { @@ -58,41 +85,71 @@ string[] GetNamespace_Internal(INamespaceSymbol symbol, int index = 0) } } - public static bool IsImplementing(this INamedTypeSymbol type, INamedTypeSymbol from) + public static bool TryGetCompatibleGenericBaseType(this ITypeSymbol type, INamedTypeSymbol? baseType, out INamedTypeSymbol? result) { - return type.IsGenericType && type.ConstructedFrom.Equals(from, SymbolEqualityComparer.Default); - } + result = null; - public static bool IsAssignableTo(this INamedTypeSymbol type, INamedTypeSymbol to) - { - if (to.TypeKind == TypeKind.Interface) + if (baseType is null) { - return type.IsImplementing(to) || type.BaseType?.AllInterfaces.Any(a => a.IsImplementing(to)) == true; + return false; + } + + if (baseType.TypeKind is TypeKind.Interface) + { + foreach (INamedTypeSymbol interfaceType in type.AllInterfaces) + { + if (IsMatchingGenericType(interfaceType, baseType)) + { + result = interfaceType; + return true; + } + } } - else + + for (INamedTypeSymbol? current = type as INamedTypeSymbol; current != null; current = current.BaseType) { - if (type.Equals(to, SymbolEqualityComparer.Default)) + if (IsMatchingGenericType(current, baseType)) { + result = current; return true; } + } - var baseType = to.BaseType; + return false; + static bool IsMatchingGenericType(INamedTypeSymbol candidate, INamedTypeSymbol baseType) + { + return candidate.IsGenericType && SymbolEqualityComparer.Default.Equals(candidate.ConstructedFrom, baseType); + } + } - while (baseType is not null) - { - if (type.Equals(baseType, SymbolEqualityComparer.Default)) - { - return true; - } - baseType = baseType.BaseType; + public static bool IsAssignableFrom(this ITypeSymbol? baseType, ITypeSymbol? type) + { + if (baseType is null || type is null) + { + return false; + } + + if (baseType.TypeKind is TypeKind.Interface) + { + if (type.AllInterfaces.Contains(baseType, SymbolEqualityComparer.Default)) + { + return true; } + } - return false; + for (INamedTypeSymbol? current = type as INamedTypeSymbol; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(baseType, current)) + { + return true; + } } + + return false; } - + public static Location ToTrimmedLocation(this Location location) => Location.Create(location.SourceTree!.FilePath, location.SourceSpan, location.GetLineSpan().Span); } } diff --git a/src/QueryByShape.Analyzer/Diagnostics/DuplicateArgumentDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/DuplicateArgumentDiagnostic.cs index 7163d63..7dd5317 100644 --- a/src/QueryByShape.Analyzer/Diagnostics/DuplicateArgumentDiagnostic.cs +++ b/src/QueryByShape.Analyzer/Diagnostics/DuplicateArgumentDiagnostic.cs @@ -11,9 +11,9 @@ internal record DuplicateArgumentDiagnostic messageFormat: "Argument names must be unique. '{0}' already exists." ); - public static DiagnosticMetadata Create(string argumentName, Location location) + public static Diagnostic Create(string argumentName, Location location) { - return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), new EquatableArray([argumentName])); + return Diagnostic.Create(Descriptor, location, [argumentName]); } } } \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Diagnostics/DuplicateVariableDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/DuplicateVariableDiagnostic.cs index a445cbe..8f6ca9d 100644 --- a/src/QueryByShape.Analyzer/Diagnostics/DuplicateVariableDiagnostic.cs +++ b/src/QueryByShape.Analyzer/Diagnostics/DuplicateVariableDiagnostic.cs @@ -10,9 +10,9 @@ internal record DuplicateVariableDiagnostic messageFormat: "Variable names must be unique. '{0}' already exists." ); - public static DiagnosticMetadata Create(string variableName, Location location) + public static Diagnostic Create(string variableName, Location location) { - return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), new EquatableArray([variableName])); + return Diagnostic.Create(Descriptor, location, [variableName]); } } } diff --git a/src/QueryByShape.Analyzer/Diagnostics/InvalidArgumentNameDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/InvalidArgumentNameDiagnostic.cs new file mode 100644 index 0000000..088c81a --- /dev/null +++ b/src/QueryByShape.Analyzer/Diagnostics/InvalidArgumentNameDiagnostic.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace QueryByShape.Analyzer.Diagnostics +{ + internal record InvalidArgumentNameDiagnostic + { + internal static DiagnosticDescriptor Descriptor { get; } = DescriptorHelper.Create( + id: 111, + title: "Invalid argumemnt name", + messageFormat: "Argument name '{0}' is not valid. {1}", + defaultSeverity: DiagnosticSeverity.Error + ); + + public static Diagnostic Create(string variableName, string[] reasons, Location location) + { + return Diagnostic.Create(Descriptor, location, [variableName, string.Join(". ", reasons)]); + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Diagnostics/InvalidOperationNameDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/InvalidOperationNameDiagnostic.cs new file mode 100644 index 0000000..2ace5d7 --- /dev/null +++ b/src/QueryByShape.Analyzer/Diagnostics/InvalidOperationNameDiagnostic.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace QueryByShape.Analyzer.Diagnostics +{ + internal record InvalidOperationNameDiagnostic + { + internal static DiagnosticDescriptor Descriptor { get; } = DescriptorHelper.Create( + id: 100, + title: "Invalid operation name", + messageFormat: "Operation name '{0}' is not valid. {1}", + defaultSeverity: DiagnosticSeverity.Error + ); + + public static Diagnostic Create(string variableName, string[] reasons, Location location) + { + return Diagnostic.Create(Descriptor, location, [variableName, string.Join(". ", reasons)]); + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Diagnostics/InvalidVariableNameDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/InvalidVariableNameDiagnostic.cs new file mode 100644 index 0000000..9a51f89 --- /dev/null +++ b/src/QueryByShape.Analyzer/Diagnostics/InvalidVariableNameDiagnostic.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; + +namespace QueryByShape.Analyzer.Diagnostics +{ + internal record InvalidVariableNameDiagnostic + { + internal static DiagnosticDescriptor Descriptor { get; } = DescriptorHelper.Create( + id: 141, + title: "Invalid variable name", + messageFormat: "Variable name '{0}' is not valid. {1}", + defaultSeverity: DiagnosticSeverity.Error + ); + + public static Diagnostic Create(string variableName, string[] reasons, Location location) + { + return Diagnostic.Create(Descriptor, location, [variableName, string.Join(". ", reasons)]); + } + } +} \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Diagnostics/MissingVariableDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/MissingVariableDiagnostic.cs index d01cc43..f30aa5d 100644 --- a/src/QueryByShape.Analyzer/Diagnostics/MissingVariableDiagnostic.cs +++ b/src/QueryByShape.Analyzer/Diagnostics/MissingVariableDiagnostic.cs @@ -10,9 +10,9 @@ internal record MissingVariableDiagnostic messageFormat: "The variable '{1}' was not found. (refenced in argument '{0}')" ); - public static DiagnosticMetadata Create(string argumentName, string variableName, Location location) + public static DiagnosticMetadata CreateMetadata(string argumentName, string variableName, Location location) { - return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), new EquatableArray([argumentName, variableName])); + return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), [argumentName, variableName]); } } } diff --git a/src/QueryByShape.Analyzer/Diagnostics/QueryMustBePartialDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/QueryMustBePartialDiagnostic.cs index ce01ccc..1bbb97d 100644 --- a/src/QueryByShape.Analyzer/Diagnostics/QueryMustBePartialDiagnostic.cs +++ b/src/QueryByShape.Analyzer/Diagnostics/QueryMustBePartialDiagnostic.cs @@ -10,9 +10,9 @@ internal record QueryMustBePartialDiagnostic messageFormat: "The class '{0}' decorated with the QueryAttribute must be partial" ); - public static DiagnosticMetadata Create(string className, Location location) + public static Diagnostic Create(string className, Location location) { - return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), new EquatableArray([className])); + return Diagnostic.Create(Descriptor, location, [className]); } } } diff --git a/src/QueryByShape.Analyzer/Diagnostics/QueryMustImplementDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/QueryMustImplementDiagnostic.cs new file mode 100644 index 0000000..ff75c89 --- /dev/null +++ b/src/QueryByShape.Analyzer/Diagnostics/QueryMustImplementDiagnostic.cs @@ -0,0 +1,18 @@ +using Microsoft.CodeAnalysis; + +namespace QueryByShape.Analyzer.Diagnostics +{ + internal record QueryMustImplementDiagnostic + { + internal static DiagnosticDescriptor Descriptor { get; } = DescriptorHelper.Create( + id: 101, + title: "Must implement IGeneratedQuery", + messageFormat: "The class '{0}' decorated with the QueryAttribute must implement IGeneratedQuery" + ); + + public static Diagnostic Create(string className, Location location) + { + return Diagnostic.Create(Descriptor, location, [className]); + } + } +} diff --git a/src/QueryByShape.Analyzer/Diagnostics/UnusedVariableDiagnostic.cs b/src/QueryByShape.Analyzer/Diagnostics/UnusedVariableDiagnostic.cs index ddbeaa5..794e1e4 100644 --- a/src/QueryByShape.Analyzer/Diagnostics/UnusedVariableDiagnostic.cs +++ b/src/QueryByShape.Analyzer/Diagnostics/UnusedVariableDiagnostic.cs @@ -11,9 +11,9 @@ internal record UnusedVariableDiagnostic defaultSeverity: DiagnosticSeverity.Warning ); - public static DiagnosticMetadata Create(string variableName, Location location) + public static DiagnosticMetadata CreateMetadata(string variableName, Location location) { - return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), new EquatableArray([variableName])); + return new DiagnosticMetadata(Descriptor, location.ToTrimmedLocation(), [variableName]); } } } diff --git a/src/QueryByShape.Analyzer/EnumerableExtensions.cs b/src/QueryByShape.Analyzer/EnumerableExtensions.cs index 397f59c..9c2ff04 100644 --- a/src/QueryByShape.Analyzer/EnumerableExtensions.cs +++ b/src/QueryByShape.Analyzer/EnumerableExtensions.cs @@ -1,20 +1,13 @@ -using System; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; namespace QueryByShape.Analyzer { public static class EnumerableExtensions { - internal static bool TrySingle(this IEnumerable items, Func predicate, out T? result) - { - result = items.SingleOrDefault(predicate); - return result is not null; - } - - internal static EquatableArray? ToEquatableArray(this ICollection list) where T: IEquatable => - list.Count > 0 ? new EquatableArray(list.ToArray()) : null; - internal static void Deconstruct(this IList list, out T first, out T second) { first = list.Count > 0 ? list[0] : throw new IndexOutOfRangeException(); diff --git a/src/QueryByShape.Analyzer/EquatableArray.cs b/src/QueryByShape.Analyzer/EquatableArray.cs index c850ba8..3ab4c10 100644 --- a/src/QueryByShape.Analyzer/EquatableArray.cs +++ b/src/QueryByShape.Analyzer/EquatableArray.cs @@ -2,33 +2,32 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; namespace QueryByShape.Analyzer { - internal readonly struct EquatableArray : IEquatable>, IEnumerable where T : IEquatable + internal static class EquatableArrayBuilder { - public static readonly EquatableArray Empty = new(Array.Empty()); + public static EquatableArray Create(ReadOnlySpan values) where T : IEquatable => new(values.ToArray()); + } - /// - /// The underlying array. - /// - private readonly T[]? _array; + /// + /// Creates a new instance. + /// + /// The input to wrap. + [CollectionBuilder(typeof(EquatableArrayBuilder), nameof(EquatableArrayBuilder.Create))] + internal readonly struct EquatableArray(T[] array) : IEquatable>, IEnumerable where T : IEquatable + { + public static readonly EquatableArray Empty = new([]); /// - /// Creates a new instance. + /// The underlying array. /// - /// The input to wrap. - public EquatableArray(T[] array) - { - _array = array; - } - + private readonly T[]? _array = array; + /// public bool Equals(EquatableArray array) { - // return ref Dictionary.CollectionsMarshalHelper - - return AsSpan().SequenceEqual(array.AsSpan()); } @@ -73,23 +72,17 @@ public ReadOnlySpan AsSpan() /// IEnumerator IEnumerable.GetEnumerator() { - return ((IEnumerable)(_array ?? Array.Empty())).GetEnumerator(); + return ((IEnumerable)(_array ?? [])).GetEnumerator(); } /// IEnumerator IEnumerable.GetEnumerator() { - return ((IEnumerable)(_array ?? Array.Empty())).GetEnumerator(); + return ((IEnumerable)(_array ?? [])).GetEnumerator(); } public int Count => _array?.Length ?? 0; - /// - /// Checks whether two values are the same. - /// - /// The first value. - /// The second value. - /// Whether and are equal. public static bool operator ==(EquatableArray left, EquatableArray right) { return left.Equals(right); diff --git a/src/QueryByShape.Analyzer/GraphQLHelpers.cs b/src/QueryByShape.Analyzer/GraphQLHelpers.cs new file mode 100644 index 0000000..a4b281f --- /dev/null +++ b/src/QueryByShape.Analyzer/GraphQLHelpers.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace QueryByShape.Analyzer +{ + internal static class GraphQLHelpers + { + public static bool IsValidName(ReadOnlySpan name, out List errors) + { + errors = []; + + if (name.Length == 0) + { + errors.Add("Name may not be empty"); + return false; + } + + if (char.IsDigit(name[0])) + { + errors.Add("First character of name may not be numeric"); + } + + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (char.IsLetterOrDigit(c) == false && c is not '_') + { + errors.Add("Must only contain alphanumeric characters or undercores"); + return false; + } + } + + return errors.Count == 0; + } + } +} diff --git a/src/QueryByShape.Analyzer/NamedTypeSymbols.cs b/src/QueryByShape.Analyzer/NamedTypeSymbols.cs index 66b1f4e..7ed1858 100644 --- a/src/QueryByShape.Analyzer/NamedTypeSymbols.cs +++ b/src/QueryByShape.Analyzer/NamedTypeSymbols.cs @@ -101,9 +101,9 @@ field.DeclaredAccessibility is Accessibility.Public public bool IsTypeSerializable(INamedTypeSymbol type) { return ( - type.IsAssignableTo(Delegate) - || type.IsAssignableTo(MemberInfo) - || type.IsAssignableTo(IDictionaryOfKV) + Delegate.IsAssignableFrom(type) + || MemberInfo.IsAssignableFrom(type) + || IDictionaryOfKV.IsAssignableFrom(type) || type.Equals(IntPtr, SymbolEqualityComparer.Default) || type.Equals(UIntPtr, SymbolEqualityComparer.Default) ) is false; @@ -127,15 +127,11 @@ private bool TryGetChildrenType(ITypeSymbol type, out INamedTypeSymbol? children { return false; } - else if (namedTypeSymbol.IsImplementing(IEnumerableOfT)) + else if (namedTypeSymbol.TryGetCompatibleGenericBaseType(IEnumerableOfT, out var instance)) { - effectiveType = namedTypeSymbol!.TypeArguments[0] as INamedTypeSymbol; + effectiveType = instance?.TypeArguments[0] as INamedTypeSymbol; } - else if (namedTypeSymbol.AllInterfaces.TrySingle(s => s.IsImplementing(IEnumerableOfT), out var ienumerable)) - { - effectiveType = ienumerable!.TypeArguments[0] as INamedTypeSymbol; - } - + bool hasChildren = effectiveType == null || CanHaveChildren(effectiveType); childrenType = hasChildren ? (effectiveType ?? type) as INamedTypeSymbol : null; return hasChildren; diff --git a/src/QueryByShape.Analyzer/Parser/QueryParser.cs b/src/QueryByShape.Analyzer/Parser/QueryParser.cs index 49aa1db..e6a4b85 100644 --- a/src/QueryByShape.Analyzer/Parser/QueryParser.cs +++ b/src/QueryByShape.Analyzer/Parser/QueryParser.cs @@ -15,8 +15,8 @@ namespace QueryByShape.Analyzer { internal class QueryParser(NamedTypeSymbols symbols) { - private readonly List _diagnostics = new(); - Dictionary)> _typeCache = new(); + private readonly List _diagnostics = []; + private readonly Dictionary)> _typeCache = []; public static EquatableArray Process(ImmutableArray<(TypeDeclarationSyntax declaration, SemanticModel semanticModel)> contexts, NamedTypeSymbols symbols, CancellationToken cancellationToken) { @@ -36,7 +36,7 @@ public static EquatableArray Process(ImmutableArray<(TypeDeclaratio items[i] = parser.Parse(declaration, declaredSymbol); } - return new(items); + return [.. items]; } public ParseResult Parse(TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol declaredSymbol) @@ -45,26 +45,26 @@ public ParseResult Parse(TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol if (typeDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)) is false) { - _diagnostics.Add(QueryMustBePartialDiagnostic.Create(declaredSymbol.Name, typeDeclaration.GetLocation())); + //_diagnostics.Add(QueryMustBePartialDiagnostic.Create(declaredSymbol.Name, typeDeclaration.GetLocation())); } (query.Type, var queryArguments) = ParseTypeMetadata(declaredSymbol, query.Options); UpdateQueryFromAttributes(query, declaredSymbol.GetAttributes()); ValidateVariables(query.Variables, queryArguments); - return (query, new(_diagnostics.ToArray())); + return (query, [.. _diagnostics]); } public void ValidateVariables(IEnumerable? variables, IList arguments) { - var variableLookup = variables?.ToDictionary(v => v.Name) ?? new(); + var variableLookup = variables?.ToDictionary(v => v.Name) ?? []; var variableUsage = variableLookup.Keys.ToHashSet(); foreach (var argument in arguments) { if (variableLookup.ContainsKey(argument.VariableName) is false) { - _diagnostics.Add(MissingVariableDiagnostic.Create(argument.Name, argument.VariableName, argument.Reference.GetLocation())); + _diagnostics.Add(MissingVariableDiagnostic.CreateMetadata(argument.Name, argument.VariableName, argument.Reference.GetLocation())); } else if (variableUsage.Contains(argument.VariableName)) { @@ -74,14 +74,14 @@ public void ValidateVariables(IEnumerable? variables, IList attributes) { - Dictionary variables = new(); + Dictionary variables = []; foreach (var attribute in attributes) { @@ -112,11 +112,7 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray(nameof(VariableAttribute.DefaultValue), out var value) ? value : null; - if (variables.ContainsKey(variablName)) - { - _diagnostics.Add(DuplicateVariableDiagnostic.Create(variablName, attribute.GetLocation())); - } - else + if (variables.ContainsKey(variablName) == false) { variables.Add(variablName, new VariableMetadata(variablName, graphType, defaultValue, attribute.ApplicationSyntaxReference)); } @@ -125,7 +121,7 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray) ParseTypeMetadata(INamedTypeSymbol type, QueryOptions options) @@ -139,8 +135,8 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray members = new(); - List arguments = new(); + Dictionary members = []; + List arguments = []; while (current?.Name is not null or "Object" or "ValueType") { @@ -152,9 +148,11 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray m.IsSerializable && m.Ignore is not true).ToArray(); - var typeMetadata = new TypeMetadata(name, typeMembers.ToEquatableArray()); + var typeMetadata = new TypeMetadata(name, [.. typeMembers]); var result = (typeMetadata, arguments); _typeCache[name] = result; @@ -211,11 +209,7 @@ private void UpdateMemberFromAttributes(MemberMetadata metadata, ImmutableArray< case AttributeNames.ARGUMENT when isMemberFromBase is false: var (name, variableName) = attribute.GetConstructorArguments(); - if (arguments.ContainsKey(name)) - { - _diagnostics.Add(DuplicateArgumentDiagnostic.Create(name, attribute.GetLocation())); - } - else + if (arguments.ContainsKey(name) == false) { var argMetadata = new ArgumentMetadata(name, variableName, attribute.ApplicationSyntaxReference); arguments.Add(name, argMetadata); @@ -230,7 +224,7 @@ private void UpdateMemberFromAttributes(MemberMetadata metadata, ImmutableArray< if (isMemberFromBase is false) { - metadata.Arguments = arguments.Values.ToEquatableArray(); + metadata.Arguments = [.. arguments.Values]; } } } diff --git a/src/QueryByShape.GraphQLClient/GraphQLClientExtensions.cs b/src/QueryByShape.GraphQLClient/GraphQLClientExtensions.cs index 1bf26b0..864f897 100644 --- a/src/QueryByShape.GraphQLClient/GraphQLClientExtensions.cs +++ b/src/QueryByShape.GraphQLClient/GraphQLClientExtensions.cs @@ -10,7 +10,7 @@ public static class GraphQLClientExtensions { public static Task> SendQueryByAsync(this IGraphQLClient client, object? variables = null, CancellationToken cancellationToken = default) where T: IGeneratedQuery { - var request = T.ToGraphQLQuery() ?? throw new ArgumentNullException("GeneratedQuery is null"); + var request = T.ToGraphQLQuery() ?? throw new NullReferenceException("GeneratedQuery is null"); return client.SendQueryAsync(query:request, variables: variables, cancellationToken: cancellationToken); } } diff --git a/tests/QueryByShape.Analyzer.Tests/Analyzers/AnalyzerTest.cs b/tests/QueryByShape.Analyzer.Tests/Analyzers/AnalyzerTest.cs new file mode 100644 index 0000000..90d4f81 --- /dev/null +++ b/tests/QueryByShape.Analyzer.Tests/Analyzers/AnalyzerTest.cs @@ -0,0 +1,45 @@ +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Resources; +using System.Text.Json.Serialization; + +namespace QueryByShape.Analyzer.Tests.DiagnosticAnalyzers +{ + public class AnalyzerTest : CSharpAnalyzerTest where T : DiagnosticAnalyzer, new() + { + const string SOURCE_GEN = @"#nullable enable annotations + #nullable disable warnings + + // Suppress warnings about [Obsolete] member usage in generated code. + // #pragma warning disable CS0612, CS0618 + + namespace Tests + { + public partial class NameQuery + { + public static string? ToGraphQLQuery() => null; + } + } + "; + + public AnalyzerTest(string source, params DiagnosticResult[] expected) + { + CompilerDiagnostics = CompilerDiagnostics.Errors; + ReferenceAssemblies = ReferenceAssemblies.Net.Net80; + ExpectedDiagnostics.AddRange(expected); + + TestState.Sources.Add(("Test.cs", source)); + TestState.Sources.Add(("SourceGen.g.cs", SOURCE_GEN)); + TestState.AdditionalReferences.Add(typeof(QueryAttribute).Assembly.Location); + TestState.AdditionalReferences.Add(typeof(IGeneratedQuery).Assembly.Location); + TestState.AdditionalReferences.Add(typeof(JsonIgnoreAttribute).Assembly.Location); + } + + public static async Task VerifyAsync(string source, params DiagnosticResult[] expected) + { + var test = new AnalyzerTest(source, expected); + await test.RunAsync(); + } + } +} diff --git a/tests/QueryByShape.Analyzer.Tests/Analyzers/ArgumentAnalyzerTests.cs b/tests/QueryByShape.Analyzer.Tests/Analyzers/ArgumentAnalyzerTests.cs new file mode 100644 index 0000000..9dc5e63 --- /dev/null +++ b/tests/QueryByShape.Analyzer.Tests/Analyzers/ArgumentAnalyzerTests.cs @@ -0,0 +1,102 @@ +using QueryByShape.Analyzer.Diagnostics; +using QueryByShape.Analyzer.Analyzers; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using System.ComponentModel; + +namespace QueryByShape.Analyzer.Tests.DiagnosticAnalyzers; + +public class ArgumentAnalyzerTests +{ + [Fact] + public async Task InvalidArgumentNameDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [Query] + [Variable(""$id"", ""UUID!"")] + public partial class NameQuery : IGeneratedQuery + { + [{|#0:Argument(""1id"", ""$id"")|}] + [{|#1:Argument(""i-d"", ""$id"")|}] + [Argument(""id"", ""$id"")] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(InvalidArgumentNameDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("1id", "First character of name may not be numeric"), + + new DiagnosticResult(InvalidArgumentNameDiagnostic.Descriptor) + .WithLocation(1) + .WithArguments("i-d", "Must only contain alphanumeric characters or undercores") + ); + } + + [Fact] + public async Task DuplicateArgumentNameDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [Query] + [Variable(""$id"", ""UUID!"")] + public partial class NameQuery : IGeneratedQuery + { + [Argument(""id"", ""$id"")] + [{|#0:Argument(""id"", ""$id"")|}] + [{|#1:Argument(""id"", ""$id"")|}] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(DuplicateArgumentDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("id"), + + new DiagnosticResult(DuplicateArgumentDiagnostic.Descriptor) + .WithLocation(1) + .WithArguments("id") + ); + } +} \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/Analyzers/QueryAnalyzerTests.cs b/tests/QueryByShape.Analyzer.Tests/Analyzers/QueryAnalyzerTests.cs new file mode 100644 index 0000000..091e9e2 --- /dev/null +++ b/tests/QueryByShape.Analyzer.Tests/Analyzers/QueryAnalyzerTests.cs @@ -0,0 +1,53 @@ +using VerifyXunit; +using Xunit; +using QueryByShape.Analyzer.Diagnostics; +using QueryByShape.Analyzer.Analyzers; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace QueryByShape.Analyzer.Tests.DiagnosticAnalyzers; + +public class QueryAnalyzerTests +{ + [Fact] + public async Task InvalidOperationNameDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [{|#0:Query(OperationName = ""2_2"")|}] + [Variable(""$id"", ""UUID!"")] + public partial class NameQuery : IGeneratedQuery + { + [Argument(""id"", ""$id"")] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(InvalidOperationNameDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("2_2", "First character of name may not be numeric") + ); + } +} \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/Analyzers/QueryDeclarationAnalyzerTests.cs b/tests/QueryByShape.Analyzer.Tests/Analyzers/QueryDeclarationAnalyzerTests.cs new file mode 100644 index 0000000..4ec10fb --- /dev/null +++ b/tests/QueryByShape.Analyzer.Tests/Analyzers/QueryDeclarationAnalyzerTests.cs @@ -0,0 +1,97 @@ +using VerifyXunit; +using Xunit; +using QueryByShape.Analyzer.Diagnostics; +using QueryByShape.Analyzer.Analyzers; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace QueryByShape.Analyzer.Tests.DiagnosticAnalyzers; + +public class QueryDeclarationAnalyzerTests +{ + [Fact] + public async Task QueryMustBePartialDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [Query] + [Variable(""$id"", ""UUID!"")] + public class {|#0:NameQuery|} : IGeneratedQuery + { + [Argument(""id"", ""$id"")] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(QueryMustBePartialDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("NameQuery"), + DiagnosticResult.CompilerError("CS0260") + .WithLocation(0) + .WithArguments("NameQuery") + + ); + } + + [Fact] + public async Task QueryMustImplementDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [Query] + [Variable(""$id"", ""UUID!"")] + public partial class {|#0:NameQuery|} + { + [Argument(""id"", ""$id"")] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(QueryMustImplementDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("NameQuery") + ); + } +} \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/Analyzers/VariableAnalyzerTests.cs b/tests/QueryByShape.Analyzer.Tests/Analyzers/VariableAnalyzerTests.cs new file mode 100644 index 0000000..f8ab051 --- /dev/null +++ b/tests/QueryByShape.Analyzer.Tests/Analyzers/VariableAnalyzerTests.cs @@ -0,0 +1,107 @@ +using QueryByShape.Analyzer.Diagnostics; +using QueryByShape.Analyzer.Analyzers; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using System.ComponentModel; + +namespace QueryByShape.Analyzer.Tests.DiagnosticAnalyzers; + +public class VariableAnalyzerTests +{ + [Fact] + public async Task InvalidVariableNameDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [Query] + [Variable(""$id"", ""UUID!"")] + [{|#0:Variable(""id"", ""UUID!"")|}] + [{|#1:Variable(""$4id"", ""UUID!"")|}] + [{|#2:Variable(""$i%d"", ""UUID!"")|}] + public partial class NameQuery : IGeneratedQuery + { + [Argument(""id"", ""$id"")] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(InvalidVariableNameDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("id", "Must start with $"), + + new DiagnosticResult(InvalidVariableNameDiagnostic.Descriptor) + .WithLocation(1) + .WithArguments("$4id", "First character of name may not be numeric"), + + new DiagnosticResult(InvalidVariableNameDiagnostic.Descriptor) + .WithLocation(2) + .WithArguments("$i%d", "Must only contain alphanumeric characters or undercores") + ); + } + + [Fact] + public async Task DuplicateVariableNameDiagnosticTest() + { + var source = @" + using System; + using QueryByShape; + using System.Collections.Generic; + + namespace Tests + { + [Query] + [Variable(""$id"", ""UUID!"")] + [{|#0:Variable(""$id"", ""UUID!"")|}] + [{|#1:Variable(""$id"", ""UUID!"")|}] + public partial class NameQuery : IGeneratedQuery + { + [Argument(""id"", ""$id"")] + public List People { get; set; } + } + + public class Customer : Person + { + public Guid CustomerId { get; set; } + } + + public class Person + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string MiddleName { get; set; } + } + } + "; + + await AnalyzerTest.VerifyAsync( + source, + new DiagnosticResult(DuplicateVariableDiagnostic.Descriptor) + .WithLocation(0) + .WithArguments("$id"), + + new DiagnosticResult(DuplicateVariableDiagnostic.Descriptor) + .WithLocation(1) + .WithArguments("$id") + ); + } +} \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/QueryByShape.Analyzer.Tests.csproj b/tests/QueryByShape.Analyzer.Tests/QueryByShape.Analyzer.Tests.csproj index 2a157ee..dd1d8d7 100644 --- a/tests/QueryByShape.Analyzer.Tests/QueryByShape.Analyzer.Tests.csproj +++ b/tests/QueryByShape.Analyzer.Tests/QueryByShape.Analyzer.Tests.csproj @@ -16,7 +16,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + + + diff --git a/tests/QueryByShape.Analyzer.Tests/DiagnosticTests.cs b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/DiagnosticTests.cs similarity index 69% rename from tests/QueryByShape.Analyzer.Tests/DiagnosticTests.cs rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/DiagnosticTests.cs index b3af6c4..ac7132f 100644 --- a/tests/QueryByShape.Analyzer.Tests/DiagnosticTests.cs +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/DiagnosticTests.cs @@ -1,107 +1,9 @@ -using VerifyXunit; -using Xunit; -using QueryByShape; -using QueryByShape.Analyzer.Diagnostics; +using QueryByShape.Analyzer.Diagnostics; -namespace QueryByShape.Analyzer.Tests; +namespace QueryByShape.Analyzer.Tests.SourceGenerator; public class DiagnosticTests { - [Fact] - public void GeneratesPartialClassDiagnostic() - { - var source = @" - using QueryByShape; - using System.Collections.Generic; - - namespace Tests - { - [Query] - public class NameQuery : IGeneratedQuery - { - public List People { get; set; } - } - - public class Person - { - public string Name { get; set; } - } - } - "; - - TestHelper.VerifyDiagnostic(source, QueryMustBePartialDiagnostic.Descriptor); - } - - [Fact] - public void GeneratesDuplicateVariableDiagnostic() - { - var source = @" - using QueryByShape; - using System.Collections.Generic; - - namespace Tests - { - [Query] - [Variable(""$id"", ""UUID!"")] - [Variable(""$id"", ""UUID!"")] - public partial class NameQuery : IGeneratedQuery - { - [Argument(""id"", ""$id"")] - public List People { get; set; } - } - - public class Customer : Person - { - public Guid CustomerId { get; set; } - } - - public class Person - { - public string FirstName { get; set; } - public string LastName { get; set; } - public string MiddleName { get; set; } - } - } - "; - - TestHelper.VerifyDiagnostic(source, DuplicateVariableDiagnostic.Descriptor); - } - - [Fact] - public void GeneratesDuplicateArgumentDiagnostic() - { - var source = @" - using QueryByShape; - using System.Collections.Generic; - - namespace Tests - { - [Query] - [Variable(""$id"", ""UUID!"")] - public partial class NameQuery : IGeneratedQuery - { - [Argument(""id"", ""$id"")] - [Argument(""id"", ""$id"")] - public List People { get; set; } - } - - public class Customer : Person - { - public Guid CustomerId { get; set; } - } - - public class Person - { - public string FirstName { get; set; } - public string LastName { get; set; } - public string MiddleName { get; set; } - } - } - "; - - TestHelper.VerifyDiagnostic(source, DuplicateArgumentDiagnostic.Descriptor); - } - [Fact] public void GeneratesMissingVariableDiagnostic() { @@ -132,7 +34,7 @@ public class Person } "; - TestHelper.VerifyDiagnostic(source, MissingVariableDiagnostic.Descriptor); + TestHelper.VerifyGeneratorDiagnostic(source, MissingVariableDiagnostic.Descriptor); } [Fact] @@ -189,7 +91,7 @@ public class Person } "; - TestHelper.VerifyDiagnostic(source, MissingVariableDiagnostic.Descriptor); + TestHelper.VerifyGeneratorDiagnostic(source, MissingVariableDiagnostic.Descriptor); } [Fact] @@ -222,7 +124,7 @@ public class Person } "; - TestHelper.VerifyDiagnostic(source, UnusedVariableDiagnostic.Descriptor); + TestHelper.VerifyGeneratorDiagnostic(source, UnusedVariableDiagnostic.Descriptor); } [Fact] @@ -281,7 +183,7 @@ public class Person } "; - TestHelper.VerifyDiagnostic(source, UnusedVariableDiagnostic.Descriptor); + TestHelper.VerifyGeneratorDiagnostic(source, UnusedVariableDiagnostic.Descriptor); } [Fact] diff --git a/tests/QueryByShape.Analyzer.Tests/SnapshotTests.cs b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/SnapshotTests.cs similarity index 99% rename from tests/QueryByShape.Analyzer.Tests/SnapshotTests.cs rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/SnapshotTests.cs index 7f5d238..8831ea7 100644 --- a/tests/QueryByShape.Analyzer.Tests/SnapshotTests.cs +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/SnapshotTests.cs @@ -1,8 +1,7 @@ using VerifyXunit; using Xunit; -using QueryByShape; -namespace QueryByShape.Analyzer.Tests; +namespace QueryByShape.Analyzer.Tests.SourceGenerator; public class SnapshotTests { @@ -414,7 +413,8 @@ public class Person } [Fact] - public Task GeneratesMultipleQueriesWithDifferentOptions() { + public Task GeneratesMultipleQueriesWithDifferentOptions() + { var source = @" using QueryByShape; using System.Collections.Generic; diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesAlias.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesAlias.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesAlias.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesAlias.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.received.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.received.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.received.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.received.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedArguments.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesStructChildrenQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesStructChildrenQuery.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesStructChildrenQuery.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesStructChildrenQuery.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.SupportsQueryName.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.SupportsQueryName.verified.txt similarity index 100% rename from tests/QueryByShape.Analyzer.Tests/Snapshots/SnapshotTests.SupportsQueryName.verified.txt rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.SupportsQueryName.verified.txt diff --git a/tests/QueryByShape.Analyzer.Tests/TestHelper.cs b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/TestHelper.cs similarity index 79% rename from tests/QueryByShape.Analyzer.Tests/TestHelper.cs rename to tests/QueryByShape.Analyzer.Tests/SourceGenerator/TestHelper.cs index 80f87ed..6a858e9 100644 --- a/tests/QueryByShape.Analyzer.Tests/TestHelper.cs +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/TestHelper.cs @@ -1,10 +1,12 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using QueryByShape; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; using System.Collections.Immutable; using System.Text.Json.Serialization; -namespace QueryByShape.Analyzer.Tests; +namespace QueryByShape.Analyzer.Tests.SourceGenerator; public static class TestHelper { @@ -18,12 +20,12 @@ public static string GetGeneratorResult(string source, out ImmutableArray !assembly.IsDynamic) .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) .Cast() - .Concat(new [] { + .Concat(new[] { MetadataReference.CreateFromFile(typeof(IGeneratedQuery).Assembly.Location), MetadataReference.CreateFromFile(typeof(QueryAttribute).Assembly.Location), MetadataReference.CreateFromFile(typeof(JsonIgnoreAttribute).Assembly.Location), }); - + var compilation = CSharpCompilation.Create("SourceGeneratorTests", new[] { syntaxTree }, references, @@ -36,24 +38,24 @@ public static string GetGeneratorResult(string source, out ImmutableArray x.GeneratedSources).Select(x=> x.SourceText.ToString()).ToArray(); + var results = runResult.Results.SelectMany(x => x.GeneratedSources).Select(x => x.SourceText.ToString()).ToArray(); return string.Join('\n', results); } - public static void VerifyDiagnostic(string source, DiagnosticDescriptor expectedDescriptor) + public static void VerifyGeneratorDiagnostic(string source, DiagnosticDescriptor expectedDescriptor) { var result = GetGeneratorResult(source, out var diagnostics); Assert.Single(diagnostics); var actualDescriptor = diagnostics[0].Descriptor; - + Assert.Equivalent(expectedDescriptor, actualDescriptor); } public static Task VerifySnapshot(string source) { - return Verifier - .Verify(GetGeneratorResult(source)) + return + Verify(GetGeneratorResult(source)) .ScrubLinesContaining("") .UseDirectory("Snapshots"); }