Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ rules.TextLengthAtMost(x => x.Name, 100);

Null values are ignored by text length rules. Use `Required`, `HasText`, or `NotNull` when needed.

## Numeric And Comparable Rules
## Comparable Rules

```csharp
rules.Above(x => x.Age, 17);
Expand All @@ -81,6 +81,12 @@ rules.Below(x => x.Age, 130);
rules.AtMost(x => x.Age, 129);
```

Comparison rules support values that implement `IComparable<T>`. Generated code uses `Comparer<T>.Default`, so numeric values, dates, times, strings, and custom comparable value objects use their default ordering.

For strings, comparison is ordering comparison, not length validation. Use `TextLengthAtLeast`, `TextLengthAtMost`, `HasText`, or `Matches` for text-specific validation.

Nullable reference values are compared with the default comparer. For example, `AtLeast(x => x.Name, "M")` treats `null` as below `"M"`. Use `Required`, `HasText`, or `NotNull` when the value must be present.

## Matches

Validates a non-empty string against a regular expression pattern.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ namespace TinyValidations.SourceGen.Analysis.RuleInvocations
{
internal sealed class AnalyzedMemberAccess
{
public AnalyzedMemberAccess(string path, string access)
public AnalyzedMemberAccess(string path, string access, bool isNullSafe)
{
Path = path;
Access = access;
IsNullSafe = isNullSafe;
}

public string Path { get; }

public string Access { get; }

public bool IsNullSafe { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ public RuleAnalysisResult Analyze(
private static RuleAnalysisResult CreateRule(string customRuleType)
{
return RuleAnalysisResult.ForRule(new RuleDefinition(
RuleKind.Use,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
customRuleType));
kind: RuleKind.Use,
memberPath: string.Empty,
memberAccess: string.Empty,
argument: string.Empty,
argumentDisplay: string.Empty,
message: string.Empty,
customRuleType: customRuleType));
}

private static bool HasSingleTypeArgument(GenericNameSyntax genericName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal sealed class MemberAccessAnalyzer
}

var path = string.Join(".", members);
return new AnalyzedMemberAccess(path, CreateAccess(members));
return new AnalyzedMemberAccess(path, CreateAccess(members), members.Count > 1);
}

private static string GetParameterName(LambdaExpressionSyntax lambda)
Expand Down Expand Up @@ -67,7 +67,7 @@ private static List<string> ReadMembers(SyntaxNode body, string parameterName)
while (current is MemberAccessExpressionSyntax memberAccess)
{
members.Insert(0, memberAccess.Name.Identifier.ValueText);
current = memberAccess.Expression;
current = UnwrapNullForgivingExpression(memberAccess.Expression);
}

if (IsOriginalParameter(current, parameterName))
Expand All @@ -88,6 +88,16 @@ private static bool IsOriginalParameter(ExpressionSyntax? expression, string par
return identifier.Identifier.ValueText == parameterName;
}

private static ExpressionSyntax UnwrapNullForgivingExpression(ExpressionSyntax expression)
{
if (expression is PostfixUnaryExpressionSyntax postfix)
{
return postfix.Operand;
}

return expression;
}

private static string CreateAccess(List<string> members)
{
if (members.Count == 1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using TinyValidations.SourceGen.Model;

Expand All @@ -8,11 +11,14 @@ internal sealed class MemberRuleAnalyzer
private readonly MemberAccessAnalyzer _memberAccessAnalyzer = new MemberAccessAnalyzer();
private readonly RuleArgumentAnalyzer _argumentAnalyzer = new RuleArgumentAnalyzer();

public RuleAnalysisResult Analyze(RuleKind kind, InvocationExpressionSyntax invocation)
public RuleAnalysisResult Analyze(
SemanticModel semanticModel,
RuleKind kind,
InvocationExpressionSyntax invocation)
{
if (!HasSelector(invocation))
if (!HasSupportedArgumentCount(kind, invocation))
{
return RuleAnalysisIssue.UnsupportedSelector(invocation, invocation.ToString());
return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString());
}

var selectorArgument = invocation.ArgumentList.Arguments[0];
Expand All @@ -29,34 +35,46 @@ public RuleAnalysisResult Analyze(RuleKind kind, InvocationExpressionSyntax invo
return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString());
}

return CreateRule(kind, invocation, member);
if (HasInvalidRegexPattern(kind, invocation))
{
return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString());
}

return CreateRule(semanticModel, kind, invocation, member);
}

private RuleAnalysisResult CreateRule(
SemanticModel semanticModel,
RuleKind kind,
InvocationExpressionSyntax invocation,
AnalyzedMemberAccess member)
{
var argument = _argumentAnalyzer.GetRuleArgument(kind, invocation);
var argumentDisplay = _argumentAnalyzer.GetRuleArgumentDisplay(kind, invocation);
var message = _argumentAnalyzer.GetMessage(kind, invocation);
var comparisonTypeName = GetComparisonTypeName(semanticModel, kind, invocation, member);

return RuleAnalysisResult.ForRule(new RuleDefinition(
kind,
member.Path,
member.Access,
argument,
argumentDisplay,
message,
string.Empty));
string.Empty,
comparisonTypeName: comparisonTypeName));
}

private AnalyzedMemberAccess? AnalyzeSelector(ArgumentSyntax selectorArgument)
{
return _memberAccessAnalyzer.Analyze(selectorArgument.Expression);
}

