From c01308e19796d08bea2c27e1a37f0fac2c93a383 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Wed, 20 May 2026 20:02:44 +0200 Subject: [PATCH 1/4] Add comprehensive Bex workflow processing framework Introduces a suite of classes to handle Bex-based workflows. This includes support for parsing, evaluating, and managing Bex bindings, expressions, and execution contexts, as well as utilities for metrics tracking and compute step execution. --- src/main/java/blue/bex/compile/BexCompiler.java | 9 ++++++--- src/main/java/blue/bex/value/BexNodeWriter.java | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/blue/bex/compile/BexCompiler.java b/src/main/java/blue/bex/compile/BexCompiler.java index ee36232..36ba81a 100644 --- a/src/main/java/blue/bex/compile/BexCompiler.java +++ b/src/main/java/blue/bex/compile/BexCompiler.java @@ -185,8 +185,11 @@ private List compileStatements(FrozenNode node, CompileScope } private CompiledStatement compileStatement(FrozenNode statement, CompileScope scope, String pointer) { - if (statement == null || statement.getProperties() == null) { - throw new BexException("Statement must be an operator object"); + if (statement == null || statement.isEmptyNode()) { + return sourceStatement(currentFunction, pointer, "$return", new ReturnStatement(null)); + } + if (statement.getProperties() == null) { + throw new BexException("Statement must be an operator object at " + pointer); } int count = 0; String op = null; @@ -199,7 +202,7 @@ private CompiledStatement compileStatement(FrozenNode statement, CompileScope sc } } if (count != 1 || statement.getProperties().size() != 1) { - throw new BexException("Statement must have exactly one $ operator"); + throw new BexException("Statement must have exactly one $ operator at " + pointer); } String bodyPointer = pointer + "/" + escape(op); CompiledStatement compiled; diff --git a/src/main/java/blue/bex/value/BexNodeWriter.java b/src/main/java/blue/bex/value/BexNodeWriter.java index 2546d60..6c2371d 100644 --- a/src/main/java/blue/bex/value/BexNodeWriter.java +++ b/src/main/java/blue/bex/value/BexNodeWriter.java @@ -19,6 +19,9 @@ public static Node toNode(BexValue value) { if (value.isNull()) { return new Node(); } + if (value instanceof NodeBexValue || value instanceof FrozenNodeBexValue) { + return value.toNode(); + } if (value.isScalar()) { return value.toNode(); } From 490b8b7350b383c890197474b20e4db88b93cc8c Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Sat, 23 May 2026 01:38:54 +0200 Subject: [PATCH 2/4] Track compile and execution times in BexMetrics and integrate meaningful node checks in BexCompiler --- src/main/java/blue/bex/api/BexEngine.java | 14 ++++++++++- .../java/blue/bex/compile/BexCompiler.java | 24 ++++++++++++++----- src/main/java/blue/bex/result/BexMetrics.java | 8 +++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/main/java/blue/bex/api/BexEngine.java b/src/main/java/blue/bex/api/BexEngine.java index ab2d0e4..88dec7b 100644 --- a/src/main/java/blue/bex/api/BexEngine.java +++ b/src/main/java/blue/bex/api/BexEngine.java @@ -43,6 +43,8 @@ public BexCompiledProgram compile(BexProgramSource source) { } private BexCompiledProgram compile(BexProgramSource source, BexMetrics metrics) { + long start = System.nanoTime(); + try { BexCompiledProgramKey key = key(source); BexCompiledProgram cached = cache.get(key); if (cached != null) { @@ -53,6 +55,9 @@ private BexCompiledProgram compile(BexProgramSource source, BexMetrics metrics) BexCompiledProgram program = new BexCompiler(metrics).compile(source); cache.put(key, program); return program; + } finally { + metrics.addCompileNanos(System.nanoTime() - start); + } } public BexExecutionResult execute(BexCompiledProgram program, BexExecutionContext context) { @@ -63,8 +68,15 @@ public BexExecutionResult execute(BexCompiledProgram program, BexExecutionContex } private BexExecutionResult execute(BexCompiledProgram program, BexExecutionContext context, BexMetrics metrics) { + long start = System.nanoTime(); BexRuntime runtime = new BexRuntime(program, context, gasSchedule, metrics, pointerCache); - return runtime.execute(); + BexExecutionResult result = runtime.execute(); + metrics.addExecuteNanos(System.nanoTime() - start); + return new BexExecutionResult(result.value(), + result.changeset(), + result.events(), + result.gasUsed(), + metrics); } public BexExecutionResult compileAndExecute(BexProgramSource source, BexExecutionContext context) { diff --git a/src/main/java/blue/bex/compile/BexCompiler.java b/src/main/java/blue/bex/compile/BexCompiler.java index 36ba81a..1bfb52f 100644 --- a/src/main/java/blue/bex/compile/BexCompiler.java +++ b/src/main/java/blue/bex/compile/BexCompiler.java @@ -49,7 +49,8 @@ public BexCompiledProgram compile(blue.bex.api.BexProgramSource source) { compiledFunctions.put(name, compileFunction(name, functionNodes.get(name))); } - String entryName = source.entry().orElse(text(prop(step, "entry"))); + FrozenNode stepExpr = meaningful(prop(step, "expr")); + String entryName = source.entry().orElse(text(meaningful(prop(step, "entry")))); BexCompiledProgram.CompiledFunction root; int rootFrameSize = 0; if (entryName != null && !entryName.isEmpty()) { @@ -62,14 +63,14 @@ public BexCompiledProgram compile(blue.bex.api.BexProgramSource source) { new ReturnStatement(sourceExpr("$root", "/entry/$call", "$call", new CallExpr(entryName, new String[0], new CompiledExpression[0]))))), null, 0); - } else if (prop(step, "expr") != null) { + } else if (stepExpr != null) { currentFunction = "$root"; root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), - Collections.emptyList(), compileExpr(prop(step, "expr"), new CompileScope(), "/expr"), 0); + Collections.emptyList(), compileExpr(stepExpr, new CompileScope(), "/expr"), 0); } else { CompileScope scope = new CompileScope(); currentFunction = "$root"; - List statements = compileStatements(prop(step, "do"), scope, "/do"); + List statements = compileStatements(meaningful(prop(step, "do")), scope, "/do"); rootFrameSize = scope.frameSize(); root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), statements, null, rootFrameSize); } @@ -90,9 +91,10 @@ private BexCompiledProgram.CompiledFunction compileFunction(String name, FrozenN scope.declareOrGetSlot(arg); } } - CompiledExpression expression = prop(functionNode, "expr") != null ? compileExpr(prop(functionNode, "expr"), scope, "/functions/" + escape(name) + "/expr") : null; + FrozenNode functionExpr = meaningful(prop(functionNode, "expr")); + CompiledExpression expression = functionExpr != null ? compileExpr(functionExpr, scope, "/functions/" + escape(name) + "/expr") : null; List statements = expression == null - ? compileStatements(prop(functionNode, "do"), scope, "/functions/" + escape(name) + "/do") + ? compileStatements(meaningful(prop(functionNode, "do")), scope, "/functions/" + escape(name) + "/do") : Collections.emptyList(); currentFunction = previousFunction; return new BexCompiledProgram.CompiledFunction(name, Collections.unmodifiableList(args), @@ -467,6 +469,16 @@ private FrozenNode prop(FrozenNode node, String key) { return null; } + private FrozenNode meaningful(FrozenNode node) { + if (node == null) { + return null; + } + boolean hasPayload = node.getValue() != null + || (node.getItems() != null && !node.getItems().isEmpty()) + || (node.getProperties() != null && !node.getProperties().isEmpty()); + return hasPayload ? node : null; + } + private FrozenNode required(FrozenNode node, String label) { if (node == null) { throw new BexException("Missing required field: " + label); diff --git a/src/main/java/blue/bex/result/BexMetrics.java b/src/main/java/blue/bex/result/BexMetrics.java index 8d0ae76..b830323 100644 --- a/src/main/java/blue/bex/result/BexMetrics.java +++ b/src/main/java/blue/bex/result/BexMetrics.java @@ -41,6 +41,8 @@ public final class BexMetrics { private long sizeEstimateCacheMisses; private long frozenWriterNodeFallbacks; private long frozenWriterChildNodeRoundTrips; + private long compileNanos; + private long executeNanos; public BexMetrics copy() { BexMetrics copy = new BexMetrics(); @@ -77,6 +79,8 @@ public BexMetrics copy() { copy.sizeEstimateCacheMisses = sizeEstimateCacheMisses; copy.frozenWriterNodeFallbacks = frozenWriterNodeFallbacks; copy.frozenWriterChildNodeRoundTrips = frozenWriterChildNodeRoundTrips; + copy.compileNanos = compileNanos; + copy.executeNanos = executeNanos; return copy; } @@ -113,6 +117,8 @@ public BexMetrics copy() { public void incrementSizeEstimateCacheMisses() { sizeEstimateCacheMisses++; } public void incrementFrozenWriterNodeFallbacks() { frozenWriterNodeFallbacks++; } public void incrementFrozenWriterChildNodeRoundTrips() { frozenWriterChildNodeRoundTrips++; } + public void addCompileNanos(long nanos) { compileNanos += Math.max(0L, nanos); } + public void addExecuteNanos(long nanos) { executeNanos += Math.max(0L, nanos); } public long compiledExecutions() { return compiledExecutions; } public long compileCacheHits() { return compileCacheHits; } @@ -147,4 +153,6 @@ public BexMetrics copy() { public long sizeEstimateCacheMisses() { return sizeEstimateCacheMisses; } public long frozenWriterNodeFallbacks() { return frozenWriterNodeFallbacks; } public long frozenWriterChildNodeRoundTrips() { return frozenWriterChildNodeRoundTrips; } + public long compileNanos() { return compileNanos; } + public long executeNanos() { return executeNanos; } } From cd74f625a67ffc66fd09e37aa7a45f395e52ba0f Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Sat, 23 May 2026 04:03:33 +0200 Subject: [PATCH 3/4] Add Blue integration for Bex with type matching, node conversion, and tests --- .cz.toml | 2 +- README.md | 119 +++ src/main/java/blue/bex/api/BexEngine.java | 11 +- .../blue/bex/compile/BexCompiledProgram.java | 62 +- .../java/blue/bex/compile/BexCompiler.java | 344 ++++++- .../blue/bex/compile/BexContainsCache.java | 10 + src/main/java/blue/bex/compile/CallExpr.java | 23 +- .../bex/compile/TypeStringExpressions.java | 19 +- .../java/blue/bex/runtime/BexRuntime.java | 6 + .../blue/bex/type/BexBlueTypeMatcher.java | 34 + .../blue/bex/value/BexBlueNodeWriter.java | 257 ++++++ .../java/blue/bex/value/BexFrozenWriter.java | 3 + .../java/blue/bex/value/BexNodeWriter.java | 31 +- src/main/java/blue/bex/value/MapBexValue.java | 11 +- .../java/blue/bex/BexBlueTypeSupportTest.java | 836 ++++++++++++++++++ .../blue/bex/BexEngineConformanceTest.java | 4 +- 16 files changed, 1665 insertions(+), 107 deletions(-) create mode 100644 src/main/java/blue/bex/type/BexBlueTypeMatcher.java create mode 100644 src/main/java/blue/bex/value/BexBlueNodeWriter.java create mode 100644 src/test/java/blue/bex/BexBlueTypeSupportTest.java diff --git a/.cz.toml b/.cz.toml index 967930d..24200c5 100644 --- a/.cz.toml +++ b/.cz.toml @@ -2,5 +2,5 @@ name = "cz_conventional_commits" tag_format = "v$version" version_scheme = "semver" -version = "0.1.0" +version = "0.2.0" update_changelog_on_bump = true diff --git a/README.md b/README.md index f916563..f980e14 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,102 @@ BexProgramSource source = BexProgramSource.withDefinition( ); ``` +## Function Arguments And Blue Patterns + +BEX function arguments may declare Blue type or shape patterns. BEX does not +have a separate type enum or type system; declared argument patterns are Blue +nodes and runtime values are checked with Blue's node/type matcher. + +```yaml +functions: + capture: + args: + amount: + type: Integer + hotelOrder: + blueId: HotelOrderBlueId + request: + customerName: + type: Text + schema: + required: true + nights: + type: Integer + schema: + required: true + expr: + amount: + $var: amount + order: + $var: hotelOrder +``` + +All declared function arguments are required by the function call ABI for now. +Unknown functions, extra argument names, and missing declared arguments fail at +compile time. Typed arguments are checked after their call expressions are +evaluated; a runtime mismatch throws `BexException`. + +Text `"400"` does not match the Blue `Integer` pattern. Use an explicit +conversion when conversion is intended: + +```yaml +$call: + function: capture + args: + amount: + $integer: + $event: /message/request/amount +``` + +Untyped required arguments remain supported by declaring an empty pattern: + +```yaml +args: + input: {} +``` + +When BEX converts computed values back to Blue nodes for `$is`, function +argument checks, or output conversion, Blue language keys keep their Blue +meaning. For example, this computed value is a node with a `type` field and a +`status` property, not an object with an ordinary property named `type`: + +```yaml +type: + blueId: HotelOrderType +status: confirmed +``` + +A bare `blueId` object is a Blue reference pattern: + +```yaml +blueId: HotelOrderType +``` + +Do not combine `blueId` with sibling fields to describe a typed instance. Use +`type: { blueId: ... }` for typed values. + +BEX programs are Blue documents, so BEX syntax must use valid Blue authoring +forms. For user-defined name containers such as `functions`, `constants`, +`args`, and `$call.args`, do not use Blue language keys as names. This includes +`value`, `items`, `blueId`, `type`, `schema`, `name`, `description`, +`itemType`, `keyType`, `valueType`, `mergePolicy`, `constraints`, `contracts`, +`properties`, `$previous`, and `$pos`. + +For operator bodies, payload/reference/control keys such as `value`, `items`, +`blueId`, `properties`, `$previous`, and `$pos` cannot be used as ordinary +multi-field operands. Use BEX operand names such as `node`, `list`, `input`, +`pattern`, `object`, `key`, `path`, `val`, `cond`, `then`, and `else`. + +Metadata keys such as `name`, `description`, `type`, `schema`, `itemType`, +`keyType`, and `valueType` are legal Blue language fields, but they are not +ordinary object properties. An operator may use one of them only when the BEX +compiler explicitly supports that field. + +Function argument patterns and `$is.pattern` are static Blue patterns. BEX does +not evaluate expressions inside those patterns, and it does not emulate Blue +authoring sugar such as inline `type: Integer` preprocessing for computed type +fields. + ## Document Views `BexDocumentView` owns document pointer resolution, canonical reads, resolved @@ -188,6 +284,7 @@ BEX operators are Blue objects whose single key starts with `$`. | Operator | Purpose | |---|---| | `$unwrap` | Unwrap Blue scalar wrapper values. | +| `$is` | Return whether a value matches a Blue pattern. | | `$text` | Convert to text. | | `$integer` | Convert to exact integer. | | `$number` | Convert to exact decimal/number. | @@ -195,6 +292,16 @@ BEX operators are Blue objects whose single key starts with `$`. | `$object` | Require an object, or default undefined to an empty object. | | `$list` | Require a list, or default undefined to an empty list. | +`$is.pattern` is static Blue pattern data, not a BEX expression: + +```yaml +$is: + node: + $event: /message/request/amount + pattern: + type: Integer +``` + ### Strings | Operator | Purpose | @@ -205,6 +312,14 @@ BEX operators are Blue objects whose single key starts with `$`. | `$startsWith` | Check a prefix. | | `$sliceAfter` | Return text after a prefix. | +```yaml +$join: + list: + - a + - b + separator: ":" +``` + ### Logic And Comparison | Operator | Purpose | @@ -254,6 +369,10 @@ BEX operators are Blue objects whose single key starts with `$`. | `$call` | Call a local function. | | `$literal` | Return payload without compiling nested operators. | +`$literal` prevents normal expression compilation, but BEX still rejects +BEX-looking operators inside Blue type-definition fields such as `type`, +`itemType`, `keyType`, `valueType`, `blue`, and `schema`. + ## Statement Operators | Statement | Purpose | diff --git a/src/main/java/blue/bex/api/BexEngine.java b/src/main/java/blue/bex/api/BexEngine.java index 88dec7b..d8e26ee 100644 --- a/src/main/java/blue/bex/api/BexEngine.java +++ b/src/main/java/blue/bex/api/BexEngine.java @@ -10,6 +10,7 @@ import blue.bex.result.BexExecutionResult; import blue.bex.result.BexMetrics; import blue.bex.runtime.BexRuntime; +import blue.language.Blue; /** * Public entry point for compiling and executing selected BEX programs. @@ -20,12 +21,14 @@ * emit events, or perform host actions.

*/ public final class BexEngine { + private final Blue blue; private final BexGasSchedule gasSchedule; private final BexCompiledProgramCache cache; private final BexMetricsSink metricsSink; private final BexPointerCache pointerCache = new BexPointerCache(); private BexEngine(Builder builder) { + this.blue = builder.blue; this.gasSchedule = builder.gasSchedule; this.cache = builder.cache; this.metricsSink = builder.metricsSink; @@ -69,7 +72,7 @@ public BexExecutionResult execute(BexCompiledProgram program, BexExecutionContex private BexExecutionResult execute(BexCompiledProgram program, BexExecutionContext context, BexMetrics metrics) { long start = System.nanoTime(); - BexRuntime runtime = new BexRuntime(program, context, gasSchedule, metrics, pointerCache); + BexRuntime runtime = new BexRuntime(program, context, blue, gasSchedule, metrics, pointerCache); BexExecutionResult result = runtime.execute(); metrics.addExecuteNanos(System.nanoTime() - start); return new BexExecutionResult(result.value(), @@ -92,10 +95,16 @@ private BexCompiledProgramKey key(BexProgramSource source) { } public static final class Builder { + private Blue blue = new Blue(); private BexGasSchedule gasSchedule = BexGasSchedule.defaults(); private BexCompiledProgramCache cache = new LruBexCompiledProgramCache(); private BexMetricsSink metricsSink = BexMetricsSink.NOOP; + public Builder blue(Blue blue) { + this.blue = blue != null ? blue : new Blue(); + return this; + } + public Builder gasSchedule(BexGasSchedule gasSchedule) { this.gasSchedule = gasSchedule; return this; diff --git a/src/main/java/blue/bex/compile/BexCompiledProgram.java b/src/main/java/blue/bex/compile/BexCompiledProgram.java index 0a5b0f8..4ee9062 100644 --- a/src/main/java/blue/bex/compile/BexCompiledProgram.java +++ b/src/main/java/blue/bex/compile/BexCompiledProgram.java @@ -1,5 +1,6 @@ package blue.bex.compile; +import blue.bex.BexException; import blue.bex.runtime.BexRuntime; import blue.bex.runtime.CompiledFrame; import blue.bex.runtime.CompiledStatement; @@ -7,7 +8,9 @@ import blue.bex.runtime.CompiledExpression; import blue.bex.value.BexValue; import blue.bex.value.BexValues; +import blue.language.snapshot.FrozenNode; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; @@ -55,35 +58,42 @@ public BexValue constant(String name) { */ public static final class CompiledFunction { private final String name; - private final List args; - private final Map argSlotByName; + private final List args; + private final Map argByName; + private final ArgSpec[] argBySlot; private final List statements; private final CompiledExpression expression; private final int frameSize; public CompiledFunction(String name, - List args, + List args, List statements, CompiledExpression expression, int frameSize) { this.name = name; - this.args = args; - LinkedHashMap argSlots = new LinkedHashMap<>(); - for (int i = 0; i < args.size(); i++) { - argSlots.put(args.get(i), i); + this.args = Collections.unmodifiableList(args); + LinkedHashMap argSpecs = new LinkedHashMap<>(); + this.argBySlot = new ArgSpec[frameSize]; + for (ArgSpec arg : args) { + argSpecs.put(arg.name(), arg); + if (arg.slot() >= 0 && arg.slot() < argBySlot.length) { + argBySlot[arg.slot()] = arg; + } } - this.argSlotByName = Collections.unmodifiableMap(argSlots); + this.argByName = Collections.unmodifiableMap(argSpecs); this.statements = statements; this.expression = expression; this.frameSize = frameSize; } public String name() { return name; } - public List args() { return args; } + public Collection args() { return args; } public int frameSize() { return frameSize; } + public boolean hasArg(String name) { return argByName.containsKey(name); } + public ArgSpec arg(String name) { return argByName.get(name); } public int argSlot(String name) { - Integer slot = argSlotByName.get(name); - return slot != null ? slot : -1; + ArgSpec arg = argByName.get(name); + return arg != null ? arg.slot() : -1; } public BexValue invokeRoot(BexRuntime runtime) { @@ -97,6 +107,14 @@ public BexValue invokePrepared(BexRuntime runtime, CompiledFrame parent, int[] s for (int i = 0; i < slots.length; i++) { if (slots[i] >= 0) { BexValue value = values[i]; + ArgSpec arg = slots[i] < argBySlot.length ? argBySlot[slots[i]] : null; + if (arg != null && arg.typed() + && !runtime.typeMatcher().matches(value, arg.pattern())) { + throw new BexException("Function " + name + + " argument " + arg.name() + + " does not match declared Blue pattern at " + + arg.sourcePointer()); + } frame.set(slots[i], value != null ? value : BexValues.undefined()); } } @@ -111,4 +129,26 @@ public BexValue invokePrepared(BexRuntime runtime, CompiledFrame parent, int[] s return runtime.defaultResultValue(); } } + + public static final class ArgSpec { + private final String name; + private final int slot; + private final FrozenNode pattern; + private final boolean typed; + private final String sourcePointer; + + public ArgSpec(String name, int slot, FrozenNode pattern, String sourcePointer) { + this.name = name; + this.slot = slot; + this.pattern = pattern; + this.typed = pattern != null && !pattern.isEmptyNode(); + this.sourcePointer = sourcePointer; + } + + public String name() { return name; } + public int slot() { return slot; } + public FrozenNode pattern() { return pattern; } + public boolean typed() { return typed; } + public String sourcePointer() { return sourcePointer; } + } } diff --git a/src/main/java/blue/bex/compile/BexCompiler.java b/src/main/java/blue/bex/compile/BexCompiler.java index 1bfb52f..f2ce447 100644 --- a/src/main/java/blue/bex/compile/BexCompiler.java +++ b/src/main/java/blue/bex/compile/BexCompiler.java @@ -8,6 +8,8 @@ import blue.bex.value.BexValue; import blue.bex.value.BexValues; import blue.bex.result.BexMetrics; +import blue.language.model.Node; +import blue.language.model.Schema; import blue.language.snapshot.FrozenNode; import java.util.ArrayDeque; @@ -23,8 +25,11 @@ * Compiler from frozen BEX Blue data to specialized runtime objects. */ public final class BexCompiler { + private static final Set RESERVED_BLUE_KEYS = reservedBlueKeys(); + private final BexContainsCache containsCache = new BexContainsCache(); private final BexMetrics metrics; + private Map functionSignatures = Collections.emptyMap(); private String currentFunction = "$root"; public BexCompiler(BexMetrics metrics) { @@ -43,10 +48,11 @@ public BexCompiledProgram compile(blue.bex.api.BexProgramSource source) { loadFunctions(functionNodes, prop(definition, "functions")); loadFunctions(functionNodes, prop(step, "functions")); rejectRecursion(functionNodes); + functionSignatures = compileFunctionSignatures(functionNodes); Map compiledFunctions = new LinkedHashMap<>(); for (String name : functionNodes.keySet()) { - compiledFunctions.put(name, compileFunction(name, functionNodes.get(name))); + compiledFunctions.put(name, compileFunction(name, functionNodes.get(name), functionSignatures.get(name))); } FrozenNode stepExpr = meaningful(prop(step, "expr")); @@ -58,37 +64,37 @@ public BexCompiledProgram compile(blue.bex.api.BexProgramSource source) { if (entry == null) { throw new BexException("Unknown entry function: " + entryName); } - root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), + if (!entry.args().isEmpty()) { + throw new BexException("Entry function " + entryName + " declares arguments but entry invocation provides none"); + } + root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), Collections.singletonList(sourceStatement("$root", "/entry", "$return", new ReturnStatement(sourceExpr("$root", "/entry/$call", "$call", - new CallExpr(entryName, new String[0], new CompiledExpression[0]))))), + new CallExpr(entryName, new int[0], new CompiledExpression[0]))))), null, 0); } else if (stepExpr != null) { currentFunction = "$root"; - root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), + root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), Collections.emptyList(), compileExpr(stepExpr, new CompileScope(), "/expr"), 0); } else { CompileScope scope = new CompileScope(); currentFunction = "$root"; List statements = compileStatements(meaningful(prop(step, "do")), scope, "/do"); rootFrameSize = scope.frameSize(); - root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), statements, null, rootFrameSize); + root = new BexCompiledProgram.CompiledFunction("$root", Collections.emptyList(), statements, null, rootFrameSize); } return new BexCompiledProgram(root, compiledFunctions, constants, rootFrameSize, step.blueId()); } - private BexCompiledProgram.CompiledFunction compileFunction(String name, FrozenNode functionNode) { + private BexCompiledProgram.CompiledFunction compileFunction(String name, FrozenNode functionNode, FunctionSignature signature) { String previousFunction = currentFunction; currentFunction = name; CompileScope scope = new CompileScope(); - List args = new ArrayList<>(); - FrozenNode argsNode = prop(functionNode, "args"); - if (argsNode != null && argsNode.getProperties() != null) { - args.addAll(argsNode.getProperties().keySet()); - Collections.sort(args); - for (String arg : args) { - scope.declareOrGetSlot(arg); + for (BexCompiledProgram.ArgSpec arg : signature.args()) { + int slot = scope.declareOrGetSlot(arg.name()); + if (slot != arg.slot()) { + throw new BexException("Internal function arg slot mismatch for " + name + "." + arg.name()); } } FrozenNode functionExpr = meaningful(prop(functionNode, "expr")); @@ -97,11 +103,47 @@ private BexCompiledProgram.CompiledFunction compileFunction(String name, FrozenN ? compileStatements(meaningful(prop(functionNode, "do")), scope, "/functions/" + escape(name) + "/do") : Collections.emptyList(); currentFunction = previousFunction; - return new BexCompiledProgram.CompiledFunction(name, Collections.unmodifiableList(args), + return new BexCompiledProgram.CompiledFunction(name, signature.args(), statements, expression, scope.frameSize()); } + private Map compileFunctionSignatures(Map functionNodes) { + Map signatures = new LinkedHashMap<>(); + for (Map.Entry entry : functionNodes.entrySet()) { + signatures.put(entry.getKey(), compileFunctionSignature(entry.getKey(), entry.getValue())); + } + return signatures; + } + + private FunctionSignature compileFunctionSignature(String name, FrozenNode functionNode) { + List names = new ArrayList<>(); + FrozenNode argsNode = prop(functionNode, "args"); + if (argsNode != null) { + validatePlainObjectContainer(argsNode, "Function " + name + " args"); + if (argsNode.getProperties() == null) { + if (!argsNode.isEmptyNode()) { + throw new BexException("Function " + name + " args must be an object"); + } + } else { + names.addAll(argsNode.getProperties().keySet()); + Collections.sort(names); + } + } + List args = new ArrayList<>(); + for (int i = 0; i < names.size(); i++) { + String arg = names.get(i); + FrozenNode pattern = argsNode.getProperties().get(arg); + String sourcePointer = "/functions/" + escape(name) + "/args/" + escape(arg); + rejectBexAnywhereInStaticPattern(pattern, sourcePointer); + args.add(new BexCompiledProgram.ArgSpec(arg, i, + pattern, + sourcePointer)); + } + return new FunctionSignature(Collections.unmodifiableList(args)); + } + private void loadConstants(Map constants, FrozenNode node) { + validatePlainObjectContainer(node, "constants"); if (node == null || node.getProperties() == null) { return; } @@ -111,6 +153,7 @@ private void loadConstants(Map constants, FrozenNode node) { } private void loadFunctions(Map functions, FrozenNode node) { + validatePlainObjectContainer(node, "functions"); if (node == null || node.getProperties() == null) { return; } @@ -121,7 +164,9 @@ private void rejectRecursion(Map functions) { Map> calls = new LinkedHashMap<>(); for (String name : functions.keySet()) { Set targets = new LinkedHashSet<>(); - collectCalls(functions.get(name), targets); + FrozenNode function = functions.get(name); + collectCalls(meaningful(prop(function, "expr")), targets); + collectCalls(meaningful(prop(function, "do")), targets); calls.put(name, targets); } for (String name : calls.keySet()) { @@ -152,6 +197,10 @@ private void collectCalls(FrozenNode node, Set calls) { if (isOperator(node, "$literal")) { return; } + if (isOperator(node, "$is")) { + collectCalls(prop(onlyValue(node), "node"), calls); + return; + } if (isOperator(node, "$call")) { FrozenNode body = onlyValue(node); String function = text(prop(body, "function")); @@ -255,6 +304,7 @@ private CompiledExpression compileExpr(FrozenNode node, CompileScope scope, Stri if (node == null) { return sourceExpr(currentFunction, pointer, null, new LiteralExpr(BexValues.nullValue())); } + rejectBexInStaticBlueDefinitionFields(node, pointer); if (!containsCache.containsBex(node, metrics)) { return sourceExpr(currentFunction, pointer, null, new LiteralExpr(BexValues.frozen(node))); } @@ -270,18 +320,27 @@ private CompiledExpression compileExpr(FrozenNode node, CompileScope scope, Stri } } } - if (node.getItems() != null) { + if (node.getItems() != null && !hasLanguageFields(node)) { List items = new ArrayList<>(); for (int i = 0; i < node.getItems().size(); i++) { items.add(compileExpr(node.getItems().get(i), scope, pointer + "/" + i)); } return sourceExpr(currentFunction, pointer, null, new ListExpr(items)); } - if (node.getProperties() != null) { + if (node.getProperties() != null || hasLanguageFields(node)) { Map fields = new LinkedHashMap<>(); addMetadataFields(fields, node, scope, pointer); - for (Map.Entry entry : node.getProperties().entrySet()) { - fields.put(entry.getKey(), compileExpr(entry.getValue(), scope, pointer + "/" + escape(entry.getKey()))); + if (node.getItems() != null) { + List items = new ArrayList<>(); + for (int i = 0; i < node.getItems().size(); i++) { + items.add(compileExpr(node.getItems().get(i), scope, pointer + "/" + i)); + } + fields.put("items", new ListExpr(items)); + } + if (node.getProperties() != null) { + for (Map.Entry entry : node.getProperties().entrySet()) { + fields.put(entry.getKey(), compileExpr(entry.getValue(), scope, pointer + "/" + escape(entry.getKey()))); + } } return sourceExpr(currentFunction, pointer, null, new ObjectExpr(fields)); } @@ -302,6 +361,7 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc if ("$events".equals(op)) return new EventsExpr(); if ("$resultValue".equals(op)) return new ResultValueExpr(pointerOperand(body, scope, pointer)); if ("$unwrap".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.UNWRAP); + if ("$is".equals(op)) return isExpr(body, scope, pointer); if ("$text".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.TEXT); if ("$integer".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.INTEGER); if ("$number".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.NUMBER); @@ -309,7 +369,7 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc if ("$object".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.OBJECT); if ("$list".equals(op)) return new UnaryExpr(compileExpr(body, scope, pointer), UnaryOp.LIST); if ("$concat".equals(op)) return new VariadicExpr(compileExprList(body, scope, pointer), VariadicOp.CONCAT); - if ("$join".equals(op)) return new JoinExpr(compileExpr(required(prop(body, "items"), "$join.items"), scope, pointer + "/items"), compileExpr(required(prop(body, "separator"), "$join.separator"), scope, pointer + "/separator")); + if ("$join".equals(op)) return new JoinExpr(compileExpr(required(prop(body, "list"), "$join.list"), scope, pointer + "/list"), compileExpr(required(prop(body, "separator"), "$join.separator"), scope, pointer + "/separator")); if ("$split".equals(op)) return new SplitExpr(compileExpr(required(prop(body, "text"), "$split.text"), scope, pointer + "/text"), compileExpr(required(prop(body, "separator"), "$split.separator"), scope, pointer + "/separator"), prop(body, "limit") != null ? compileExpr(prop(body, "limit"), scope, pointer + "/limit") : null); if ("$startsWith".equals(op)) return new BinaryTextExpr(compileExprList(body, scope, pointer), BinaryTextOp.STARTS_WITH); if ("$sliceAfter".equals(op)) return new BinaryTextExpr(compileExprList(body, scope, pointer), BinaryTextOp.SLICE_AFTER); @@ -344,6 +404,17 @@ private CompiledExpression compileOperator(String op, FrozenNode body, CompileSc throw new BexException("Unknown expression operator: " + op); } + private CompiledExpression isExpr(FrozenNode body, CompileScope scope, String pointer) { + if (body == null || body.getProperties() == null) { + throw new BexException("$is expects an object body"); + } + FrozenNode pattern = required(prop(body, "pattern"), "$is.pattern"); + rejectBexAnywhereInStaticPattern(pattern, pointer + "/pattern"); + return new IsExpr( + compileExpr(required(prop(body, "node"), "$is.node"), scope, pointer + "/node"), + pattern); + } + private CompiledExpression documentExpr(FrozenNode body, CompileScope scope, String pointer) { boolean resolved = false; FrozenNode pointerNode = body; @@ -392,17 +463,44 @@ private CompiledExpression stepsExpr(FrozenNode body, CompileScope scope, String private CallExpr compileCall(FrozenNode body, CompileScope scope, String pointer) { String function = requiredText(prop(body, "function"), "$call.function"); - List argNames = new ArrayList<>(); + FunctionSignature signature = functionSignatures.get(function); + if (signature == null) { + throw new BexException("Unknown function: " + function); + } List argExpressions = new ArrayList<>(); + List targetSlots = new ArrayList<>(); + Set providedArgs = new LinkedHashSet<>(); FrozenNode argsNode = prop(body, "args"); - if (argsNode != null && argsNode.getProperties() != null) { - for (Map.Entry entry : argsNode.getProperties().entrySet()) { - argNames.add(entry.getKey()); - argExpressions.add(compileExpr(entry.getValue(), scope, pointer + "/args/" + escape(entry.getKey()))); + if (argsNode != null) { + validatePlainObjectContainer(argsNode, "$call.args"); + if (argsNode.getProperties() == null) { + if (!argsNode.isEmptyNode()) { + throw new BexException("$call.args must be an object at " + pointer + "/args"); + } + } else { + for (Map.Entry entry : argsNode.getProperties().entrySet()) { + String argName = entry.getKey(); + BexCompiledProgram.ArgSpec arg = signature.arg(argName); + if (arg == null) { + throw new BexException("Unknown argument " + argName + " for function " + function); + } + providedArgs.add(argName); + targetSlots.add(arg.slot()); + argExpressions.add(compileExpr(entry.getValue(), scope, pointer + "/args/" + escape(argName))); + } + } + } + for (BexCompiledProgram.ArgSpec arg : signature.args()) { + if (!providedArgs.contains(arg.name())) { + throw new BexException("Missing argument " + arg.name() + " for function " + function); } } + int[] slots = new int[targetSlots.size()]; + for (int i = 0; i < targetSlots.size(); i++) { + slots[i] = targetSlots.get(i); + } return new CallExpr(function, - argNames.toArray(new String[0]), + slots, argExpressions.toArray(new CompiledExpression[0])); } @@ -470,13 +568,7 @@ private FrozenNode prop(FrozenNode node, String key) { } private FrozenNode meaningful(FrozenNode node) { - if (node == null) { - return null; - } - boolean hasPayload = node.getValue() != null - || (node.getItems() != null && !node.getItems().isEmpty()) - || (node.getProperties() != null && !node.getProperties().isEmpty()); - return hasPayload ? node : null; + return node == null || node.isEmptyNode() ? null : node; } private FrozenNode required(FrozenNode node, String label) { @@ -519,9 +611,43 @@ private void addMetadataFields(Map fields, FrozenNod if (node.getType() != null) { fields.put("type", compileExpr(node.getType(), scope, pointer + "/type")); } + if (node.getItemType() != null) { + fields.put("itemType", compileExpr(node.getItemType(), scope, pointer + "/itemType")); + } + if (node.getKeyType() != null) { + fields.put("keyType", compileExpr(node.getKeyType(), scope, pointer + "/keyType")); + } + if (node.getValueType() != null) { + fields.put("valueType", compileExpr(node.getValueType(), scope, pointer + "/valueType")); + } if (node.getValue() != null) { fields.put("value", new LiteralExpr(BexValues.scalar(node.getValue()))); } + if (node.getReferenceBlueId() != null) { + fields.put("blueId", new LiteralExpr(BexValues.scalar(node.getReferenceBlueId()))); + } + if (node.getBlue() != null) { + fields.put("blue", compileExpr(node.getBlue(), scope, pointer + "/blue")); + } + if (node.getSchema() != null) { + fields.put("schema", new LiteralExpr(BexValues.nodeSnapshot(new blue.language.model.Node().schema(node.getSchema())))); + } + if (node.getMergePolicy() != null) { + fields.put("mergePolicy", new LiteralExpr(BexValues.scalar(node.getMergePolicy()))); + } + } + + private boolean hasLanguageFields(FrozenNode node) { + return node.getName() != null + || node.getDescription() != null + || node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getReferenceBlueId() != null + || node.getBlue() != null + || node.getSchema() != null + || node.getMergePolicy() != null; } private FrozenNode scalarNode(Object value) { @@ -540,4 +666,156 @@ private String escape(String segment) { return segment.replace("~", "~0").replace("/", "~1"); } + private void validatePlainObjectContainer(FrozenNode node, String label) { + if (node == null) { + return; + } + if (node.getValue() != null || node.getItems() != null || node.getReferenceBlueId() != null) { + throw new BexException(label + " must be a plain object with non-reserved field names"); + } + if (node.getPreviousBlueId() != null || node.getPosition() != null) { + throw new BexException(label + " contains a Blue list-control key; use non-reserved names"); + } + if (node.getName() != null + || node.getDescription() != null + || node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getBlue() != null + || node.getSchema() != null + || node.getMergePolicy() != null) { + throw new BexException(label + " contains a Blue language key; use non-reserved names"); + } + if (node.getProperties() != null) { + for (String key : node.getProperties().keySet()) { + if (RESERVED_BLUE_KEYS.contains(key)) { + throw new BexException(label + " contains reserved Blue key: " + key); + } + } + } + } + + private void rejectBexAnywhereInStaticPattern(FrozenNode pattern, String pointer) { + if (pattern != null && containsCache.containsBex(pattern, metrics)) { + throw new BexException("BEX expressions inside static Blue patterns are not supported at " + pointer); + } + rejectBexInStaticBlueDefinitionFields(pattern, pointer); + } + + private void rejectBexInStaticBlueDefinitionFields(FrozenNode node, String pointer) { + if (node == null) { + return; + } + rejectBexInStaticField(node.getType(), pointer + "/type", "type"); + rejectBexInStaticField(node.getItemType(), pointer + "/itemType", "itemType"); + rejectBexInStaticField(node.getKeyType(), pointer + "/keyType", "keyType"); + rejectBexInStaticField(node.getValueType(), pointer + "/valueType", "valueType"); + rejectBexInStaticField(node.getBlue(), pointer + "/blue", "blue"); + if (node.getSchema() != null) { + rejectBexInSchema(node.getSchema(), pointer + "/schema"); + } + if (node.getItems() != null) { + for (int i = 0; i < node.getItems().size(); i++) { + rejectBexInStaticBlueDefinitionFields(node.getItems().get(i), pointer + "/" + i); + } + } + if (node.getProperties() != null) { + for (Map.Entry entry : node.getProperties().entrySet()) { + rejectBexInStaticBlueDefinitionFields(entry.getValue(), pointer + "/" + escape(entry.getKey())); + } + } + } + + private void rejectBexInStaticField(FrozenNode field, String pointer, String fieldName) { + if (field != null && containsCache.containsBex(field, metrics)) { + throw new BexException("BEX expressions inside Blue " + fieldName + + " fields are not supported at " + pointer); + } + rejectBexInStaticBlueDefinitionFields(field, pointer); + } + + private void rejectBexInSchema(Schema schema, String pointer) { + rejectSchemaNode(schema.getRequired(), pointer); + rejectSchemaNode(schema.getAllowMultiple(), pointer); + rejectSchemaNode(schema.getMinLength(), pointer); + rejectSchemaNode(schema.getMaxLength(), pointer); + rejectSchemaNode(schema.getMinimum(), pointer); + rejectSchemaNode(schema.getMaximum(), pointer); + rejectSchemaNode(schema.getExclusiveMinimum(), pointer); + rejectSchemaNode(schema.getExclusiveMaximum(), pointer); + rejectSchemaNode(schema.getMultipleOf(), pointer); + rejectSchemaNode(schema.getMinItems(), pointer); + rejectSchemaNode(schema.getMaxItems(), pointer); + rejectSchemaNode(schema.getUniqueItems(), pointer); + rejectSchemaNode(schema.getMinFields(), pointer); + rejectSchemaNode(schema.getMaxFields(), pointer); + if (schema.getEnum() != null) { + for (Node node : schema.getEnum()) { + rejectSchemaNode(node, pointer); + } + } + if (schema.getOptions() != null) { + for (Node node : schema.getOptions()) { + rejectSchemaNode(node, pointer); + } + } + } + + private void rejectSchemaNode(Node node, String pointer) { + if (node == null) { + return; + } + FrozenNode frozen = FrozenNode.fromResolvedNode(node); + if (containsCache.containsBex(frozen, metrics)) { + throw new BexException("BEX expressions inside schema are not supported at " + pointer); + } + rejectBexInStaticBlueDefinitionFields(frozen, pointer); + } + + private static Set reservedBlueKeys() { + Set keys = new LinkedHashSet<>(); + Collections.addAll(keys, + "name", + "description", + "type", + "itemType", + "keyType", + "valueType", + "value", + "items", + "blueId", + "blue", + "schema", + "constraints", + "mergePolicy", + "properties", + "contracts", + "$previous", + "$pos"); + return Collections.unmodifiableSet(keys); + } + + private static final class FunctionSignature { + private final List args; + private final Map argsByName; + + private FunctionSignature(List args) { + this.args = args; + Map byName = new LinkedHashMap<>(); + for (BexCompiledProgram.ArgSpec arg : args) { + byName.put(arg.name(), arg); + } + this.argsByName = Collections.unmodifiableMap(byName); + } + + private List args() { + return args; + } + + private BexCompiledProgram.ArgSpec arg(String name) { + return argsByName.get(name); + } + } + } diff --git a/src/main/java/blue/bex/compile/BexContainsCache.java b/src/main/java/blue/bex/compile/BexContainsCache.java index a4b7408..289edf0 100644 --- a/src/main/java/blue/bex/compile/BexContainsCache.java +++ b/src/main/java/blue/bex/compile/BexContainsCache.java @@ -47,6 +47,16 @@ public synchronized boolean containsBex(FrozenNode node, BexMetrics metrics) { } private boolean scan(FrozenNode node) { + if (node == null) { + return false; + } + if (scan(node.getType()) + || scan(node.getItemType()) + || scan(node.getKeyType()) + || scan(node.getValueType()) + || scan(node.getBlue())) { + return true; + } if (node.getProperties() != null) { if (node.getProperties().size() == 1) { String key = node.getProperties().keySet().iterator().next(); diff --git a/src/main/java/blue/bex/compile/CallExpr.java b/src/main/java/blue/bex/compile/CallExpr.java index b21146c..3fe0789 100644 --- a/src/main/java/blue/bex/compile/CallExpr.java +++ b/src/main/java/blue/bex/compile/CallExpr.java @@ -1,40 +1,27 @@ package blue.bex.compile; -import blue.bex.BexException; import blue.bex.runtime.CompiledExpression; import blue.bex.runtime.CompiledFrame; import blue.bex.value.BexValue; final class CallExpr extends Expr { private final String function; - private final String[] argNames; + private final int[] targetSlots; private final CompiledExpression[] argExpressions; - private BexCompiledProgram.CompiledFunction cachedFunction; - private int[] cachedTargetSlots; - CallExpr(String function, String[] argNames, CompiledExpression[] argExpressions) { + CallExpr(String function, int[] targetSlots, CompiledExpression[] argExpressions) { this.function = function; - this.argNames = argNames; + this.targetSlots = targetSlots; this.argExpressions = argExpressions; } @Override protected BexValue doEval(CompiledFrame frame) { - BexCompiledProgram.CompiledFunction compiled = cachedFunction; - if (compiled == null) { - compiled = frame.runtime().program().functions().get(function); - if (compiled == null) throw new BexException("Unknown function: " + function); - int[] slots = new int[argNames.length]; - for (int i = 0; i < argNames.length; i++) { - slots[i] = compiled.argSlot(argNames[i]); - } - cachedTargetSlots = slots; - cachedFunction = compiled; - } + BexCompiledProgram.CompiledFunction compiled = frame.runtime().program().functions().get(function); BexValue[] values = new BexValue[argExpressions.length]; for (int i = 0; i < argExpressions.length; i++) { values[i] = argExpressions[i].eval(frame); } - return compiled.invokePrepared(frame.runtime(), frame, cachedTargetSlots, values); + return compiled.invokePrepared(frame.runtime(), frame, targetSlots, values); } } diff --git a/src/main/java/blue/bex/compile/TypeStringExpressions.java b/src/main/java/blue/bex/compile/TypeStringExpressions.java index fdd0983..24ac18f 100644 --- a/src/main/java/blue/bex/compile/TypeStringExpressions.java +++ b/src/main/java/blue/bex/compile/TypeStringExpressions.java @@ -5,6 +5,7 @@ import blue.bex.runtime.CompiledFrame; import blue.bex.value.BexValue; import blue.bex.value.BexValues; +import blue.language.snapshot.FrozenNode; import java.math.BigInteger; import java.util.ArrayList; @@ -75,6 +76,22 @@ protected BexValue doEval(CompiledFrame frame) { } } +final class IsExpr extends Expr { + private final CompiledExpression valueExpression; + private final FrozenNode pattern; + + IsExpr(CompiledExpression valueExpression, FrozenNode pattern) { + this.valueExpression = valueExpression; + this.pattern = pattern; + } + + @Override + protected BexValue doEval(CompiledFrame frame) { + BexValue value = valueExpression.eval(frame); + return BexValues.scalar(frame.runtime().typeMatcher().matches(value, pattern)); + } +} + enum VariadicOp { CONCAT, LIST_CONCAT, MERGE } final class VariadicExpr extends Expr { @@ -124,7 +141,7 @@ final class JoinExpr extends Expr { @Override protected BexValue doEval(CompiledFrame frame) { BexValue list = items.eval(frame); - if (!list.isList()) throw new BexException("$join items must be a list"); + if (!list.isList()) throw new BexException("$join list must be a list"); String sep = separator.eval(frame).asText(); StringBuilder out = new StringBuilder(); for (int i = 0; i < list.size(); i++) { diff --git a/src/main/java/blue/bex/runtime/BexRuntime.java b/src/main/java/blue/bex/runtime/BexRuntime.java index 3b2a597..ea2d408 100644 --- a/src/main/java/blue/bex/runtime/BexRuntime.java +++ b/src/main/java/blue/bex/runtime/BexRuntime.java @@ -9,8 +9,10 @@ import blue.bex.result.BexExecutionResult; import blue.bex.result.BexMetrics; import blue.bex.result.BexResultOverlay; +import blue.bex.type.BexBlueTypeMatcher; import blue.bex.value.BexValue; import blue.bex.value.BexValues; +import blue.language.Blue; import blue.language.utils.JsonPointer; import java.util.LinkedHashMap; @@ -27,9 +29,11 @@ public final class BexRuntime { private final BexMetrics metrics; private final BexPointerCache pointerCache; private final BexExecutionAccumulator accumulator; + private final BexBlueTypeMatcher typeMatcher; public BexRuntime(BexCompiledProgram program, BexExecutionContext context, + Blue blue, BexGasSchedule gasSchedule, BexMetrics metrics, BexPointerCache pointerCache) { @@ -39,6 +43,7 @@ public BexRuntime(BexCompiledProgram program, this.metrics = metrics; this.pointerCache = pointerCache; this.accumulator = new BexExecutionAccumulator(new BexResultOverlay(context.document(), metrics)); + this.typeMatcher = new BexBlueTypeMatcher(blue); } public BexExecutionResult execute() { @@ -53,6 +58,7 @@ public BexExecutionResult execute() { public BexMetrics metrics() { return metrics; } public BexPointerCache pointerCache() { return pointerCache; } public BexExecutionAccumulator accumulator() { return accumulator; } + public BexBlueTypeMatcher typeMatcher() { return typeMatcher; } public BexValue readDocument(String absolutePointer, List precompiledSegments, boolean resolved) { if (resolved) { diff --git a/src/main/java/blue/bex/type/BexBlueTypeMatcher.java b/src/main/java/blue/bex/type/BexBlueTypeMatcher.java new file mode 100644 index 0000000..869d264 --- /dev/null +++ b/src/main/java/blue/bex/type/BexBlueTypeMatcher.java @@ -0,0 +1,34 @@ +package blue.bex.type; + +import blue.bex.value.BexNodeWriter; +import blue.bex.value.BexValue; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; + +/** + * BEX boundary adapter for Blue's node/type matcher. + */ +public final class BexBlueTypeMatcher { + private final Blue blue; + + public BexBlueTypeMatcher(Blue blue) { + this.blue = blue != null ? blue : new Blue(); + } + + public boolean matches(BexValue value, FrozenNode pattern) { + if (value == null || value.isUndefined()) { + return false; + } + if (pattern == null || pattern.isEmptyNode()) { + return true; + } + try { + Node valueNode = BexNodeWriter.toNode(value); + Node patternNode = pattern.toNode(); + return blue.nodeMatchesType(valueNode, patternNode); + } catch (RuntimeException ex) { + return false; + } + } +} diff --git a/src/main/java/blue/bex/value/BexBlueNodeWriter.java b/src/main/java/blue/bex/value/BexBlueNodeWriter.java new file mode 100644 index 0000000..c9d8538 --- /dev/null +++ b/src/main/java/blue/bex/value/BexBlueNodeWriter.java @@ -0,0 +1,257 @@ +package blue.bex.value; + +import blue.bex.BexException; +import blue.language.model.Node; +import blue.language.model.Schema; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * Boundary writer from BEX values to Blue nodes using Blue language keys. + */ +public final class BexBlueNodeWriter { + private BexBlueNodeWriter() { + } + + public static Node toNode(BexValue value) { + if (value.isUndefined()) { + throw new BexException("Undefined cannot be emitted as a Blue value"); + } + if (value.isNull()) { + return new Node(); + } + if (value instanceof NodeBexValue || value instanceof FrozenNodeBexValue) { + return value.toNode(); + } + if (value.isScalar()) { + return value.toNode(); + } + if (value.isList()) { + return new Node().items(toNodeList(value)); + } + if (!value.isObject()) { + return value.toNode(); + } + + if (!value.get("properties").isUndefined()) { + throw new BexException("\"properties\" is an internal Blue field and must not appear in BEX output"); + } + validateBlueIdReferenceShape(value); + + Node node = new Node(); + LinkedHashMap properties = new LinkedHashMap<>(); + boolean hasValuePayload = false; + boolean hasItemsPayload = false; + boolean hasSchema = false; + for (String key : value.keys()) { + BexValue child = value.get(key); + if (child == null || child.isUndefined()) { + continue; + } + if ("name".equals(key)) { + node.name(child.isNull() ? null : child.asText()); + } else if ("description".equals(key)) { + node.description(child.isNull() ? null : child.asText()); + } else if ("type".equals(key)) { + node.type(toNode(child)); + } else if ("itemType".equals(key)) { + node.itemType(toNode(child)); + } else if ("keyType".equals(key)) { + node.keyType(toNode(child)); + } else if ("valueType".equals(key)) { + node.valueType(toNode(child)); + } else if ("mergePolicy".equals(key)) { + node.mergePolicy(child.isNull() ? null : child.asText()); + } else if ("value".equals(key)) { + hasValuePayload = true; + node.value(scalarValue(child, "value")); + } else if ("items".equals(key)) { + hasItemsPayload = true; + if (!child.isList()) { + throw new BexException("Blue items field must be a list"); + } + node.items(toNodeList(child)); + } else if ("blueId".equals(key)) { + node.blueId(child.asText()); + } else if ("blue".equals(key)) { + node.blue(toNode(child)); + } else if ("schema".equals(key) || "constraints".equals(key)) { + if (hasSchema) { + throw new BexException("A Blue node cannot contain both schema and constraints"); + } + hasSchema = true; + node.schema(toSchema(child)); + } else if ("$previous".equals(key) || "$pos".equals(key)) { + throw new BexException("BEX output does not currently support Blue list-control field " + key); + } else if ("properties".equals(key)) { + throw new BexException("\"properties\" is an internal Blue field and must not appear in BEX output"); + } else { + properties.put(key, toNode(child)); + } + } + int payloadKinds = 0; + if (hasValuePayload) { + payloadKinds++; + } + if (hasItemsPayload) { + payloadKinds++; + } + if (!properties.isEmpty()) { + payloadKinds++; + } + if (payloadKinds > 1) { + throw new BexException("A Blue node may contain only one payload kind: value, items, or object fields"); + } + if (!properties.isEmpty()) { + node.properties(properties); + } + return node; + } + + public static boolean hasLanguageField(BexValue value) { + if (value == null || !value.isObject()) { + return false; + } + for (String key : value.keys()) { + if (isLanguageField(key)) { + return true; + } + } + return false; + } + + public static boolean isLanguageField(String key) { + return "name".equals(key) + || "description".equals(key) + || "type".equals(key) + || "itemType".equals(key) + || "keyType".equals(key) + || "valueType".equals(key) + || "value".equals(key) + || "items".equals(key) + || "blueId".equals(key) + || "blue".equals(key) + || "schema".equals(key) + || "constraints".equals(key) + || "mergePolicy".equals(key) + || "properties".equals(key) + || "$previous".equals(key) + || "$pos".equals(key); + } + + private static void validateBlueIdReferenceShape(BexValue value) { + if (value.get("blueId").isUndefined()) { + return; + } + for (String key : value.keys()) { + if (!"blueId".equals(key)) { + throw new BexException("Blue blueId reference node cannot contain sibling field: " + key); + } + } + } + + private static Object scalarValue(BexValue value, String field) { + if (value.isNull()) { + return null; + } + if (!value.isScalar()) { + throw new BexException("Blue " + field + " field must be a scalar value"); + } + return BexValues.rawScalar(value); + } + + private static List toNodeList(BexValue value) { + ArrayList items = new ArrayList<>(); + for (int i = 0; i < value.size(); i++) { + items.add(toNode(value.get(String.valueOf(i)))); + } + return items; + } + + private static Schema toSchema(BexValue value) { + if (value.isNull() || value.isUndefined()) { + return null; + } + Node node = toNode(value); + if (node.getSchema() != null && node.getValue() == null + && node.getItems() == null && node.getProperties() == null) { + return node.getSchema(); + } + if (!value.isObject()) { + throw new BexException("Blue schema field must be an object"); + } + Schema schema = new Schema(); + setSchemaNode(schema, value, "required"); + setSchemaNode(schema, value, "allowMultiple"); + setSchemaNode(schema, value, "minLength"); + setSchemaNode(schema, value, "maxLength"); + setSchemaNode(schema, value, "minimum"); + setSchemaNode(schema, value, "maximum"); + setSchemaNode(schema, value, "exclusiveMinimum"); + setSchemaNode(schema, value, "exclusiveMaximum"); + setSchemaNode(schema, value, "multipleOf"); + setSchemaNode(schema, value, "minItems"); + setSchemaNode(schema, value, "maxItems"); + setSchemaNode(schema, value, "uniqueItems"); + setSchemaNode(schema, value, "minFields"); + setSchemaNode(schema, value, "maxFields"); + setSchemaList(schema, value, "enum"); + setSchemaList(schema, value, "options"); + return schema; + } + + private static void setSchemaNode(Schema schema, BexValue source, String key) { + BexValue value = source.get(key); + if (value.isUndefined()) { + return; + } + Node node = toNode(value); + if ("required".equals(key)) { + schema.required(node); + } else if ("allowMultiple".equals(key)) { + schema.allowMultiple(node); + } else if ("minLength".equals(key)) { + schema.minLength(node); + } else if ("maxLength".equals(key)) { + schema.maxLength(node); + } else if ("minimum".equals(key)) { + schema.minimum(node); + } else if ("maximum".equals(key)) { + schema.maximum(node); + } else if ("exclusiveMinimum".equals(key)) { + schema.exclusiveMinimum(node); + } else if ("exclusiveMaximum".equals(key)) { + schema.exclusiveMaximum(node); + } else if ("multipleOf".equals(key)) { + schema.multipleOf(node); + } else if ("minItems".equals(key)) { + schema.minItems(node); + } else if ("maxItems".equals(key)) { + schema.maxItems(node); + } else if ("uniqueItems".equals(key)) { + schema.uniqueItems(node); + } else if ("minFields".equals(key)) { + schema.minFields(node); + } else if ("maxFields".equals(key)) { + schema.maxFields(node); + } + } + + private static void setSchemaList(Schema schema, BexValue source, String key) { + BexValue value = source.get(key); + if (value.isUndefined()) { + return; + } + if (!value.isList()) { + throw new BexException("Blue schema " + key + " field must be a list"); + } + List nodes = toNodeList(value); + if ("enum".equals(key)) { + schema.enumValues(nodes); + } else if ("options".equals(key)) { + schema.options(nodes); + } + } +} diff --git a/src/main/java/blue/bex/value/BexFrozenWriter.java b/src/main/java/blue/bex/value/BexFrozenWriter.java index cf55ea6..67f8b29 100644 --- a/src/main/java/blue/bex/value/BexFrozenWriter.java +++ b/src/main/java/blue/bex/value/BexFrozenWriter.java @@ -56,6 +56,9 @@ private FrozenNode toFrozenInternal(BexValue value) { return factory.list(items, metrics); } if (value.isObject()) { + if (BexBlueNodeWriter.hasLanguageField(value)) { + return FrozenNode.fromResolvedNode(BexBlueNodeWriter.toNode(value)); + } Map properties = new LinkedHashMap<>(); for (String key : value.keys()) { BexValue child = value.get(key); diff --git a/src/main/java/blue/bex/value/BexNodeWriter.java b/src/main/java/blue/bex/value/BexNodeWriter.java index 6c2371d..c198f97 100644 --- a/src/main/java/blue/bex/value/BexNodeWriter.java +++ b/src/main/java/blue/bex/value/BexNodeWriter.java @@ -2,9 +2,6 @@ import blue.language.model.Node; -import java.util.ArrayList; -import java.util.LinkedHashMap; - /** * Boundary writer from BEX values to mutable Blue nodes. */ @@ -13,32 +10,6 @@ private BexNodeWriter() { } public static Node toNode(BexValue value) { - if (value.isUndefined()) { - throw new blue.bex.BexException("Undefined cannot be emitted as a Blue value"); - } - if (value.isNull()) { - return new Node(); - } - if (value instanceof NodeBexValue || value instanceof FrozenNodeBexValue) { - return value.toNode(); - } - if (value.isScalar()) { - return value.toNode(); - } - if (value.isList()) { - ArrayList items = new ArrayList<>(); - for (int i = 0; i < value.size(); i++) { - items.add(toNode(value.get(String.valueOf(i)))); - } - return new Node().items(items); - } - LinkedHashMap properties = new LinkedHashMap<>(); - for (String key : value.keys()) { - BexValue child = value.get(key); - if (!child.isUndefined()) { - properties.put(key, toNode(child)); - } - } - return new Node().properties(properties); + return BexBlueNodeWriter.toNode(value); } } diff --git a/src/main/java/blue/bex/value/MapBexValue.java b/src/main/java/blue/bex/value/MapBexValue.java index a515780..1ca9ab6 100644 --- a/src/main/java/blue/bex/value/MapBexValue.java +++ b/src/main/java/blue/bex/value/MapBexValue.java @@ -50,16 +50,7 @@ public List keys() { @Override public Node toNode() { - Node node = new Node(); - LinkedHashMap properties = new LinkedHashMap<>(); - for (String key : keys()) { - BexValue value = values.get(key); - if (value != null && !value.isUndefined()) { - properties.put(key, value.toNode()); - } - } - node.properties(properties); - return node; + return BexNodeWriter.toNode(this); } @Override diff --git a/src/test/java/blue/bex/BexBlueTypeSupportTest.java b/src/test/java/blue/bex/BexBlueTypeSupportTest.java new file mode 100644 index 0000000..b80de92 --- /dev/null +++ b/src/test/java/blue/bex/BexBlueTypeSupportTest.java @@ -0,0 +1,836 @@ +package blue.bex; + +import blue.bex.api.BexEngine; +import blue.bex.api.BexProgramSource; +import blue.bex.compile.BexCompiledProgram; +import blue.bex.result.BexExecutionResult; +import blue.bex.value.BexFrozenWriter; +import blue.bex.value.BexNodeWriter; +import blue.bex.value.BexValue; +import blue.bex.value.BexValues; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.Collections; + +import static blue.bex.test.BexTestFixtures.bi; +import static blue.bex.test.BexTestFixtures.defaultContext; +import static blue.bex.test.BexTestFixtures.list; +import static blue.bex.test.BexTestFixtures.m; +import static blue.bex.test.BexTestFixtures.obj; +import static blue.bex.test.BexTestFixtures.op; +import static blue.bex.test.BexTestFixtures.simple; +import static blue.bex.test.BexTestFixtures.stepExpr; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BexBlueTypeSupportTest { + private static final Blue YAML_BLUE = new Blue(); + private final Blue blue = new Blue(blueId -> { + if ("HotelOrderType".equals(blueId)) { + return Collections.singletonList(YAML_BLUE.yamlToNode(yaml( + "status:", + " type: Text"))); + } + if ("RestaurantOrderType".equals(blueId)) { + return Collections.singletonList(YAML_BLUE.yamlToNode(yaml( + "restaurantStatus:", + " type: Text"))); + } + return Collections.emptyList(); + }); + private final BexEngine engine = BexEngine.builder().blue(blue).build(); + + @Test + void functionArgAcceptsMatchingPrimitiveType() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " amount:", + " type: Integer", + " expr:", + " $var: amount", + "expr:", + " $call:", + " function: f", + " args:", + " amount: 400")); + + assertEquals(BigInteger.valueOf(400), simple(result.value())); + } + + @Test + void functionArgRejectsWrongPrimitiveTypeAtRuntime() { + BexCompiledProgram program = compile(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " amount:", + " type: Integer", + " expr:", + " $var: amount", + "expr:", + " $call:", + " function: f", + " args:", + " amount: \"400\"")); + + BexException ex = assertThrows(BexException.class, () -> engine.execute(program, defaultContext())); + assertTrue(ex.getMessage().contains("Function f argument amount does not match declared Blue pattern at /functions/f/args/amount")); + } + + @Test + void functionArgAcceptsStructuralObjectPattern() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " request:", + " customerName:", + " type: Text", + " schema:", + " required: true", + " nights:", + " type: Integer", + " schema:", + " required: true", + " expr:", + " $var: request", + "expr:", + " $call:", + " function: f", + " args:", + " request:", + " customerName: Jan", + " nights: 2")); + + assertEquals(m("customerName", "Jan", "nights", bi(2)), simple(result.value())); + } + + @Test + void functionArgRejectsMissingRequiredStructuralField() { + BexCompiledProgram program = compile(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " request:", + " customerName:", + " type: Text", + " schema:", + " required: true", + " nights:", + " type: Integer", + " schema:", + " required: true", + " expr:", + " $var: request", + "expr:", + " $call:", + " function: f", + " args:", + " request:", + " customerName: Jan")); + + assertThrows(BexException.class, () -> engine.execute(program, defaultContext())); + } + + @Test + void functionArgRejectsUnknownArgAtCompileTime() { + assertThrows(BexException.class, () -> compile(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " amount:", + " type: Integer", + " expr:", + " $var: amount", + "expr:", + " $call:", + " function: f", + " args:", + " amount: 400", + " extra: true"))); + } + + @Test + void functionArgRejectsMissingArgAtCompileTime() { + assertThrows(BexException.class, () -> compile(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " amount:", + " type: Integer", + " expr:", + " $var: amount", + "expr:", + " $call:", + " function: f", + " args: {}"))); + } + + @Test + void untypedArgStillWorks() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " input: {}", + " expr:", + " $var: input", + "expr:", + " $call:", + " function: f", + " args:", + " input:", + " anything: works")); + + assertEquals(m("anything", "works"), simple(result.value())); + } + + @Test + void isUsesNodeFieldInYamlSyntax() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: 400", + " pattern:", + " type: Integer")); + + assertEquals(true, simple(result.value())); + } + + @Test + void isUsesNodeFieldAndReturnsFalseForWrongPrimitiveInYamlSyntax() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: \"400\"", + " pattern:", + " type: Integer")); + + assertEquals(false, simple(result.value())); + } + + @Test + void isIntegerPatternExamplesMatchOnlyIntegerNodes() { + assertEquals(true, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: 400", + " pattern:", + " type: Integer")).value())); + assertEquals(true, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node:", + " $integer: \"400\"", + " pattern:", + " type: Integer")).value())); + assertEquals(true, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node:", + " type: Integer", + " value: 400", + " pattern:", + " type: Integer")).value())); + assertEquals(false, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: \"400\"", + " pattern:", + " type: Integer")).value())); + assertEquals(false, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: 4.5", + " pattern:", + " type: Integer")).value())); + assertEquals(false, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: true", + " pattern:", + " type: Integer")).value())); + assertEquals(false, simple(run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node:", + " $document: /missing", + " pattern:", + " type: Integer")).value())); + } + + @Test + void oldIsValueShapeIsInvalidBlueYaml() { + assertThrows(IllegalArgumentException.class, () -> blue.yamlToNode(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " value: 400", + " pattern:", + " type: Integer"))); + } + + @Test + void joinUsesListFieldInYamlSyntax() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "expr:", + " $join:", + " list:", + " - a", + " - b", + " separator: \":\"")); + + assertEquals("a:b", simple(result.value())); + } + + @Test + void oldJoinItemsShapeIsInvalidBlueYaml() { + assertThrows(IllegalArgumentException.class, () -> blue.yamlToNode(yaml( + "type: Blue/BEX Program", + "expr:", + " $join:", + " items:", + " - a", + " - b", + " separator: \":\""))); + } + + @Test + void reservedArgNameFromInternalShapeFailsCompileTime() { + BexException ex = assertThrows(BexException.class, () -> engine.compile(BexProgramSource.inline(FrozenNode.fromResolvedNode(obj( + "type", "Blue/BEX Program", + "functions", obj("f", obj( + "args", obj("value", obj()), + "expr", op("$var", "value"))), + "expr", op("$call", obj( + "function", "f", + "args", obj("value", 1)))))))); + + assertTrue(ex.getMessage().contains("reserved Blue key: value")); + } + + @Test + void reservedArgNameIsInvalidOrRejectedInYaml() { + assertThrows(RuntimeException.class, () -> run(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " value: {}", + " expr:", + " $var: value", + "expr:", + " $call:", + " function: f", + " args:", + " value: 1"))); + } + + @Test + void contractsIsReservedAsFunctionArgumentName() { + BexException ex = assertThrows(BexException.class, () -> engine.compile(BexProgramSource.inline(FrozenNode.fromResolvedNode(obj( + "type", "Blue/BEX Program", + "functions", obj("f", obj( + "args", obj("contracts", obj()), + "expr", op("$var", "contracts"))), + "expr", op("$call", obj( + "function", "f", + "args", obj("contracts", 1)))))))); + + assertTrue(ex.getMessage().contains("reserved Blue key: contracts")); + } + + @Test + void functionArgAcceptsMatchingBlueIdPattern() { + Node hotelOrderPattern = pattern("blueId: HotelOrderBlueId"); + BexExecutionResult result = run(obj( + "type", "Blue/BEX Program", + "functions", obj("f", obj( + "args", obj("hotelOrder", hotelOrderPattern), + "expr", op("$is", obj( + "node", op("$var", "hotelOrder"), + "pattern", hotelOrderPattern)))), + "expr", op("$call", obj( + "function", "f", + "args", obj("hotelOrder", hotelOrderPattern))))); + + assertEquals(true, simple(result.value())); + } + + @Test + void isReturnsTrueForComputedTypedObjectWithBlueIdPattern() { + Node hotelOrderPattern = pattern("blueId: HotelOrderType"); + BexExecutionResult result = run(stepExpr(op("$is", obj( + "node", obj( + "type", hotelOrderPattern, + "status", op("$literal", "confirmed")), + "pattern", hotelOrderPattern)))); + + assertEquals(true, simple(result.value())); + } + + @Test + void isReturnsFalseForComputedTypedObjectWithDifferentBlueIdPattern() { + BexExecutionResult result = run(stepExpr(op("$is", obj( + "node", obj( + "type", pattern("blueId: RestaurantOrderType"), + "status", op("$literal", "confirmed")), + "pattern", pattern("blueId: HotelOrderType"))))); + + assertEquals(false, simple(result.value())); + } + + @Test + void functionArgAcceptsComputedTypedObjectMatchingBlueIdPattern() { + Node hotelOrderPattern = pattern("blueId: HotelOrderType"); + BexExecutionResult result = run(obj( + "type", "Blue/BEX Program", + "functions", obj("f", obj( + "args", obj("hotelOrder", hotelOrderPattern), + "expr", op("$is", obj( + "node", op("$var", "hotelOrder"), + "pattern", hotelOrderPattern)))), + "expr", op("$call", obj( + "function", "f", + "args", obj("hotelOrder", obj( + "type", hotelOrderPattern, + "status", op("$literal", "confirmed"))))))); + + assertEquals(true, simple(result.value())); + } + + @Test + void functionArgRejectsComputedTypedObjectWithDifferentBlueIdPattern() { + BexCompiledProgram program = engine.compile(BexProgramSource.inline(FrozenNode.fromResolvedNode(obj( + "type", "Blue/BEX Program", + "functions", obj("f", obj( + "args", obj("hotelOrder", pattern("blueId: HotelOrderType")), + "expr", op("$var", "hotelOrder"))), + "expr", op("$call", obj( + "function", "f", + "args", obj("hotelOrder", obj( + "type", pattern("blueId: RestaurantOrderType"), + "status", op("$literal", "confirmed"))))))))); + + assertThrows(BexException.class, () -> engine.execute(program, defaultContext())); + } + + @Test + void isReturnsFalseForBlueIdReferenceWithSiblingFields() { + BexExecutionResult result = run(stepExpr(op("$is", obj( + "node", obj( + "blueId", "HotelOrderType", + "status", op("$literal", "confirmed")), + "pattern", pattern("blueId: HotelOrderType"))))); + + assertEquals(false, simple(result.value())); + } + + @Test + void isReturnsTrueForPrimitiveType() { + assertEquals(true, simple(run(stepExpr(op("$is", obj( + "node", 400, + "pattern", pattern("type: Integer"))))).value())); + } + + @Test + void isReturnsFalseForWrongPrimitiveType() { + assertEquals(false, simple(run(stepExpr(op("$is", obj( + "node", "400", + "pattern", pattern("type: Integer"))))).value())); + } + + @Test + void isReturnsTrueForStructuralPattern() { + assertEquals(true, simple(run(stepExpr(op("$is", obj( + "node", obj("customerName", "Jan", "nights", 2), + "pattern", pattern( + "customerName:", + " type: Text", + " schema:", + " required: true", + "nights:", + " type: Integer", + " schema:", + " required: true"))))).value())); + } + + @Test + void isReturnsTrueForStructuralPatternWithComputedFields() { + assertEquals(true, simple(run(stepExpr(op("$is", obj( + "node", obj( + "customerName", op("$literal", "Jan"), + "nights", op("$integer", "2")), + "pattern", pattern( + "customerName:", + " type: Text", + " schema:", + " required: true", + "nights:", + " type: Integer", + " schema:", + " required: true"))))).value())); + } + + @Test + void isReturnsFalseForStructuralPatternMismatch() { + assertEquals(false, simple(run(stepExpr(op("$is", obj( + "node", obj("customerName", "Jan", "nights", "2"), + "pattern", pattern( + "customerName:", + " type: Text", + " schema:", + " required: true", + "nights:", + " type: Integer", + " schema:", + " required: true"))))).value())); + } + + @Test + void isReturnsFalseForUndefinedValue() { + assertEquals(false, simple(run(stepExpr(op("$is", obj( + "node", op("$document", "/missing"), + "pattern", pattern("type: Text"))))).value())); + } + + @Test + void computedObjectPreservesValueTypeLanguageField() { + BexExecutionResult result = run(stepExpr(obj( + "valueType", pattern("type: Integer"), + "amount", op("$integer", "2")))); + Node node = BexNodeWriter.toNode(result.value()); + + assertEquals(BigInteger.valueOf(2), node.getProperties().get("amount").getValue()); + assertTrue(node.getValueType() != null); + assertTrue(node.getValueType().getType() != null); + } + + @Test + void bexInsideTypeLanguageFieldIsRejected() { + assertThrows(BexException.class, () -> run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node:", + " type:", + " $choose:", + " cond: true", + " then: Integer", + " else: Text", + " value: 10", + " pattern:", + " type: Integer"))); + } + + @Test + void bexInsideSchemaIsRejectedAtCompileTime() { + assertThrows(BexException.class, () -> run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: abc", + " pattern:", + " type: Text", + " schema:", + " minLength:", + " $integer: \"1\""))); + } + + @Test + void bexInsideIsPatternIsRejectedAtCompileTime() { + assertThrows(BexException.class, () -> run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node: 10", + " pattern:", + " type:", + " $choose:", + " cond: true", + " then: Integer", + " else: Text"))); + } + + @Test + void bexInsideFunctionArgumentPatternIsRejectedAtCompileTime() { + assertThrows(BexException.class, () -> compile(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " input:", + " type:", + " $choose:", + " cond: true", + " then: Integer", + " else: Text", + " expr:", + " $var: input", + "expr:", + " $call:", + " function: f", + " args:", + " input: abc"))); + } + + @Test + void bexInsideFunctionArgumentSchemaIsRejectedAtCompileTime() { + assertThrows(BexException.class, () -> compile(yaml( + "type: Blue/BEX Program", + "functions:", + " f:", + " args:", + " input:", + " type: Text", + " schema:", + " minLength:", + " $integer: \"1\"", + " expr:", + " $var: input", + "expr:", + " $call:", + " function: f", + " args:", + " input: abc"))); + } + + @Test + void bexInsideNestedTypeBlueIdIsRejectedAtCompileTime() { + Node expr = new Node() + .type(obj("blueId", op("$concat", list("Hotel", "OrderType")))) + .properties("status", new Node().value("confirmed")); + + assertThrows(BexException.class, () -> run(stepExpr(expr))); + } + + @Test + void staticAuthoredTypeStillWorks() { + BexExecutionResult result = run(yaml( + "type: Blue/BEX Program", + "expr:", + " $is:", + " node:", + " type: Integer", + " value: 10", + " pattern:", + " type: Integer")); + + assertEquals(true, simple(result.value())); + } + + @Test + void computedScalarTypeIsNotTreatedAsAuthoredTypeAlias() { + BexValue value = BexValues.fromSimple(m( + "type", "Integer", + "value", BigInteger.TEN)); + Node node = BexNodeWriter.toNode(value); + Node pattern = blue.yamlToNode("type: Integer"); + + assertTrue(node.getType() != null); + assertEquals("Integer", node.getType().getValue()); + assertFalse(node.getType().isInlineValue()); + assertFalse(blue.nodeMatchesType(node, pattern)); + } + + @Test + void nameAndDescriptionAreNeutralForBluePatternMatching() { + BexExecutionResult result = run(stepExpr(op("$is", obj( + "node", obj( + "name", "Runtime name", + "description", "Runtime description", + "type", pattern("type: Integer"), + "value", op("$literal", 10)), + "pattern", pattern( + "name: Pattern name", + "description: Pattern description", + "type: Integer"))))); + + assertEquals(true, simple(result.value())); + } + + @Test + void entryFunctionWithArgsFailsAtCompileTime() { + BexException ex = assertThrows(BexException.class, () -> compile(yaml( + "type: Blue/BEX Program", + "entry: f", + "functions:", + " f:", + " args:", + " amount:", + " type: Integer", + " expr:", + " $var: amount"))); + + assertTrue(ex.getMessage().contains("Entry function f declares arguments but entry invocation provides none")); + } + + @Test + void staticPatternsRejectBexOperators() { + Node recursiveLookingPattern = obj("$call", obj("function", "f")); + + assertThrows(BexException.class, () -> run(obj( + "type", "Blue/BEX Program", + "functions", obj("f", obj( + "args", obj("x", recursiveLookingPattern), + "expr", op("$is", obj( + "node", op("$literal", recursiveLookingPattern), + "pattern", recursiveLookingPattern)))), + "expr", op("$call", obj( + "function", "f", + "args", obj("x", op("$literal", recursiveLookingPattern))))))); + } + + @Test + void literalPayloadStillRejectsBexInsideTypeDefinitionFields() { + assertThrows(BexException.class, () -> run(yaml( + "type: Blue/BEX Program", + "expr:", + " $literal:", + " type:", + " $choose:", + " cond: true", + " then: Integer", + " else: Text"))); + } + + @Test + void nodeWriterMapsComputedObjectLanguageKeysToBlueNodeFields() { + BexValue value = BexValues.fromSimple(m( + "type", m("blueId", "HotelOrderType"), + "status", "confirmed")); + + Node node = BexNodeWriter.toNode(value); + + assertEquals("HotelOrderType", node.getType().getBlueId()); + assertEquals("confirmed", node.getProperties().get("status").getValue()); + assertFalse(node.getProperties().containsKey("type")); + } + + @Test + void frozenWriterMapsComputedObjectLanguageKeysToBlueNodeFields() { + BexValue value = BexValues.fromSimple(m( + "type", m("blueId", "HotelOrderType"), + "status", "confirmed")); + + FrozenNode node = BexFrozenWriter.toFrozen(value); + + assertEquals("HotelOrderType", node.getType().getReferenceBlueId()); + assertEquals("confirmed", node.property("status").getValue()); + assertNull(node.property("type")); + } + + @Test + void nodeWriterMapsSchemaAndValueLanguageKeysToBlueNodeFields() { + BexValue value = BexValues.fromSimple(m( + "name", "Amount", + "type", m("blueId", "Integer"), + "schema", m("required", true), + "value", bi(10))); + + Node node = BexNodeWriter.toNode(value); + + assertEquals("Amount", node.getName()); + assertEquals("Integer", node.getType().getBlueId()); + assertTrue(node.getSchema().getRequiredValue()); + assertEquals(bi(10), node.getValue()); + assertNull(node.getProperties()); + } + + @Test + void nodeWriterRejectsBlueIdReferenceWithSiblingFields() { + BexValue value = BexValues.fromSimple(m( + "blueId", "HotelOrderType", + "status", "confirmed")); + + assertThrows(BexException.class, () -> BexNodeWriter.toNode(value)); + } + + @Test + void nodeWriterRejectsPropertiesInternalField() { + BexValue value = BexValues.fromSimple(m( + "properties", m("status", "confirmed"))); + + assertThrows(BexException.class, () -> BexNodeWriter.toNode(value)); + assertThrows(BexException.class, () -> BexFrozenWriter.toFrozen(value)); + } + + @Test + void nodeWriterRejectsMixedPayloadKinds() { + BexValue value = BexValues.fromSimple(m( + "value", "confirmed", + "status", "confirmed")); + + assertThrows(BexException.class, () -> BexNodeWriter.toNode(value)); + } + + @Test + void nodeWriterRejectsSchemaAndConstraintsTogether() { + BexValue value = BexValues.fromSimple(m( + "schema", m("required", true), + "constraints", m("minLength", 1))); + + assertThrows(BexException.class, () -> BexNodeWriter.toNode(value)); + } + + @Test + void nodeWriterRejectsListControlFields() { + assertThrows(BexException.class, () -> BexNodeWriter.toNode(BexValues.fromSimple(m( + "$previous", "abc")))); + assertThrows(BexException.class, () -> BexNodeWriter.toNode(BexValues.fromSimple(m( + "$pos", 1)))); + assertThrows(BexException.class, () -> BexFrozenWriter.toFrozen(BexValues.fromSimple(m( + "$previous", "abc")))); + } + + private BexExecutionResult run(String yaml) { + return engine.compileAndExecute(source(yaml), defaultContext()); + } + + private BexExecutionResult run(Node step) { + return engine.compileAndExecute(BexProgramSource.inline(FrozenNode.fromResolvedNode(step)), defaultContext()); + } + + private BexCompiledProgram compile(String yaml) { + return engine.compile(source(yaml)); + } + + private BexProgramSource source(String yaml) { + Node node = blue.yamlToNode(yaml); + return BexProgramSource.inline(FrozenNode.fromResolvedNode(node)); + } + + private static String yaml(String... lines) { + return String.join("\n", lines); + } + + private Node pattern(String... lines) { + return blue.yamlToNode(yaml(lines)); + } +} diff --git a/src/test/java/blue/bex/BexEngineConformanceTest.java b/src/test/java/blue/bex/BexEngineConformanceTest.java index dbfbd07..abf5025 100644 --- a/src/test/java/blue/bex/BexEngineConformanceTest.java +++ b/src/test/java/blue/bex/BexEngineConformanceTest.java @@ -70,7 +70,7 @@ Collection expressionOperators() { cases.add(c("object undefined", op("$object", op("$document", "/none")), m())); cases.add(c("list undefined", op("$list", op("$document", "/none")), l())); cases.add(c("concat", op("$concat", list("a", op("$document", "/status"))), "aactive")); - cases.add(c("join", op("$join", obj("items", list("a", "b"), "separator", ":")), "a:b")); + cases.add(c("join", op("$join", obj("list", list("a", "b"), "separator", ":")), "a:b")); cases.add(c("split basic", op("$split", obj("text", "a:b:c", "separator", ":")), l("a", "b", "c"))); cases.add(c("split empty segment", op("$split", obj("text", "a::c", "separator", ":")), l("a", "", "c"))); cases.add(c("split trailing empty", op("$split", obj("text", "a:", "separator", ":")), l("a", ""))); @@ -229,7 +229,7 @@ void functionArgsUseSlotPassingWithoutMapAllocation() { "functions", obj("sum", sum, "missing", missing), "do", list( op("$let", obj("name", "first", "expr", op("$call", obj("function", "sum", "args", obj("a", 1, "b", 2, "c", 3))))), - op("$let", obj("name", "second", "expr", op("$call", obj("function", "missing", "args", obj("a", "set"))))), + op("$let", obj("name", "second", "expr", op("$call", obj("function", "missing", "args", obj("a", "set", "b", op("$document", "/missing")))))), op("$return", obj("first", op("$var", "first"), "second", op("$var", "second"))) ) ); From 454b418d04d72f098682b028c828d05ecdfcb737 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Sat, 23 May 2026 04:09:22 +0200 Subject: [PATCH 4/4] Refactor pointer handling and execution context lookups in Bex views --- .../blue/bex/api/FrozenBexDocumentView.java | 2 +- ...cessorExecutionContextBexDocumentView.java | 24 ++++++++----------- .../blue/bex/value/FrozenNodeBexValue.java | 2 +- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/main/java/blue/bex/api/FrozenBexDocumentView.java b/src/main/java/blue/bex/api/FrozenBexDocumentView.java index e3a4cb1..030d7d7 100644 --- a/src/main/java/blue/bex/api/FrozenBexDocumentView.java +++ b/src/main/java/blue/bex/api/FrozenBexDocumentView.java @@ -57,7 +57,7 @@ public String currentScopePath() { private BexValue read(FrozenNode root, String pointer) { List segments = JsonPointer.split(pointer); - FrozenNode selected = root.at(segments); + FrozenNode selected = root.at(JsonPointer.toPointer(segments)); if (selected != null) { return BexValues.frozen(selected); } diff --git a/src/main/java/blue/bex/api/ProcessorExecutionContextBexDocumentView.java b/src/main/java/blue/bex/api/ProcessorExecutionContextBexDocumentView.java index 771d18e..f7724bb 100644 --- a/src/main/java/blue/bex/api/ProcessorExecutionContextBexDocumentView.java +++ b/src/main/java/blue/bex/api/ProcessorExecutionContextBexDocumentView.java @@ -2,8 +2,8 @@ import blue.bex.value.BexValue; import blue.bex.value.BexValues; +import blue.language.model.Node; import blue.language.processor.ProcessorExecutionContext; -import blue.language.snapshot.FrozenNode; import blue.language.utils.JsonPointer; import java.util.Objects; @@ -25,26 +25,22 @@ public String resolvePointer(String authoredPointer) { @Override public BexValue canonicalAt(String absolutePointer) { - FrozenNode selected = context.canonicalFrozenAt(absolutePointer); - if (selected != null) { - return BexValues.frozen(selected); - } - FrozenNode root = context.canonicalFrozenAt("/"); - return root != null ? BexValues.frozen(root).at(JsonPointer.split(absolutePointer)) : BexValues.undefined(); + return documentAt(absolutePointer); } @Override public BexValue resolvedAt(String absolutePointer) { - FrozenNode selected = context.resolvedFrozenAt(absolutePointer); - if (selected != null) { - return BexValues.frozen(selected); - } - FrozenNode root = context.resolvedFrozenAt("/"); - return root != null ? BexValues.frozen(root).at(JsonPointer.split(absolutePointer)) : BexValues.undefined(); + return documentAt(absolutePointer); } @Override public String currentScopePath() { - return context.scopePath(); + String pointer = context.resolvePointer(""); + return pointer != null ? JsonPointer.canonicalize(pointer) : "/"; + } + + private BexValue documentAt(String absolutePointer) { + Node selected = context.documentAt(absolutePointer); + return selected != null ? BexValues.nodeSnapshot(selected) : BexValues.undefined(); } } diff --git a/src/main/java/blue/bex/value/FrozenNodeBexValue.java b/src/main/java/blue/bex/value/FrozenNodeBexValue.java index bc00e31..78b19a9 100644 --- a/src/main/java/blue/bex/value/FrozenNodeBexValue.java +++ b/src/main/java/blue/bex/value/FrozenNodeBexValue.java @@ -82,7 +82,7 @@ public BexValue get(String key) { @Override public BexValue at(List pointerSegments) { - FrozenNode selected = node.at(pointerSegments); + FrozenNode selected = node.at(JsonPointer.toPointer(pointerSegments)); if (selected != null) { return BexValues.frozen(selected); }