using System.Globalization;
using System.Text.Json.Nodes;
namespace w4c_workflows.Services.Nodes;
/// One step in a resolved item path: a property name or an array index.
internal readonly record struct NodePathSegment(string? Name, int Index)
{
public bool IsIndex => Name is null;
public static NodePathSegment Property(string name) => new(name, 0);
public static NodePathSegment ElementAt(int index) => new(null, index);
}
///
/// The single resolver for "@item" style paths, shared by the expression
/// interpolator and the executor field readers (sort / dedup / aggregate /
/// HTTP output paths). Supports .name, ["name"] and [index],
/// so items[0].id resolves everywhere; before the merge the executors'
/// resolver silently failed on array indices.
///
/// Parsing and navigation are separated so the interpolator can turn a parse
/// error into a typed exception while the executor readers treat it as a miss.
///
internal static class NodePath
{
///
/// Resolves a path against and returns the node, or
/// null when the path is empty, malformed or absent. Never throws.
/// false treats the whole path as one literal
/// property name (a field name that may itself contain dots).
///
public static JsonNode? Read(JsonNode? root, string? path, bool dotNotation)
{
if (string.IsNullOrEmpty(path))
return null;
if (!dotNotation)
return root is JsonObject literal ? Property(literal, path) : null;
return TryParse(path, out var segments, out _) ? Navigate(root, segments) : null;
}
///
/// Parses a path into segments. Returns false with a human-readable
/// when the syntax is invalid.
///
public static bool TryParse(string path, out IReadOnlyList segments, out string? error)
{
var parsed = new List();
var cursor = 0;
var first = true;
error = null;
while (cursor < path.Length)
{
var ch = path[cursor];
if (ch == '.')
{
cursor++;
var name = ReadIdentifier(path, ref cursor);
if (name.Length == 0)
{
error = $"expected a field name after '.' at {cursor}";
segments = Array.Empty();
return false;
}
parsed.Add(NodePathSegment.Property(name));
}
else if (ch == '[')
{
cursor++;
var close = path.IndexOf(']', cursor);
if (close < 0)
{
error = "unterminated '[' in path";
segments = Array.Empty();
return false;
}
var token = path[cursor..close].Trim();
cursor = close + 1;
if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0])
{
parsed.Add(NodePathSegment.Property(token[1..^1]));
}
else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index) && index >= 0)
{
parsed.Add(NodePathSegment.ElementAt(index));
}
else
{
error = $"invalid array index '{token}'";
segments = Array.Empty();
return false;
}
}
else if (first)
{
// A leading bare identifier: executor paths look like "user.name",
// not ".user.name" as the interpolator's tail does.
var name = ReadIdentifier(path, ref cursor);
if (name.Length == 0)
{
error = $"unexpected '{ch}' in path";
segments = Array.Empty();
return false;
}
parsed.Add(NodePathSegment.Property(name));
}
else
{
error = $"unexpected '{ch}' in path";
segments = Array.Empty();
return false;
}
first = false;
}
segments = parsed;
return true;
}
/// Walks parsed segments; a missing property or out-of-range index yields null.
public static JsonNode? Navigate(JsonNode? root, IReadOnlyList segments)
{
var current = root;
foreach (var segment in segments)
{
if (current == null)
return null;
current = segment.IsIndex
? current is JsonArray array && segment.Index < array.Count ? array[segment.Index] : null
: current is JsonObject obj ? Property(obj, segment.Name!) : null;
}
return current;
}
private static JsonNode? Property(JsonObject obj, string name)
=> obj.TryGetPropertyValue(name, out var value) ? value : null;
private static string ReadIdentifier(string path, ref int cursor)
{
var start = cursor;
while (cursor < path.Length && (char.IsLetterOrDigit(path[cursor]) || path[cursor] == '_' || path[cursor] == '-'))
cursor++;
return path[start..cursor];
}
}