private static bool HasSelector(InvocationExpressionSyntax invocation)
private static bool HasSupportedArgumentCount(RuleKind kind, InvocationExpressionSyntax invocation)
{
return invocation.ArgumentList.Arguments.Count > 0;
var count = invocation.ArgumentList.Arguments.Count;
return count >= RuleShape.MinimumArgumentCount(kind)
&& count <= RuleShape.MaximumArgumentCount(kind);
}

private static bool HasUnsupportedArgument(RuleKind kind, InvocationExpressionSyntax invocation)
Expand Down Expand Up @@ -105,5 +123,108 @@ private static bool HasArgument(InvocationExpressionSyntax invocation, int argum
{
return invocation.ArgumentList.Arguments.Count > argumentIndex;
}

private static bool HasInvalidRegexPattern(RuleKind kind, InvocationExpressionSyntax invocation)
{
if (kind != RuleKind.Matches)
{
return false;
}

var argumentIndex = RuleShape.ValueArgumentIndex(kind);
if (!HasArgument(invocation, argumentIndex))
{
return true;
}

var expression = invocation.ArgumentList.Arguments[argumentIndex].Expression;
if (!(expression is LiteralExpressionSyntax literal))
{
return true;
}

try
{
_ = new Regex(literal.Token.ValueText);
return false;
}
catch (ArgumentException)
{
return true;
}
}

private static string GetComparisonTypeName(
SemanticModel semanticModel,
RuleKind kind,
InvocationExpressionSyntax invocation,
AnalyzedMemberAccess member)
{
if (!IsComparableRule(kind))
{
return string.Empty;
}

var symbol = semanticModel.GetSymbolInfo(invocation).Symbol;
if (!(symbol is IMethodSymbol method))
{
return string.Empty;
}

if (method.TypeArguments.Length != 1)
{
return string.Empty;
}

var typeArgument = method.TypeArguments[0];
if (RequiresNullableComparisonType(typeArgument, member))
{
return "global::System.Nullable<" + typeArgument.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">";
}

return typeArgument.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}

private static bool RequiresNullableComparisonType(
ITypeSymbol type,
AnalyzedMemberAccess member)
{
if (!member.IsNullSafe)
{
return false;
}

if (type.IsReferenceType)
{
return false;
}

if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
{
return false;
}

return type.IsValueType;
}

private static bool IsComparableRule(RuleKind kind)
{
if (kind == RuleKind.Above)
{
return true;
}

if (kind == RuleKind.AtLeast)
{
return true;
}

if (kind == RuleKind.Below)
{
return true;
}

return kind == RuleKind.AtMost;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public RuleAnalysisResult Analyze(
SemanticModel semanticModel,
InvocationExpressionSyntax invocation)
{
if (invocation.ArgumentList.Arguments.Count < 3)
if (!HasSupportedArgumentCount(invocation))
{
return RuleAnalysisIssue.UnsupportedArgument(invocation, invocation.ToString());
}
Expand Down Expand Up @@ -54,20 +54,26 @@ private RuleAnalysisResult CreateRule(
var message = messageArgument.Expression.ToString();

return RuleAnalysisResult.ForRule(new RuleDefinition(
RuleKind.Requires,
member.Path,
member.Access,
string.Empty,
message,
string.Empty,
requirementMethod));
kind: RuleKind.Requires,
memberPath: member.Path,
memberAccess: member.Access,
argument: string.Empty,
argumentDisplay: string.Empty,
message: message,
customRuleType: string.Empty,
requirementMethod: requirementMethod));
}

private AnalyzedMemberAccess? AnalyzeSelector(ArgumentSyntax selectorArgument)
{
return _memberAccessAnalyzer.Analyze(selectorArgument.Expression);
}

private static bool HasSupportedArgumentCount(InvocationExpressionSyntax invocation)
{
return invocation.ArgumentList.Arguments.Count == RuleShape.MinimumArgumentCount(RuleKind.Requires);
}

private static bool IsSupportedMessage(ArgumentSyntax messageArgument)
{
if (!(messageArgument.Expression is LiteralExpressionSyntax))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ public string GetMessage(RuleKind kind, InvocationExpressionSyntax invocation)
return GetArgument(invocation, messageIndex);
}

public string GetRuleArgumentDisplay(RuleKind kind, InvocationExpressionSyntax invocation)
{
if (!RuleShape.RequiresValueArgument(kind))
{
return string.Empty;
}

var argumentIndex = RuleShape.ValueArgumentIndex(kind);
if (!HasArgument(invocation, argumentIndex))
{
return string.Empty;
}

var expression = invocation.ArgumentList.Arguments[argumentIndex].Expression;
if (expression is LiteralExpressionSyntax literal)
{
return literal.Token.ValueText;
}

return expression.ToString();
}

private static string GetArgument(InvocationExpressionSyntax invocation, int argumentIndex)
{
if (!HasArgument(invocation, argumentIndex))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ private RuleAnalysisResult AnalyzeKnownRule(
return _requiresRuleAnalyzer.Analyze(semanticModel, invocation);
}

return _memberRuleAnalyzer.Analyze(ruleKind, invocation);
return _memberRuleAnalyzer.Analyze(semanticModel, ruleKind, invocation);
}
}
}
Loading
Loading