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]; } }