using System.Globalization; using System.Text; using System.Text.Json.Nodes; using w4c_workflows.Models.Nodes; namespace w4c_workflows.Services.Nodes.Interpolation; /// Raised when a parameter expression cannot be resolved. public sealed class NodeInterpolationException : Exception { public NodeInterpolationException(string expression, string reason) : base(reason) { Expression = expression; } public string Expression { get; } } /// /// Data a parameter expression can read from. Kept small and explicit so the /// resolver stays sandbox-safe (no reflection, no I/O, no arbitrary calls). /// public sealed record InterpolationScope { /// The item being mapped; exposed as $json / $item. public JsonObject? Item { get; init; } /// The node's own parameters; exposed as $parameter. public JsonObject? Parameters { get; init; } /// Workflow environment; exposed as $env. public JsonObject? Environment { get; init; } public int ItemIndex { get; init; } public int RunIndex { get; init; } /// Resolves items emitted by another node ($("Name") / $items("Name")). public Func>? NodeItems { get; init; } } /// /// Our parameter interpolation engine. /// /// Rules: /// - A string is in expression mode when it starts with '=' or contains a /// {{ … }} block; otherwise it is a literal. /// - In expression mode, {{ … }} blocks are evaluated and any text /// around them stays literal. /// - When the whole value is a single block, the resolved value keeps its /// type (number, boolean, object, array). /// - When a block is embedded in surrounding text, the result is a string and /// non-string values are rendered (objects/arrays as JSON). /// - Objects and arrays are resolved recursively, so expressions nested in a /// collection field are applied per leaf. /// /// Grammar supported in increment 1: literals (string/number/bool/null) and /// $root accessor paths with .name, ["name"] and [index]. /// public sealed class NodeParameterInterpolator { private const char ExpressionPrefix = '='; public JsonObject ResolveObject(JsonObject parameters, InterpolationScope scope) { var result = new JsonObject(); foreach (var (name, value) in parameters) result[name] = ResolveNode(value, scope); return result; } public JsonNode? ResolveNode(JsonNode? value, InterpolationScope scope) => value switch { null => null, JsonObject obj => ResolveObject(obj, scope), JsonArray array => ResolveArray(array, scope), JsonValue jsonValue when jsonValue.TryGetValue(out var text) => ResolveString(text, scope), JsonValue jsonValue => jsonValue.DeepClone(), _ => value.DeepClone(), }; private JsonArray ResolveArray(JsonArray array, InterpolationScope scope) { var result = new JsonArray(); foreach (var element in array) result.Add(ResolveNode(element, scope)); return result; } /// Resolves a single string value according to the rules above. public JsonNode? ResolveString(string text, InterpolationScope scope) { if (text.Length == 0) return JsonValue.Create(text); string body; if (text[0] == ExpressionPrefix) { body = text[1..]; } else { if (!text.Contains("{{", StringComparison.Ordinal)) return JsonValue.Create(text); // no delimiters → literal body = text; } var blocks = ExtractBlocks(body); if (blocks.Count == 0) return JsonValue.Create(body); // '=' with no delimiters → literal text if (blocks.Count == 1 && blocks[0].Start == 0 && blocks[0].End == body.Length) { // Whole-value expression: preserve the resolved type. var resolved = Evaluate(blocks[0].Expression, scope); return resolved?.DeepClone(); } var builder = new StringBuilder(); var cursor = 0; foreach (var block in blocks) { if (block.Start > cursor) builder.Append(body, cursor, block.Start - cursor); builder.Append(Render(Evaluate(block.Expression, scope))); cursor = block.End; } if (cursor < body.Length) builder.Append(body, cursor, body.Length - cursor); return JsonValue.Create(builder.ToString()); } // ---------------------------------------------------------------- expressions private JsonNode? Evaluate(string expression, InterpolationScope scope) { var expr = expression.Trim(); if (expr.Length == 0) throw new NodeInterpolationException(expression, "empty expression"); if (expr[0] == '$') return EvaluateAccessor(expr, scope); return ParseLiteral(expr, expression); } private JsonNode? EvaluateAccessor(string expr, InterpolationScope scope) { // Node references: $("Name"), $node("Name"), $items("Name"). if (TryReadNodeReference(expr, out var nodeName, out var wantsArray, out var tail)) { var items = scope.NodeItems?.Invoke(nodeName) ?? Array.Empty(); JsonNode? node = wantsArray ? new JsonArray(items.Select(i => (JsonNode?)i.Json.DeepClone()).ToArray()) : items.Count > 0 ? items[0].Json.DeepClone() : null; return tail.Length == 0 ? node : Navigate(node, tail, expr); } // Root token: $ + identifier. var end = 1; while (end < expr.Length && (char.IsLetterOrDigit(expr[end]) || expr[end] == '_')) end++; var root = expr[..end]; var path = expr[end..]; JsonNode? start = root switch { "$json" or "$item" => scope.Item?.DeepClone(), "$parameter" => scope.Parameters?.DeepClone(), "$env" => scope.Environment?.DeepClone(), "$itemIndex" => JsonValue.Create(scope.ItemIndex), "$runIndex" => JsonValue.Create(scope.RunIndex), "$now" => JsonValue.Create(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)), "$today" => JsonValue.Create(DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), _ => throw new NodeInterpolationException(expr, $"unknown accessor '{root}'"), }; return path.Length == 0 ? start : Navigate(start, path, expr); } private static bool TryReadNodeReference(string expr, out string nodeName, out bool wantsArray, out string tail) { nodeName = string.Empty; wantsArray = false; tail = string.Empty; string? prefix = null; if (expr.StartsWith("$items(", StringComparison.Ordinal)) { prefix = "$items("; wantsArray = true; } else if (expr.StartsWith("$node(", StringComparison.Ordinal)) { prefix = "$node("; } else if (expr.StartsWith("$(", StringComparison.Ordinal)) { prefix = "$("; } if (prefix == null) return false; var close = expr.IndexOf(')', prefix.Length); if (close < 0) throw new NodeInterpolationException(expr, "unterminated node reference"); var inner = expr[prefix.Length..close].Trim(); if (inner.Length >= 2 && ((inner[0] == '"' && inner[^1] == '"') || (inner[0] == '\'' && inner[^1] == '\''))) { inner = inner[1..^1]; } nodeName = inner; tail = expr[(close + 1)..]; return true; } private static JsonNode? Navigate(JsonNode? start, string path, string expression) { var cursor = 0; var current = start; while (cursor < path.Length) { var ch = path[cursor]; if (ch == '.') { cursor++; var name = ReadIdentifier(path, ref cursor); if (name.Length == 0) throw new NodeInterpolationException(expression, $"expected a field name after '.' at {cursor}"); current = current is JsonObject obj ? obj[name] : null; continue; } if (ch == '[') { cursor++; var close = path.IndexOf(']', cursor); if (close < 0) throw new NodeInterpolationException(expression, "unterminated '[' in path"); var token = path[cursor..close].Trim(); cursor = close + 1; if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0]) { var key = token[1..^1]; current = current is JsonObject obj ? obj[key] : null; } else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) { current = current is JsonArray array && index >= 0 && index < array.Count ? array[index] : null; } else { throw new NodeInterpolationException(expression, $"invalid array index '{token}'"); } continue; } throw new NodeInterpolationException(expression, $"unexpected '{ch}' in path"); } return current; } 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]; } private static JsonNode? ParseLiteral(string literal, string expression) { if (literal is "true" or "false") return JsonValue.Create(literal == "true"); if (literal is "null") return null; if (literal.Length >= 2 && ((literal[0] == '"' && literal[^1] == '"') || (literal[0] == '\'' && literal[^1] == '\''))) { return JsonValue.Create(literal[1..^1]); } if (decimal.TryParse(literal, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)) return JsonValue.Create(number); throw new NodeInterpolationException(expression, $"unsupported expression '{literal}'"); } private static string Render(JsonNode? node) => node switch { null => string.Empty, JsonValue value when value.TryGetValue(out var text) => text, JsonValue value => value.ToJsonString(), _ => node.ToJsonString(), }; // ------------------------------------------------------------------- scanning private readonly record struct Block(int Start, int End, string Expression); private static List ExtractBlocks(string body) { var blocks = new List(); var cursor = 0; while (cursor < body.Length) { var open = body.IndexOf("{{", cursor, StringComparison.Ordinal); if (open < 0) break; var close = body.IndexOf("}}", open + 2, StringComparison.Ordinal); if (close < 0) break; // unterminated block: treat the remainder as literal blocks.Add(new Block(open, close + 2, body[(open + 2)..close])); cursor = close + 2; } return blocks; } }