// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections;
using System.Linq.Expressions;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Geekeey.Request.Validation;
///
/// Represents a property path used by validation problems.
///
[JsonConverter(typeof(PropertyPathJsonConverter))]
public readonly struct PropertyPath : IEquatable, IEquatable, IReadOnlyList
{
///
/// Creates a new property path.
///
public PropertyPath(string value)
{
ArgumentNullException.ThrowIfNull(value);
Value = value;
}
///
/// Creates a property path from a lambda expression.
///
/// The type containing the property.
/// The expression representing the property access.
/// A new .
public static PropertyPath Of(Expression> expression)
{
ArgumentNullException.ThrowIfNull(expression);
return FromExpression(expression);
}
internal static PropertyPath FromExpression(Expression expression)
{
var segments = new List();
var current = expression;
while (current is not null)
{
switch (current)
{
case LambdaExpression expr:
current = expr.Body;
break;
case MemberExpression expr:
segments.Add(expr.Member.Name);
current = expr.Expression;
break;
case MethodCallExpression { Method.Name: "get_Item", Arguments.Count: 1 } expr:
segments.Add($"[{Evaluate(expr.Arguments[0])}]");
current = expr.Object;
break;
case BinaryExpression { NodeType: ExpressionType.ArrayIndex } expr:
segments.Add($"[{Evaluate(expr.Right)}]");
current = expr.Left;
break;
case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } expr:
current = expr.Operand;
break;
case ParameterExpression:
current = null;
break;
default:
throw new InvalidOperationException("Only simple member access expressions are supported.");
}
}
var builder = new StringBuilder();
for (var i = segments.Count - 1; i >= 0; i--)
{
var segment = segments[i];
if (builder.Length > 0 && !segment.StartsWith('['))
{
builder.Append('.');
}
builder.Append(segment);
}
return builder.ToString();
}
private static object? Evaluate(Expression expression)
{
if (expression is ConstantExpression constant)
{
return constant.Value;
}
if (expression is MemberExpression member && member.Expression is ConstantExpression closure)
{
var value = closure.Value;
if (member.Member is System.Reflection.FieldInfo field)
{
return field.GetValue(value);
}
if (member.Member is System.Reflection.PropertyInfo prop)
{
return prop.GetValue(value);
}
}
return Expression.Lambda(expression).Compile().DynamicInvoke();
}
///
/// Gets the raw property path.
///
public string Value { get; } = string.Empty;
///
/// Gets the number of path segments.
///
public int Count
{
get
{
var count = 0;
var enumerator = new Enumerator(Value);
while (enumerator.MoveNext())
{
count++;
}
return count;
}
}
///
/// Gets whether the property path contains no segments.
///
public bool IsEmpty => Value.Length is 0;
///
/// Gets the segment at the specified index.
///
public ReadOnlySpan this[int index]
{
get
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
var currentIndex = 0;
var enumerator = new Enumerator(Value);
while (enumerator.MoveNext())
{
if (currentIndex == index)
{
return enumerator.Current;
}
currentIndex++;
}
throw new ArgumentOutOfRangeException(nameof(index), index, "The segment index must refer to an existing property path segment.");
}
}
///
public override string ToString()
{
return Value;
}
///
/// Converts a string into a property path.
///
public static implicit operator PropertyPath(string value)
{
return new PropertyPath(value);
}
///
/// Converts a property path into its string representation.
///
public static implicit operator string(PropertyPath propertyPath)
{
return propertyPath.Value;
}
///
public bool Equals(PropertyPath other)
{
return string.Equals(Value, other.Value, StringComparison.Ordinal);
}
///
public bool Equals(string? other)
{
return string.Equals(Value, other, StringComparison.Ordinal);
}
///
public override bool Equals(object? obj)
{
return (obj is PropertyPath other && Equals(other)) || (obj is string s && Equals(s));
}
///
public override int GetHashCode()
{
return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
}
///
/// Compares two property paths for ordinal equality.
///
public static bool operator ==(PropertyPath left, PropertyPath right)
{
return left.Equals(right);
}
///
/// Compares two property paths for ordinal inequality.
///
public static bool operator !=(PropertyPath left, PropertyPath right)
{
return !left.Equals(right);
}
///
/// Combines two property paths using the same rules as nested validation paths.
///
public static PropertyPath operator +(PropertyPath prefix, PropertyPath suffix)
{
if (prefix.Value is not { Length: > 0 })
{
return suffix;
}
if (suffix.Value is not { Length: > 0 })
{
return prefix;
}
return $"{prefix}.{suffix}";
}
///
/// Appends a collection index to a property path.
///
public static PropertyPath operator +(PropertyPath propertyPath, int index)
{
return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]";
}
///
/// Returns an allocation-free enumerator over the path segments.
///
public Enumerator GetEnumerator()
{
return new Enumerator(Value);
}
///
string IReadOnlyList.this[int index] => this[index].ToString();
///
IEnumerator IEnumerable.GetEnumerator()
{
var enumerator = new Enumerator(Value);
List segments = [];
while (enumerator.MoveNext())
{
segments.Add(enumerator.Current.ToString());
}
return segments.GetEnumerator();
}
///
IEnumerator IEnumerable.GetEnumerator()
{
var enumerator = new Enumerator(Value);
List segments = [];
while (enumerator.MoveNext())
{
segments.Add(enumerator.Current.ToString());
}
return segments.GetEnumerator();
}
internal string ToJsonName(JsonNamingPolicy? namingPolicy)
{
var value = Value;
if (namingPolicy is null || value.Length is 0)
{
return value;
}
var builder = new StringBuilder(value.Length);
var first = true;
var enumerator = new Enumerator(value);
while (enumerator.MoveNext())
{
if (!first)
{
builder.Append('.');
}
if (enumerator.Current is { IsEmpty: false } segment)
{
if (segment.IndexOf('[') is >= 0 and var x)
{
builder.Append(namingPolicy.ConvertName(segment[..x].ToString()));
builder.Append(segment[x..]);
}
else
{
builder.Append(namingPolicy.ConvertName(segment.ToString()));
}
}
first = false;
}
return builder.ToString();
}
///
/// Enumerates path segments without allocations.
///
public ref struct Enumerator
{
private readonly ReadOnlySpan _value;
private int _nextStart;
internal Enumerator(ReadOnlySpan value)
{
_value = value;
_nextStart = value.Length is 0 ? -1 : 0;
Current = default;
}
///
/// Gets the current path segment.
///
public ReadOnlySpan Current { get; private set; }
///
/// Advances to the next path segment.
///
public bool MoveNext()
{
if (_nextStart < 0)
{
return false;
}
var start = _nextStart;
var bracketDepth = 0;
for (var i = start; i < _value.Length; i++)
{
switch (_value[i])
{
case '[':
bracketDepth++;
break;
case ']':
bracketDepth = int.Max(bracketDepth - 1, 0);
break;
case '.' when bracketDepth is 0:
Current = _value[start..i];
_nextStart = i + 1;
return true;
default:
break;
}
}
Current = _value[start..];
_nextStart = -1;
return true;
}
}
}
internal sealed class PropertyPathJsonConverter : JsonConverter
{
///
public override PropertyPath Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.String)
{
return new PropertyPath(reader.GetString()!);
}
throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}.");
}
///
public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy));
}
}