diff --git a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs index b5ed211..f322fd2 100644 --- a/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/Declarations/DefineMethodAnalyzer.cs @@ -2,7 +2,7 @@ using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using TinyValidations.SourceGen.Analysis.Rules; +using TinyValidations.SourceGen.Analysis.RuleInvocations; using TinyValidations.SourceGen.Model; namespace TinyValidations.SourceGen.Analysis.Declarations diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs similarity index 81% rename from src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs rename to src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs index 72cf99b..de0ce42 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/AnalyzedMemberAccess.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/AnalyzedMemberAccess.cs @@ -1,4 +1,4 @@ -namespace TinyValidations.SourceGen.Analysis.Rules +namespace TinyValidations.SourceGen.Analysis.RuleInvocations { internal sealed class AnalyzedMemberAccess { diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs new file mode 100644 index 0000000..1957876 --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/CustomRuleAnalyzer.cs @@ -0,0 +1,109 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TinyValidations.SourceGen.Model; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal sealed class CustomRuleAnalyzer + { + public RuleAnalysisResult Analyze( + SemanticModel semanticModel, + SimpleNameSyntax methodName, + INamedTypeSymbol commandType) + { + if (!(methodName is GenericNameSyntax genericName)) + { + return RuleAnalysisIssue.InvalidCustomRule(methodName, methodName.ToString()); + } + + if (!HasSingleTypeArgument(genericName)) + { + return RuleAnalysisIssue.InvalidCustomRule(genericName, genericName.ToString()); + } + + var typeSyntax = genericName.TypeArgumentList.Arguments[0]; + var typeSymbol = semanticModel.GetTypeInfo(typeSyntax).Type; + if (!IsValidCustomRule(typeSymbol, commandType)) + { + return RuleAnalysisIssue.InvalidCustomRule(typeSyntax, typeSyntax.ToString()); + } + + var customRuleType = GetTypeName(typeSyntax, typeSymbol); + return CreateRule(customRuleType); + } + + private static RuleAnalysisResult CreateRule(string customRuleType) + { + return RuleAnalysisResult.ForRule(new RuleDefinition( + RuleKind.Use, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + customRuleType)); + } + + private static bool HasSingleTypeArgument(GenericNameSyntax genericName) + { + return genericName.TypeArgumentList.Arguments.Count == 1; + } + + private static string GetTypeName(TypeSyntax typeSyntax, ITypeSymbol? typeSymbol) + { + if (typeSymbol == null) + { + return typeSyntax.ToString(); + } + + return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + private static bool IsValidCustomRule(ITypeSymbol? typeSymbol, INamedTypeSymbol commandType) + { + if (!(typeSymbol is INamedTypeSymbol namedType)) + { + return false; + } + + foreach (var candidate in namedType.AllInterfaces) + { + if (IsAsyncValidationRule(candidate, commandType)) + { + return true; + } + } + + return false; + } + + private static bool IsAsyncValidationRule(INamedTypeSymbol candidate, INamedTypeSymbol commandType) + { + if (!IsTinyValidationsRule(candidate)) + { + return false; + } + + if (!HasSingleTypeArgument(candidate)) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(candidate.TypeArguments[0], commandType); + } + + private static bool IsTinyValidationsRule(INamedTypeSymbol candidate) + { + if (candidate.ContainingNamespace.ToDisplayString() != "TinyValidations") + { + return false; + } + + return candidate.Name == "IAsyncValidationRule"; + } + + private static bool HasSingleTypeArgument(INamedTypeSymbol candidate) + { + return candidate.TypeArguments.Length == 1; + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs similarity index 87% rename from src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs rename to src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs index 87a5982..71ce931 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/MemberAccessAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberAccessAnalyzer.cs @@ -1,11 +1,12 @@ using System.Collections.Generic; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace TinyValidations.SourceGen.Analysis.Rules +namespace TinyValidations.SourceGen.Analysis.RuleInvocations { internal sealed class MemberAccessAnalyzer { - public AnalyzedMemberAccess? Analyze(Microsoft.CodeAnalysis.CSharp.Syntax.ExpressionSyntax expression) + public AnalyzedMemberAccess? Analyze(ExpressionSyntax expression) { if (!(expression is LambdaExpressionSyntax lambda)) { @@ -58,10 +59,10 @@ private static bool HasSingleParameter(ParenthesizedLambdaExpressionSyntax lambd return lambda.ParameterList.Parameters.Count == 1; } - private static List ReadMembers(Microsoft.CodeAnalysis.SyntaxNode body, string parameterName) + private static List ReadMembers(SyntaxNode body, string parameterName) { var members = new List(); - ExpressionSyntax? current = body as Microsoft.CodeAnalysis.CSharp.Syntax.ExpressionSyntax; + ExpressionSyntax? current = body as ExpressionSyntax; while (current is MemberAccessExpressionSyntax memberAccess) { diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs new file mode 100644 index 0000000..eeb9d33 --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/MemberRuleAnalyzer.cs @@ -0,0 +1,109 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TinyValidations.SourceGen.Model; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal sealed class MemberRuleAnalyzer + { + private readonly MemberAccessAnalyzer _memberAccessAnalyzer = new MemberAccessAnalyzer(); + private readonly RuleArgumentAnalyzer _argumentAnalyzer = new RuleArgumentAnalyzer(); + + public RuleAnalysisResult Analyze(RuleKind kind, InvocationExpressionSyntax invocation) + { + if (!HasSelector(invocation)) + { + return RuleAnalysisIssue.UnsupportedSelector(invocation, invocation.ToString()); + } + + var selectorArgument = invocation.ArgumentList.Arguments[0]; + var member = AnalyzeSelector(selectorArgument); + if (member == null) + { + return RuleAnalysisIssue.UnsupportedSelector( + selectorArgument, + selectorArgument.Expression.ToString()); + } + + if (HasUnsupportedArgument(kind, invocation)) + { + return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); + } + + return CreateRule(kind, invocation, member); + } + + private RuleAnalysisResult CreateRule( + RuleKind kind, + InvocationExpressionSyntax invocation, + AnalyzedMemberAccess member) + { + var argument = _argumentAnalyzer.GetRuleArgument(kind, invocation); + var message = _argumentAnalyzer.GetMessage(kind, invocation); + + return RuleAnalysisResult.ForRule(new RuleDefinition( + kind, + member.Path, + member.Access, + argument, + message, + string.Empty)); + } + + private AnalyzedMemberAccess? AnalyzeSelector(ArgumentSyntax selectorArgument) + { + return _memberAccessAnalyzer.Analyze(selectorArgument.Expression); + } + + private static bool HasSelector(InvocationExpressionSyntax invocation) + { + return invocation.ArgumentList.Arguments.Count > 0; + } + + private static bool HasUnsupportedArgument(RuleKind kind, InvocationExpressionSyntax invocation) + { + if (HasUnsupportedValueArgument(kind, invocation)) + { + return true; + } + + return HasUnsupportedMessageArgument(kind, invocation); + } + + private static bool HasUnsupportedValueArgument(RuleKind kind, InvocationExpressionSyntax invocation) + { + var valueArgumentIndex = RuleShape.ValueArgumentIndex(kind); + if (valueArgumentIndex < 0) + { + return false; + } + + return !IsSupportedArgument(invocation, valueArgumentIndex); + } + + private static bool HasUnsupportedMessageArgument(RuleKind kind, InvocationExpressionSyntax invocation) + { + var messageArgumentIndex = RuleShape.MessageArgumentIndex(kind); + if (!HasArgument(invocation, messageArgumentIndex)) + { + return false; + } + + return !IsSupportedArgument(invocation, messageArgumentIndex); + } + + private static bool IsSupportedArgument(InvocationExpressionSyntax invocation, int argumentIndex) + { + if (!HasArgument(invocation, argumentIndex)) + { + return false; + } + + return invocation.ArgumentList.Arguments[argumentIndex].Expression is LiteralExpressionSyntax; + } + + private static bool HasArgument(InvocationExpressionSyntax invocation, int argumentIndex) + { + return invocation.ArgumentList.Arguments.Count > argumentIndex; + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs new file mode 100644 index 0000000..6260e1d --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RequiresRuleAnalyzer.cs @@ -0,0 +1,135 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TinyValidations.SourceGen.Model; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal sealed class RequiresRuleAnalyzer + { + private readonly MemberAccessAnalyzer _memberAccessAnalyzer = new MemberAccessAnalyzer(); + + public RuleAnalysisResult Analyze( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation) + { + if (invocation.ArgumentList.Arguments.Count < 3) + { + return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString()); + } + + var selectorArgument = invocation.ArgumentList.Arguments[0]; + var requirementArgument = invocation.ArgumentList.Arguments[1]; + var messageArgument = invocation.ArgumentList.Arguments[2]; + + var member = AnalyzeSelector(selectorArgument); + if (member == null) + { + return RuleAnalysisIssue.UnsupportedSelector( + selectorArgument, + selectorArgument.Expression.ToString()); + } + + if (!IsSupportedRequirementMethod(semanticModel, requirementArgument.Expression, out var requirementMethod)) + { + return RuleAnalysisIssue.UnsupportedArgument( + requirementArgument, + requirementArgument.Expression.ToString()); + } + + if (!IsSupportedMessage(messageArgument)) + { + return RuleAnalysisIssue.UnsupportedArgument( + messageArgument, + messageArgument.Expression.ToString()); + } + + return CreateRule(member, requirementMethod, messageArgument); + } + + private RuleAnalysisResult CreateRule( + AnalyzedMemberAccess member, + string requirementMethod, + ArgumentSyntax messageArgument) + { + var message = messageArgument.Expression.ToString(); + + return RuleAnalysisResult.ForRule(new RuleDefinition( + RuleKind.Requires, + member.Path, + member.Access, + string.Empty, + message, + string.Empty, + requirementMethod)); + } + + private AnalyzedMemberAccess? AnalyzeSelector(ArgumentSyntax selectorArgument) + { + return _memberAccessAnalyzer.Analyze(selectorArgument.Expression); + } + + private static bool IsSupportedMessage(ArgumentSyntax messageArgument) + { + if (!(messageArgument.Expression is LiteralExpressionSyntax)) + { + return false; + } + + return true; + } + + private static bool IsSupportedRequirementMethod( + SemanticModel semanticModel, + ExpressionSyntax expression, + out string requirementMethod) + { + requirementMethod = string.Empty; + + var symbolInfo = semanticModel.GetSymbolInfo(expression); + var symbol = symbolInfo.Symbol ?? GetSingleCandidate(symbolInfo); + if (!(symbol is IMethodSymbol method)) + { + return false; + } + + if (!method.IsStatic) + { + return false; + } + + if (method.TypeArguments.Length != 0) + { + return false; + } + + if (method.Parameters.Length != 1) + { + return false; + } + + if (method.ReturnType.SpecialType != SpecialType.System_Boolean) + { + return false; + } + + requirementMethod = GetRequirementMethodName(method); + return true; + } + + private static ISymbol? GetSingleCandidate(SymbolInfo symbolInfo) + { + if (symbolInfo.CandidateSymbols.Length != 1) + { + return null; + } + + return symbolInfo.CandidateSymbols[0]; + } + + private static string GetRequirementMethodName(IMethodSymbol method) + { + var containingType = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return containingType + "." + method.Name; + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleAnalysisIssue.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleAnalysisIssue.cs new file mode 100644 index 0000000..80aeb5b --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleAnalysisIssue.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis; +using TinyValidations.SourceGen.Model; +using TinyValidations.SourceGen.Validation; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal static class RuleAnalysisIssue + { + public static RuleAnalysisResult UnsupportedRuleCall(SyntaxNode syntax, string value) + { + return Create(ValidationDiagnostics.UnsupportedRuleCall, syntax, value); + } + + public static RuleAnalysisResult UnsupportedSelector(SyntaxNode syntax, string value) + { + return Create(ValidationDiagnostics.UnsupportedSelector, syntax, value); + } + + public static RuleAnalysisResult UnsupportedArgument(SyntaxNode syntax, string value) + { + return Create(ValidationDiagnostics.UnsupportedArgument, syntax, value); + } + + public static RuleAnalysisResult InvalidCustomRule(SyntaxNode syntax, string value) + { + return Create(ValidationDiagnostics.InvalidCustomRule, syntax, value); + } + + private static RuleAnalysisResult Create( + DiagnosticDescriptor descriptor, + SyntaxNode syntax, + string value) + { + return RuleAnalysisResult.ForIssue(new ValidationIssue( + descriptor, + syntax.GetLocation(), + value)); + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/RuleAnalysisResult.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleAnalysisResult.cs similarity index 91% rename from src/TinyValidations.SourceGen/Analysis/Rules/RuleAnalysisResult.cs rename to src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleAnalysisResult.cs index e04c614..64ebbf6 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/RuleAnalysisResult.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleAnalysisResult.cs @@ -1,6 +1,6 @@ using TinyValidations.SourceGen.Model; -namespace TinyValidations.SourceGen.Analysis.Rules +namespace TinyValidations.SourceGen.Analysis.RuleInvocations { internal sealed class RuleAnalysisResult { diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/RuleArgumentAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs similarity index 55% rename from src/TinyValidations.SourceGen/Analysis/Rules/RuleArgumentAnalyzer.cs rename to src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs index 3b75fac..85ccde2 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/RuleArgumentAnalyzer.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleArgumentAnalyzer.cs @@ -1,23 +1,23 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using TinyValidations.SourceGen.Model; -namespace TinyValidations.SourceGen.Analysis.Rules +namespace TinyValidations.SourceGen.Analysis.RuleInvocations { internal sealed class RuleArgumentAnalyzer { public string GetRuleArgument(RuleKind kind, InvocationExpressionSyntax invocation) { - if (!RequiresValueArgument(kind)) + if (!RuleShape.RequiresValueArgument(kind)) { return string.Empty; } - return GetArgument(invocation, 1); + return GetArgument(invocation, RuleShape.ValueArgumentIndex(kind)); } public string GetMessage(RuleKind kind, InvocationExpressionSyntax invocation) { - var messageIndex = GetMessageArgumentIndex(kind); + var messageIndex = RuleShape.MessageArgumentIndex(kind); return GetArgument(invocation, messageIndex); } @@ -31,30 +31,9 @@ private static string GetArgument(InvocationExpressionSyntax invocation, int arg return invocation.ArgumentList.Arguments[argumentIndex].Expression.ToString(); } - private static int GetMessageArgumentIndex(RuleKind kind) - { - if (RequiresValueArgument(kind)) - { - return 2; - } - - return 1; - } - private static bool HasArgument(InvocationExpressionSyntax invocation, int argumentIndex) { return invocation.ArgumentList.Arguments.Count > argumentIndex; } - - private static bool RequiresValueArgument(RuleKind kind) - { - return kind == RuleKind.TextLengthAtLeast - || kind == RuleKind.TextLengthAtMost - || kind == RuleKind.Above - || kind == RuleKind.AtLeast - || kind == RuleKind.Below - || kind == RuleKind.AtMost - || kind == RuleKind.Matches; - } } } diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs new file mode 100644 index 0000000..0ae29d3 --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleInvocationAnalyzer.cs @@ -0,0 +1,66 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TinyValidations.SourceGen.Model; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal sealed class RuleInvocationAnalyzer + { + private readonly ValidationRulesInvocationMatcher _invocationMatcher = new ValidationRulesInvocationMatcher(); + private readonly RuleMethodMap _methodMap = new RuleMethodMap(); + private readonly MemberRuleAnalyzer _memberRuleAnalyzer = new MemberRuleAnalyzer(); + private readonly CustomRuleAnalyzer _customRuleAnalyzer = new CustomRuleAnalyzer(); + private readonly RequiresRuleAnalyzer _requiresRuleAnalyzer = new RequiresRuleAnalyzer(); + + public RuleAnalysisResult? Analyze( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + INamedTypeSymbol validationRules, + INamedTypeSymbol commandType) + { + if (!(invocation.Expression is MemberAccessExpressionSyntax memberAccess)) + { + return null; + } + + if (!_invocationMatcher.IsMatch(semanticModel, memberAccess, invocation, validationRules)) + { + return null; + } + + var methodName = memberAccess.Name.Identifier.ValueText; + var kind = _methodMap.GetKind(methodName); + if (kind == null) + { + return RuleAnalysisIssue.UnsupportedRuleCall(memberAccess.Name, methodName); + } + + return AnalyzeKnownRule( + semanticModel, + invocation, + memberAccess, + commandType, + kind.Value); + } + + private RuleAnalysisResult AnalyzeKnownRule( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + MemberAccessExpressionSyntax memberAccess, + INamedTypeSymbol commandType, + RuleKind ruleKind) + { + if (ruleKind == RuleKind.Use) + { + return _customRuleAnalyzer.Analyze(semanticModel, memberAccess.Name, commandType); + } + + if (ruleKind == RuleKind.Requires) + { + return _requiresRuleAnalyzer.Analyze(semanticModel, invocation); + } + + return _memberRuleAnalyzer.Analyze(ruleKind, invocation); + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/RuleMethodMap.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleMethodMap.cs similarity index 94% rename from src/TinyValidations.SourceGen/Analysis/Rules/RuleMethodMap.cs rename to src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleMethodMap.cs index 0d14d7e..7a076d2 100644 --- a/src/TinyValidations.SourceGen/Analysis/Rules/RuleMethodMap.cs +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleMethodMap.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using TinyValidations.SourceGen.Model; -namespace TinyValidations.SourceGen.Analysis.Rules +namespace TinyValidations.SourceGen.Analysis.RuleInvocations { internal sealed class RuleMethodMap { diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs new file mode 100644 index 0000000..14cf6d2 --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/RuleShape.cs @@ -0,0 +1,38 @@ +using TinyValidations.SourceGen.Model; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal static class RuleShape + { + public static bool RequiresValueArgument(RuleKind kind) + { + return kind == RuleKind.TextLengthAtLeast + || kind == RuleKind.TextLengthAtMost + || kind == RuleKind.Above + || kind == RuleKind.AtLeast + || kind == RuleKind.Below + || kind == RuleKind.AtMost + || kind == RuleKind.Matches; + } + + public static int ValueArgumentIndex(RuleKind kind) + { + if (RequiresValueArgument(kind)) + { + return 1; + } + + return -1; + } + + public static int MessageArgumentIndex(RuleKind kind) + { + if (RequiresValueArgument(kind)) + { + return 2; + } + + return 1; + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/RuleInvocations/ValidationRulesInvocationMatcher.cs b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/ValidationRulesInvocationMatcher.cs new file mode 100644 index 0000000..1b97767 --- /dev/null +++ b/src/TinyValidations.SourceGen/Analysis/RuleInvocations/ValidationRulesInvocationMatcher.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace TinyValidations.SourceGen.Analysis.RuleInvocations +{ + internal sealed class ValidationRulesInvocationMatcher + { + public bool IsMatch( + SemanticModel semanticModel, + MemberAccessExpressionSyntax memberAccess, + InvocationExpressionSyntax invocation, + INamedTypeSymbol validationRules) + { + if (MatchesResolvedMethod(semanticModel, invocation, validationRules)) + { + return true; + } + + return MatchesMemberAccessExpression(semanticModel, memberAccess, validationRules); + } + + private static bool MatchesResolvedMethod( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + INamedTypeSymbol validationRules) + { + var symbol = semanticModel.GetSymbolInfo(invocation).Symbol; + if (!(symbol is IMethodSymbol method)) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(method.ContainingType.OriginalDefinition, validationRules); + } + + private static bool MatchesMemberAccessExpression( + SemanticModel semanticModel, + MemberAccessExpressionSyntax memberAccess, + INamedTypeSymbol validationRules) + { + var expressionType = semanticModel.GetTypeInfo(memberAccess.Expression).Type; + if (!(expressionType is INamedTypeSymbol namedType)) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, validationRules); + } + } +} diff --git a/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs b/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs deleted file mode 100644 index 1f64f56..0000000 --- a/src/TinyValidations.SourceGen/Analysis/Rules/RuleInvocationAnalyzer.cs +++ /dev/null @@ -1,388 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using TinyValidations.SourceGen.Model; -using TinyValidations.SourceGen.Validation; - -namespace TinyValidations.SourceGen.Analysis.Rules -{ - internal sealed class RuleInvocationAnalyzer - { - private readonly RuleMethodMap _methodMap = new RuleMethodMap(); - private readonly MemberAccessAnalyzer _memberAccessAnalyzer = new MemberAccessAnalyzer(); - private readonly RuleArgumentAnalyzer _argumentAnalyzer = new RuleArgumentAnalyzer(); - - public RuleAnalysisResult? Analyze( - SemanticModel semanticModel, - InvocationExpressionSyntax invocation, - INamedTypeSymbol validationRules, - INamedTypeSymbol commandType) - { - if (!(invocation.Expression is MemberAccessExpressionSyntax memberAccess)) - { - return null; - } - - if (!IsValidationRulesInvocation(semanticModel, memberAccess, invocation, validationRules)) - { - return null; - } - - var methodName = memberAccess.Name.Identifier.ValueText; - var kind = _methodMap.GetKind(methodName); - if (kind == null) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedRuleCall, - memberAccess.Name.GetLocation(), - methodName)); - } - - if (kind.Value == RuleKind.Use) - { - return AnalyzeCustomRule(semanticModel, invocation, memberAccess.Name, commandType); - } - - if (kind.Value == RuleKind.Requires) - { - return AnalyzeRequiresRule(semanticModel, invocation); - } - - if (invocation.ArgumentList.Arguments.Count == 0) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedSelector, - invocation.GetLocation(), - invocation.ToString())); - } - - var member = _memberAccessAnalyzer.Analyze(invocation.ArgumentList.Arguments[0].Expression); - if (member == null) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedSelector, - invocation.ArgumentList.Arguments[0].GetLocation(), - invocation.ArgumentList.Arguments[0].Expression.ToString())); - } - - if (HasUnsupportedArgument(kind.Value, invocation)) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedArgument, - invocation.GetLocation(), - invocation.ToString())); - } - - var argument = _argumentAnalyzer.GetRuleArgument(kind.Value, invocation); - var message = _argumentAnalyzer.GetMessage(kind.Value, invocation); - - return RuleAnalysisResult.ForRule(new RuleDefinition(kind.Value, member.Path, member.Access, argument, message, string.Empty)); - } - - private RuleAnalysisResult AnalyzeRequiresRule( - SemanticModel semanticModel, - InvocationExpressionSyntax invocation) - { - if (invocation.ArgumentList.Arguments.Count < 3) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedArgument, - invocation.GetLocation(), - invocation.ToString())); - } - - var member = _memberAccessAnalyzer.Analyze(invocation.ArgumentList.Arguments[0].Expression); - if (member == null) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedSelector, - invocation.ArgumentList.Arguments[0].GetLocation(), - invocation.ArgumentList.Arguments[0].Expression.ToString())); - } - - if (!IsSupportedRequirementMethod(semanticModel, invocation.ArgumentList.Arguments[1].Expression, out var requirementMethod)) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedArgument, - invocation.ArgumentList.Arguments[1].GetLocation(), - invocation.ArgumentList.Arguments[1].Expression.ToString())); - } - - if (!IsSupportedArgument(invocation, 2)) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.UnsupportedArgument, - invocation.ArgumentList.Arguments[2].GetLocation(), - invocation.ArgumentList.Arguments[2].Expression.ToString())); - } - - var message = invocation.ArgumentList.Arguments[2].Expression.ToString(); - - return RuleAnalysisResult.ForRule(new RuleDefinition( - RuleKind.Requires, - member.Path, - member.Access, - string.Empty, - message, - string.Empty, - requirementMethod)); - } - - private static RuleAnalysisResult AnalyzeCustomRule( - SemanticModel semanticModel, - InvocationExpressionSyntax invocation, - SimpleNameSyntax methodName, - INamedTypeSymbol commandType) - { - if (!(methodName is GenericNameSyntax genericName)) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.InvalidCustomRule, - methodName.GetLocation(), - methodName.ToString())); - } - - if (!HasSingleTypeArgument(genericName)) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.InvalidCustomRule, - genericName.GetLocation(), - genericName.ToString())); - } - - var typeSyntax = genericName.TypeArgumentList.Arguments[0]; - var typeSymbol = semanticModel.GetTypeInfo(typeSyntax).Type; - if (!IsValidCustomRule(typeSymbol, commandType)) - { - return RuleAnalysisResult.ForIssue(new ValidationIssue( - ValidationDiagnostics.InvalidCustomRule, - typeSyntax.GetLocation(), - typeSyntax.ToString())); - } - - var customRuleType = GetTypeName(typeSyntax, typeSymbol); - - return RuleAnalysisResult.ForRule(new RuleDefinition(RuleKind.Use, string.Empty, string.Empty, string.Empty, string.Empty, customRuleType)); - } - - private static bool HasSingleTypeArgument(GenericNameSyntax genericName) - { - return genericName.TypeArgumentList.Arguments.Count == 1; - } - - private static string GetTypeName(TypeSyntax typeSyntax, ITypeSymbol? typeSymbol) - { - if (typeSymbol == null) - { - return typeSyntax.ToString(); - } - - return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - - private static bool IsValidationRulesInvocation( - SemanticModel semanticModel, - MemberAccessExpressionSyntax memberAccess, - InvocationExpressionSyntax invocation, - INamedTypeSymbol validationRules) - { - var symbol = semanticModel.GetSymbolInfo(invocation).Symbol; - if (symbol is IMethodSymbol method) - { - return SymbolEqualityComparer.Default.Equals(method.ContainingType.OriginalDefinition, validationRules); - } - - var expressionType = semanticModel.GetTypeInfo(memberAccess.Expression).Type; - if (!(expressionType is INamedTypeSymbol namedType)) - { - return false; - } - - return SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, validationRules); - } - - private static bool HasUnsupportedArgument(RuleKind kind, InvocationExpressionSyntax invocation) - { - var valueArgumentIndex = GetValueArgumentIndex(kind); - if (valueArgumentIndex >= 0) - { - if (!IsSupportedArgument(invocation, valueArgumentIndex)) - { - return true; - } - } - - var messageArgumentIndex = GetMessageArgumentIndex(kind); - if (HasArgument(invocation, messageArgumentIndex)) - { - if (!IsSupportedArgument(invocation, messageArgumentIndex)) - { - return true; - } - } - - return false; - } - - private static int GetValueArgumentIndex(RuleKind kind) - { - if (RequiresValueArgument(kind)) - { - return 1; - } - - return -1; - } - - private static int GetMessageArgumentIndex(RuleKind kind) - { - if (RequiresValueArgument(kind)) - { - return 2; - } - - return 1; - } - - private static bool HasArgument(InvocationExpressionSyntax invocation, int argumentIndex) - { - return invocation.ArgumentList.Arguments.Count > argumentIndex; - } - - private static bool IsSupportedArgument(InvocationExpressionSyntax invocation, int argumentIndex) - { - if (!HasArgument(invocation, argumentIndex)) - { - return false; - } - - return invocation.ArgumentList.Arguments[argumentIndex].Expression is LiteralExpressionSyntax; - } - - private static bool IsSupportedRequirementMethod( - SemanticModel semanticModel, - ExpressionSyntax expression, - out string requirementMethod) - { - requirementMethod = string.Empty; - - var symbolInfo = semanticModel.GetSymbolInfo(expression); - var symbol = symbolInfo.Symbol ?? GetSingleCandidate(symbolInfo); - if (!(symbol is IMethodSymbol method)) - { - return false; - } - - if (!method.IsStatic) - { - return false; - } - - if (method.TypeArguments.Length != 0) - { - return false; - } - - if (method.Parameters.Length != 1) - { - return false; - } - - if (method.ReturnType.SpecialType != SpecialType.System_Boolean) - { - return false; - } - - requirementMethod = GetRequirementMethodName(method); - return true; - } - - private static ISymbol? GetSingleCandidate(SymbolInfo symbolInfo) - { - if (symbolInfo.CandidateSymbols.Length != 1) - { - return null; - } - - return symbolInfo.CandidateSymbols[0]; - } - - private static string GetRequirementMethodName(IMethodSymbol method) - { - var containingType = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return containingType + "." + method.Name; - } - - private static bool RequiresValueArgument(RuleKind kind) - { - if (kind == RuleKind.TextLengthAtLeast) - { - return true; - } - - if (kind == RuleKind.TextLengthAtMost) - { - return true; - } - - if (kind == RuleKind.Above) - { - return true; - } - - if (kind == RuleKind.AtLeast) - { - return true; - } - - if (kind == RuleKind.Below) - { - return true; - } - - if (kind == RuleKind.AtMost) - { - return true; - } - - return kind == RuleKind.Matches; - } - - private static bool IsValidCustomRule(ITypeSymbol? typeSymbol, INamedTypeSymbol commandType) - { - if (!(typeSymbol is INamedTypeSymbol namedType)) - { - return false; - } - - foreach (var candidate in namedType.AllInterfaces) - { - if (IsAsyncValidationRule(candidate, commandType)) - { - return true; - } - } - - return false; - } - - private static bool IsAsyncValidationRule(INamedTypeSymbol candidate, INamedTypeSymbol commandType) - { - if (candidate.ContainingNamespace.ToDisplayString() != "TinyValidations") - { - return false; - } - - if (candidate.Name != "IAsyncValidationRule") - { - return false; - } - - if (candidate.TypeArguments.Length != 1) - { - return false; - } - - return SymbolEqualityComparer.Default.Equals(candidate.TypeArguments[0], commandType); - } - } -} diff --git a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs index 26b8fc3..2289fd8 100644 --- a/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/DiscoveryTests.cs @@ -62,4 +62,38 @@ public sealed class CreateUser Assert.DoesNotContain("Email is required.", text); } + + [Fact] + public void Ignores_validation_rule_calls_outside_define_method() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.Email); + } + + public void Configure(ValidationRules rules) + { + rules.Required(x => x.DisplayName); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } + public string? DisplayName { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + Assert.Contains("Email is required.", text); + Assert.DoesNotContain("DisplayName is required.", text); + } } diff --git a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs index fb01a1c..b064e14 100644 --- a/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs +++ b/tests/TinyValidations.SourceGen.Tests/GenerationTests.cs @@ -79,6 +79,36 @@ public sealed class CreateUser Assert.Contains("Email is required.", text); } + [Fact] + public void Generates_all_rules_from_explicit_define_implementation() + { + var source = """ +using TinyValidations; + +public sealed class CreateUserValidation : IValidation +{ + void IValidation.Define(ValidationRules rules) + { + rules.Required(x => x.Email); + rules.TextLengthAtLeast(x => x.DisplayName, 2); + } +} + +public sealed class CreateUser +{ + public string? Email { get; init; } + public string? DisplayName { get; init; } +} +"""; + + var result = SourceGeneratorTestHost.Run(source); + var text = result.SingleGeneratedSource(); + + result.ShouldHaveNoDiagnostics(); + Assert.Contains("Email is required.", text); + Assert.Contains("DisplayName must contain at least 2 characters.", text); + } + [Fact] public void Generates_static_requires_rule_call() { diff --git a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs index 2c38342..d039d1e 100644 --- a/tests/TinyValidations.Tests/ValidationBehaviorTests.cs +++ b/tests/TinyValidations.Tests/ValidationBehaviorTests.cs @@ -26,6 +26,23 @@ public async Task Built_in_rules_return_validation_errors() AssertHasError(result, nameof(CreateProfile.Roles), "Roles must contain at least one item."); } + [Fact] + public async Task Built_in_rules_return_valid_result_when_values_satisfy_rules() + { + var validator = BuildValidator(); + var command = new CreateProfile( + "person@example.com", + "Valid Name", + 18, + "ABC", + new[] { "admin" }); + + var result = await validator.ValidateAsync(command); + + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + [Fact] public async Task Built_in_rules_use_custom_messages() { @@ -70,6 +87,50 @@ public async Task Remaining_built_in_rules_return_validation_errors() AssertHasError(result, nameof(ConfigureProduct.Rating), "Rating must be at most 5."); } + [Fact] + public async Task Remaining_built_in_rules_accept_boundary_values() + { + var validator = BuildValidator(); + var command = new ConfigureProduct( + "Product", + "Category", + "ABC", + 1, + 9, + 5); + + var result = await validator.ValidateAsync(command); + + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Fact] + public async Task Text_rules_reject_null_empty_and_whitespace_values() + { + var validator = BuildValidator(); + var command = new UpdateContact(null, string.Empty, " "); + + var result = await validator.ValidateAsync(command); + + Assert.False(result.IsValid); + AssertHasError(result, nameof(UpdateContact.RequiredEmail), "RequiredEmail is required."); + AssertHasError(result, nameof(UpdateContact.DisplayName), "DisplayName must contain text."); + AssertHasError(result, nameof(UpdateContact.Notes), "Notes must contain text."); + } + + [Fact] + public async Task Text_rules_accept_non_empty_text_values() + { + var validator = BuildValidator(); + var command = new UpdateContact("person@example.com", "Person", "Available"); + + var result = await validator.ValidateAsync(command); + + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + [Fact] public async Task Custom_rules_are_resolved_from_dependency_injection() { @@ -273,6 +334,22 @@ public void Define(ValidationRules rules) } } +public sealed record UpdateContact( + string? RequiredEmail, + string DisplayName, + string Notes); + +public sealed class UpdateContactValidation : IValidation +{ + public void Define(ValidationRules rules) + { + rules.Required(x => x.RequiredEmail); + rules.HasText(x => x.DisplayName); + rules.HasText(x => x.Notes); + rules.Email(x => x.RequiredEmail); + } +} + public sealed record CreateProfile( string Email, string DisplayName,