From 3faec69f7d220fe9d37a7b6539a261327e042d12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 14:13:59 +0000 Subject: [PATCH 1/9] Issue #136 Document experimental JTD ESM codegen CLI Co-authored-by: Simon Massey --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 04fdad8..95a7f31 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ In addition to the core backport, this repo includes implementations of more adv | --- | --- | --- | | `json-java21-jtd` | JSON Type Definition (JTD) validator implementing RFC 8927 | [JTD validator](#json-type-definition-jtd-validator) | | `json-java21-jsonpath` | JsonPath query engine over `java.util.json` values | [JsonPath](#jsonpath) | +| `jtd-esm-codegen` | Experimental JTD → ES2020 ESM validator code generator | [JTD → ESM codegen](#jtd-to-esm-validator-codegen-experimental) | We welcome contributions to these incubating modules. @@ -388,6 +389,38 @@ Features: - ✅ Discriminator tag exemption from additional properties - ✅ Stack-based validation preventing StackOverflowError +## JTD to ESM Validator Codegen (Experimental) + +This repo also contains an **experimental** CLI tool that reads a JTD schema (RFC 8927) and generates a **vanilla ES2020 module** exporting a `validate(instance)` function. The intended use case is validating JSON event payloads in the browser (for example, across tabs using `BroadcastChannel`) without a build step. + +### Supported JTD subset (flat schemas only) + +This tool deliberately supports only: +- `properties` (required properties) +- `optionalProperties` +- `type` primitives (`string`, `boolean`, `timestamp`, `int8`, `int16`, `int32`, `uint8`, `uint16`, `uint32`, `float32`, `float64`) +- `enum` +- `metadata.id` (used for the output filename prefix) + +It rejects other JTD features (`elements`, `values`, `discriminator`/`mapping`, `ref`/`definitions`) and also rejects **nested `properties`** (object schemas inside properties). + +When rejected, the error message is: + +`Unsupported JTD feature: . This experimental tool only supports flat schemas with properties, optionalProperties, type, and enum.` + +### Build and run + +```bash +./mvnw -pl jtd-esm-codegen -am package +java -jar ./jtd-esm-codegen/target/jtd-esm-codegen.jar schema.jtd.json +``` + +The output file is written to the current directory as: + +`-.js` + +Where `` is the first 8 characters of the SHA-256 hash of the input schema file bytes. + ## Building Requires JDK 21 or later. Build with Maven: From 7b71cc53442c930117dff11a0549c2a57010ed04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 14:25:03 +0000 Subject: [PATCH 2/9] Issue #136 Add jtd-esm-codegen module and CLI generator Co-authored-by: Simon Massey --- jtd-esm-codegen/pom.xml | 112 ++++++++ .../json/jtd/codegen/EsmRenderer.java | 244 ++++++++++++++++++ .../simbo1905/json/jtd/codegen/JtdAst.java | 30 +++ .../simbo1905/json/jtd/codegen/JtdParser.java | 198 ++++++++++++++ .../json/jtd/codegen/JtdToEsmCli.java | 61 +++++ .../simbo1905/json/jtd/codegen/Sha256.java | 63 +++++ .../codegen/JtdEsmCodegenLoggingConfig.java | 43 +++ .../json/jtd/codegen/JtdToEsmCodegenTest.java | 171 ++++++++++++ .../resources/odc-chart-event-v1.jtd.json | 16 ++ pom.xml | 1 + 10 files changed, 939 insertions(+) create mode 100644 jtd-esm-codegen/pom.xml create mode 100644 jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java create mode 100644 jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java create mode 100644 jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java create mode 100644 jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java create mode 100644 jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/Sha256.java create mode 100644 jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmCodegenLoggingConfig.java create mode 100644 jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java create mode 100644 jtd-esm-codegen/src/test/resources/odc-chart-event-v1.jtd.json diff --git a/jtd-esm-codegen/pom.xml b/jtd-esm-codegen/pom.xml new file mode 100644 index 0000000..0d390bb --- /dev/null +++ b/jtd-esm-codegen/pom.xml @@ -0,0 +1,112 @@ + + + 4.0.0 + + + io.github.simbo1905.json + parent + 0.1.9 + + + jtd-esm-codegen + jar + + JTD to ES2020 Validator Code Generator (Experimental) + https://simbo1905.github.io/java.util.json.Java21/ + + scm:git:https://github.com/simbo1905/java.util.json.Java21.git + scm:git:git@github.com:simbo1905/java.util.json.Java21.git + https://github.com/simbo1905/java.util.json.Java21 + HEAD + + Experimental CLI that generates vanilla ES2020 ESM validators from a deliberately-limited JTD (RFC 8927) subset for browser payload validation. + + + UTF-8 + 21 + 3.6.0 + + + + + io.github.simbo1905.json + java.util.json + ${project.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + + + jtd-esm-codegen + + + + org.apache.maven.plugins + maven-compiler-plugin + + 21 + + -Xlint:all + -Werror + -Xdiags:verbose + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -ea + + + + + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + + + package + + shade + + + false + + + io.github.simbo1905.json.jtd.codegen.JtdToEsmCli + + + + + + + + + + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java new file mode 100644 index 0000000..d35f21b --- /dev/null +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java @@ -0,0 +1,244 @@ +package io.github.simbo1905.json.jtd.codegen; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +import static io.github.simbo1905.json.jtd.codegen.JtdNode.*; + +/// Renders an ES2020 JavaScript module exporting `validate(instance)`. +/// +/// The generated validator: +/// - Treats the root as a JSON object (non-array, non-null) +/// - Checks required `properties` presence and validates leaf `type`/`enum` +/// - Checks optional `optionalProperties` only when present +/// - Ignores additional properties (RFC 8927 "properties" form allows them) +final class EsmRenderer { + private EsmRenderer() {} + + static String render(SchemaNode schema, String sha256Hex, String sha256Prefix8) { + Objects.requireNonNull(schema, "schema must not be null"); + Objects.requireNonNull(sha256Hex, "sha256Hex must not be null"); + Objects.requireNonNull(sha256Prefix8, "sha256Prefix8 must not be null"); + + final var sb = new StringBuilder(8 * 1024); + + sb.append("// ").append(schema.id()).append("-").append(sha256Prefix8).append(".js\n"); + sb.append("// Generated from JTD schema: ").append(schema.id()).append("\n"); + sb.append("// SHA-256: ").append(sha256Prefix8).append("...").append("\n"); + sb.append("// WARNING: Experimental - flat schemas only\n"); + sb.append("// Generated at: ").append(Instant.now()).append("\n\n"); + + sb.append("const SCHEMA_ID = ").append(jsString(schema.id())).append(";\n\n"); + + final var enumConsts = enumConstants(schema); + for (var e : enumConsts.entrySet()) { + sb.append("const ").append(e.getKey()).append(" = ").append(jsStringArray(e.getValue())).append(";\n"); + } + if (!enumConsts.isEmpty()) { + sb.append("\n"); + } + + sb.append("function isString(v) { return typeof v === \"string\"; }\n"); + sb.append("function isBoolean(v) { return typeof v === \"boolean\"; }\n"); + sb.append("function isTimestamp(v) { return typeof v === \"string\" && !Number.isNaN(Date.parse(v)); }\n"); + sb.append("function isNumber(v) { return typeof v === \"number\" && Number.isFinite(v); }\n"); + sb.append("function isInt(v) { return Number.isInteger(v); }\n"); + sb.append("function isIntRange(v, min, max) { return isInt(v) && v >= min && v <= max; }\n\n"); + + sb.append("export function validate(instance) {\n"); + sb.append(" const errors = [];\n\n"); + + sb.append(" if (instance === null || typeof instance !== \"object\" || Array.isArray(instance)) {\n"); + sb.append(" errors.push({ instancePath: \"\", schemaPath: \"\" });\n"); + sb.append(" return errors;\n"); + sb.append(" }\n\n"); + + final var required = new TreeMap<>(schema.properties()); + for (var p : required.values()) { + renderRequiredProperty(sb, p, enumConsts, "/properties"); + sb.append("\n"); + } + + final var optional = new TreeMap<>(schema.optionalProperties()); + for (var p : optional.values()) { + renderOptionalProperty(sb, p, enumConsts, "/optionalProperties"); + sb.append("\n"); + } + + sb.append(" return errors;\n"); + sb.append("}\n\n"); + sb.append("export { SCHEMA_ID };\n"); + + return sb.toString(); + } + + private static Map> enumConstants(SchemaNode schema) { + final var out = new LinkedHashMap>(); + final var allProps = new ArrayList(); + allProps.addAll(schema.properties().values()); + allProps.addAll(schema.optionalProperties().values()); + allProps.sort(Comparator.comparing(PropertyNode::name)); + + int i = 0; + for (var p : allProps) { + if (p.type() instanceof EnumNode en) { + final String base = "ENUM_" + toConstName(p.name()); + String name = base; + while (out.containsKey(name)) { + i++; + name = base + "_" + i; + } + out.put(name, en.values()); + } + } + return out; + } + + private static void renderRequiredProperty(StringBuilder sb, PropertyNode p, Map> enumConsts, String schemaPrefix) { + final String prop = p.name(); + final String schemaPathProp = schemaPrefix + "/" + pointerEscape(prop); + + sb.append(" // Required: ").append(prop).append("\n"); + sb.append(" if (!(\"").append(jsStringRaw(prop)).append("\" in instance)) {\n"); + sb.append(" errors.push({ instancePath: \"\", schemaPath: \"").append(schemaPathProp).append("\" });\n"); + sb.append(" } else {\n"); + + renderLeafCheck(sb, "instance[" + jsString(prop) + "]", "/" + pointerEscape(prop), schemaPathProp, p.type(), enumConsts); + + sb.append(" }\n"); + } + + private static void renderOptionalProperty(StringBuilder sb, PropertyNode p, Map> enumConsts, String schemaPrefix) { + final String prop = p.name(); + final String schemaPathProp = schemaPrefix + "/" + pointerEscape(prop); + + sb.append(" // Optional: ").append(prop).append("\n"); + sb.append(" if (\"").append(jsStringRaw(prop)).append("\" in instance) {\n"); + + renderLeafCheck(sb, "instance[" + jsString(prop) + "]", "/" + pointerEscape(prop), schemaPathProp, p.type(), enumConsts); + + sb.append(" }\n"); + } + + private static void renderLeafCheck( + StringBuilder sb, + String valueExpr, + String instancePath, + String schemaPathProp, + JtdNode node, + Map> enumConsts + ) { + switch (node) { + case EmptyNode ignored -> { + // Empty schema accepts any value. + } + case TypeNode tn -> { + final String type = tn.type(); + final String check = typeCheckExpr(type, valueExpr); + sb.append(" if (!(").append(check).append(")) {\n"); + sb.append(" errors.push({ instancePath: \"").append(instancePath).append("\", schemaPath: \"") + .append(schemaPathProp).append("/type\" });\n"); + sb.append(" }\n"); + } + case EnumNode en -> { + final String constName = findEnumConst(enumConsts, en.values()); + sb.append(" if (!").append(constName).append(".includes(").append(valueExpr).append(")) {\n"); + sb.append(" errors.push({ instancePath: \"").append(instancePath).append("\", schemaPath: \"") + .append(schemaPathProp).append("/enum\" });\n"); + sb.append(" }\n"); + } + default -> throw new IllegalStateException("Unexpected node in leaf position: " + node); + } + } + + private static String findEnumConst(Map> enumConsts, List values) { + for (var e : enumConsts.entrySet()) { + if (e.getValue().equals(values)) { + return e.getKey(); + } + } + throw new IllegalStateException("Enum constants map missing values: " + values); + } + + private static String typeCheckExpr(String type, String valueExpr) { + return switch (type) { + case "string" -> "isString(" + valueExpr + ")"; + case "boolean" -> "isBoolean(" + valueExpr + ")"; + case "timestamp" -> "isTimestamp(" + valueExpr + ")"; + case "float32", "float64" -> "isNumber(" + valueExpr + ")"; + case "int8" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -128, 127)"; + case "uint8" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 255)"; + case "int16" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -32768, 32767)"; + case "uint16" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 65535)"; + case "int32" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -2147483648, 2147483647)"; + case "uint32" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 4294967295)"; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + private static String toConstName(String propName) { + final var sb = new StringBuilder(propName.length() + 8); + for (int i = 0; i < propName.length(); i++) { + final char c = propName.charAt(i); + if (c >= 'a' && c <= 'z') { + sb.append((char) (c - 32)); + } else if (c >= 'A' && c <= 'Z') { + sb.append(c); + } else if (c >= '0' && c <= '9') { + sb.append(c); + } else { + sb.append('_'); + } + } + if (sb.isEmpty()) { + return "PROP"; + } + if (sb.charAt(0) >= '0' && sb.charAt(0) <= '9') { + sb.insert(0, "P_"); + } + return sb.toString(); + } + + /// Escape a JSON Pointer path segment. + private static String pointerEscape(String s) { + return s.replace("~", "~0").replace("/", "~1"); + } + + private static String jsString(String s) { + return "\"" + jsStringRaw(s) + "\""; + } + + private static String jsStringRaw(String s) { + final var sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + switch (c) { + case '\\' -> sb.append("\\\\"); + case '"' -> sb.append("\\\""); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> sb.append(c); + } + } + return sb.toString(); + } + + private static String jsStringArray(List values) { + final var sb = new StringBuilder(values.size() * 16); + sb.append("["); + for (int i = 0; i < values.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(jsString(values.get(i))); + } + sb.append("]"); + return sb.toString(); + } +} + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java new file mode 100644 index 0000000..9231a80 --- /dev/null +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java @@ -0,0 +1,30 @@ +package io.github.simbo1905.json.jtd.codegen; + +import java.util.List; +import java.util.Map; + +/// Minimal AST for the experimental "flat JTD → ESM validator" code generator. +/// +/// Supported JTD subset: +/// - root: `properties`, `optionalProperties`, `metadata.id` +/// - leaf: `{}` (empty form), `{ "type": ... }`, `{ "enum": [...] }` +sealed interface JtdNode permits JtdNode.SchemaNode, JtdNode.PropertyNode, JtdNode.TypeNode, JtdNode.EnumNode, JtdNode.EmptyNode { + + record SchemaNode( + String id, // metadata.id + Map properties, + Map optionalProperties + ) implements JtdNode {} + + record PropertyNode(String name, JtdNode type) implements JtdNode {} + + /// JTD primitive type keyword as a string, e.g. "string", "int32", "timestamp". + record TypeNode(String type) implements JtdNode {} + + /// Enum values (strings only in RFC 8927). + record EnumNode(List values) implements JtdNode {} + + /// Empty form `{}`: accepts any JSON value. + record EmptyNode() implements JtdNode {} +} + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java new file mode 100644 index 0000000..f96bb18 --- /dev/null +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java @@ -0,0 +1,198 @@ +package io.github.simbo1905.json.jtd.codegen; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import static io.github.simbo1905.json.jtd.codegen.JtdNode.*; + +/// Parses a deliberately-limited subset of JTD (RFC 8927) for code generation. +/// +/// This parser is *not* the general-purpose validator in `json-java21-jtd`. +/// It exists to support the experimental code generator and intentionally rejects +/// most of JTD. +final class JtdParser { + private JtdParser() {} + + static SchemaNode parseString(String jtdJson) { + Objects.requireNonNull(jtdJson, "jtdJson must not be null"); + return parseValue(Json.parse(jtdJson)); + } + + static SchemaNode parseValue(JsonValue rootValue) { + Objects.requireNonNull(rootValue, "rootValue must not be null"); + if (!(rootValue instanceof JsonObject root)) { + throw new IllegalArgumentException("JTD schema must be a JSON object"); + } + + rejectUnsupportedKeys(root, "elements", "values", "discriminator", "mapping", "ref", "definitions"); + + final var metadata = getObjectOrNull(root, "metadata"); + if (metadata == null) { + throw new IllegalArgumentException("JTD schema missing required key: metadata.id"); + } + final var id = getString(metadata, "id"); + if (id.isBlank()) { + throw new IllegalArgumentException("metadata.id must be non-blank"); + } + + final Map properties = parsePropertiesBlock(root, "properties"); + final Map optionalProperties = parsePropertiesBlock(root, "optionalProperties"); + + // Reject additional unknown top-level keys (keeps the tool intentionally strict/limited). + final Set allowedRoot = Set.of("properties", "optionalProperties", "metadata"); + for (String k : root.members().keySet()) { + if (!allowedRoot.contains(k)) { + throw unsupported("unknown key '" + k + "'"); + } + } + + return new SchemaNode(id, Map.copyOf(properties), Map.copyOf(optionalProperties)); + } + + private static Map parsePropertiesBlock(JsonObject root, String key) { + final var block = getObjectOrNull(root, key); + if (block == null) { + return Map.of(); + } + + final var out = new LinkedHashMap(); + for (var e : block.members().entrySet()) { + final String propName = e.getKey(); + final JsonValue propSchemaValue = e.getValue(); + if (!(propSchemaValue instanceof JsonObject propSchemaObj)) { + throw new IllegalArgumentException("Schema for '" + key + "." + propName + "' must be a JSON object"); + } + final JtdNode type = parsePropertySchema(propName, propSchemaObj, key); + out.put(propName, new PropertyNode(propName, type)); + } + return out; + } + + private static JtdNode parsePropertySchema(String propName, JsonObject propSchema, String containerKey) { + // Explicitly reject unsupported features inside property schemas too. + rejectUnsupportedKeys(propSchema, "elements", "values", "discriminator", "mapping", "ref", "definitions"); + + if (propSchema.members().isEmpty()) { + return new EmptyNode(); + } + + if (propSchema.members().containsKey("properties") || propSchema.members().containsKey("optionalProperties")) { + throw unsupported("properties"); + } + + // Only allow leaf forms: type or enum. + final boolean hasType = propSchema.members().containsKey("type"); + final boolean hasEnum = propSchema.members().containsKey("enum"); + if (hasType && hasEnum) { + throw new IllegalArgumentException("Property '" + propName + "' must not specify both 'type' and 'enum'"); + } + if (!hasType && !hasEnum) { + // Any other leaf form is unsupported for this tool. + final var keys = propSchema.members().keySet().stream().sorted().toList(); + throw unsupported("schema keys " + keys); + } + + if (hasType) { + final var typeStr = stringValue(propSchema.members().get("type"), containerKey, propName, "type"); + final var normalized = typeStr.toLowerCase(Locale.ROOT).trim(); + if (!ALLOWED_TYPES.contains(normalized)) { + throw new IllegalArgumentException("Unsupported JTD type: '" + typeStr + "', expected one of: " + ALLOWED_TYPES); + } + rejectUnknownKeys(propSchema, Set.of("type")); + return new TypeNode(normalized); + } + + final var enumValues = enumValues(propSchema.members().get("enum"), containerKey, propName); + rejectUnknownKeys(propSchema, Set.of("enum")); + return new EnumNode(List.copyOf(enumValues)); + } + + private static void rejectUnknownKeys(JsonObject obj, Set allowedKeys) { + for (String k : obj.members().keySet()) { + if (!allowedKeys.contains(k)) { + throw unsupported("unknown key '" + k + "'"); + } + } + } + + private static void rejectUnsupportedKeys(JsonObject obj, String... keys) { + for (String k : keys) { + if (obj.members().containsKey(k)) { + throw unsupported(k); + } + } + } + + private static JsonObject getObjectOrNull(JsonObject obj, String key) { + final var v = obj.members().get(key); + if (v == null) { + return null; + } + if (!(v instanceof JsonObject o)) { + throw new IllegalArgumentException("Expected '" + key + "' to be an object"); + } + return o; + } + + private static String getString(JsonObject obj, String key) { + final var v = obj.members().get(key); + if (!(v instanceof JsonString js)) { + throw new IllegalArgumentException("Expected 'metadata." + key + "' to be a string"); + } + return js.string(); + } + + private static String stringValue(JsonValue v, String container, String name, String key) { + if (!(v instanceof JsonString js)) { + throw new IllegalArgumentException("Expected '" + container + "." + name + "." + key + "' to be a string"); + } + return js.string(); + } + + private static List enumValues(JsonValue v, String containerKey, String propName) { + if (!(v instanceof JsonArray arr)) { + throw new IllegalArgumentException("Expected '" + containerKey + "." + propName + ".enum' to be an array"); + } + final var out = new ArrayList(); + for (int i = 0; i < arr.elements().size(); i++) { + final var el = arr.element(i); + if (!(el instanceof JsonString js)) { + throw new IllegalArgumentException("Expected '" + containerKey + "." + propName + ".enum[" + i + "]' to be a string"); + } + out.add(js.string()); + } + return out; + } + + private static IllegalArgumentException unsupported(String feature) { + return new IllegalArgumentException( + "Unsupported JTD feature: " + feature + ". This experimental tool only supports flat schemas with properties, optionalProperties, type, and enum." + ); + } + + private static final Set ALLOWED_TYPES = Set.of( + "string", + "boolean", + "timestamp", + "int8", + "int16", + "int32", + "uint8", + "uint16", + "uint32", + "float32", + "float64" + ); +} + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java new file mode 100644 index 0000000..4773588 --- /dev/null +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java @@ -0,0 +1,61 @@ +package io.github.simbo1905.json.jtd.codegen; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +import static io.github.simbo1905.json.jtd.codegen.JtdNode.SchemaNode; + +/// CLI entry point for generating an ES2020 ESM validator from a flat JTD schema. +/// +/// Usage: +/// `java -jar jtd-esm-codegen.jar schema.jtd.json` +public final class JtdToEsmCli { + private JtdToEsmCli() {} + + public static void main(String[] args) { + final var err = new PrintWriter(System.err, true, StandardCharsets.UTF_8); + + if (args == null || args.length != 1) { + err.println("Usage: java -jar jtd-esm-codegen.jar "); + System.exit(2); + return; + } + + try { + final var in = Path.of(args[0]); + final var out = run(in, Path.of(".")); + System.out.println(out.toAbsolutePath()); + } catch (IllegalArgumentException e) { + err.println(e.getMessage()); + System.exit(2); + } catch (Exception e) { + e.printStackTrace(err); + System.exit(1); + } + } + + static Path run(Path schemaFile, Path outputDir) throws IOException { + Objects.requireNonNull(schemaFile, "schemaFile must not be null"); + Objects.requireNonNull(outputDir, "outputDir must not be null"); + + final byte[] digest = Sha256.digest(schemaFile); + final String shaHex = Sha256.hex(digest); + final String shaPrefix8 = Sha256.hexPrefix8(digest); + + final String json = Files.readString(schemaFile, StandardCharsets.UTF_8); + final SchemaNode schema = JtdParser.parseString(json); + + final String js = EsmRenderer.render(schema, shaHex, shaPrefix8); + final String fileName = schema.id() + "-" + shaPrefix8 + ".js"; + + Files.createDirectories(outputDir); + final Path out = outputDir.resolve(fileName); + Files.writeString(out, js, StandardCharsets.UTF_8); + return out; + } +} + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/Sha256.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/Sha256.java new file mode 100644 index 0000000..fda2f41 --- /dev/null +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/Sha256.java @@ -0,0 +1,63 @@ +package io.github.simbo1905.json.jtd.codegen; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/// SHA-256 helpers for deterministic output naming. +final class Sha256 { + private Sha256() {} + + static byte[] digest(Path file) throws IOException { + try (InputStream in = Files.newInputStream(file)) { + return digest(in); + } + } + + static byte[] digest(InputStream in) throws IOException { + final MessageDigest md = messageDigest(); + final byte[] buf = new byte[16 * 1024]; + for (int r; (r = in.read(buf)) >= 0; ) { + if (r > 0) { + md.update(buf, 0, r); + } + } + return md.digest(); + } + + static String hex(byte[] digest) { + final var out = new StringBuilder(digest.length * 2); + for (byte b : digest) { + out.append(HEX[(b >>> 4) & 0x0F]).append(HEX[b & 0x0F]); + } + return out.toString(); + } + + static String hexPrefix8(byte[] digest) { + // 8 hex chars == 4 bytes. + if (digest.length < 4) { + throw new IllegalArgumentException("digest too short: " + digest.length); + } + final var out = new StringBuilder(8); + for (int i = 0; i < 4; i++) { + final byte b = digest[i]; + out.append(HEX[(b >>> 4) & 0x0F]).append(HEX[b & 0x0F]); + } + return out.toString(); + } + + private static MessageDigest messageDigest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is required by the Java platform. + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static final char[] HEX = "0123456789abcdef".toCharArray(); +} + diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmCodegenLoggingConfig.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmCodegenLoggingConfig.java new file mode 100644 index 0000000..3bfbe39 --- /dev/null +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmCodegenLoggingConfig.java @@ -0,0 +1,43 @@ +package io.github.simbo1905.json.jtd.codegen; + +import org.junit.jupiter.api.BeforeAll; + +import java.util.Locale; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/// Base class for JTD ESM codegen tests that configures JUL logging from system properties. +/// All test classes should extend this class to enable consistent logging behavior. +public class JtdEsmCodegenLoggingConfig { + @BeforeAll + static void enableJulDebug() { + final var log = Logger.getLogger(JtdEsmCodegenLoggingConfig.class.getName()); + final Logger root = Logger.getLogger(""); + + final String levelProp = System.getProperty("java.util.logging.ConsoleHandler.level"); + Level targetLevel = Level.INFO; + if (levelProp != null) { + try { + targetLevel = Level.parse(levelProp.trim()); + } catch (IllegalArgumentException ex) { + try { + targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + log.warning(() -> "Unrecognized logging level from 'java.util.logging.ConsoleHandler.level': " + levelProp); + } + } + } + + if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) { + root.setLevel(targetLevel); + } + for (Handler handler : root.getHandlers()) { + final Level handlerLevel = handler.getLevel(); + if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) { + handler.setLevel(targetLevel); + } + } + } +} + diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java new file mode 100644 index 0000000..bb61ee8 --- /dev/null +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java @@ -0,0 +1,171 @@ +package io.github.simbo1905.json.jtd.codegen; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +final class JtdToEsmCodegenTest extends JtdEsmCodegenLoggingConfig { + private static final Logger LOG = Logger.getLogger(JtdToEsmCodegenTest.class.getName()); + + @Test + void parsesFlatSchemaSubset() throws Exception { + LOG.info(() -> "Running parsesFlatSchemaSubset"); + + final var json = Files.readString(resource("odc-chart-event-v1.jtd.json"), StandardCharsets.UTF_8); + final var schema = JtdParser.parseString(json); + + assertThat(schema.id()).isEqualTo("odc-chart-event-v1"); + assertThat(schema.properties().keySet()).containsExactlyInAnyOrder("src", "action", "domain", "data"); + assertThat(schema.optionalProperties().keySet()).containsExactly("ts"); + } + + @Test + void rejectsUnsupportedFeaturesWithRequiredMessage() { + LOG.info(() -> "Running rejectsUnsupportedFeaturesWithRequiredMessage"); + + final var bad = """ + { + "properties": { "x": { "type": "string" } }, + "elements": { "type": "string" }, + "metadata": { "id": "bad" } + } + """; + + assertThatThrownBy(() -> JtdParser.parseString(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Unsupported JTD feature: elements. This experimental tool only supports flat schemas with properties, optionalProperties, type, and enum."); + } + + @Test + void rendersEsmModuleWithValidateExport() throws Exception { + LOG.info(() -> "Running rendersEsmModuleWithValidateExport"); + + final var json = Files.readString(resource("odc-chart-event-v1.jtd.json"), StandardCharsets.UTF_8); + final var schema = JtdParser.parseString(json); + final var digest = Sha256.digest(resource("odc-chart-event-v1.jtd.json")); + final var shaHex = Sha256.hex(digest); + final var shaPrefix8 = Sha256.hexPrefix8(digest); + + final var js = EsmRenderer.render(schema, shaHex, shaPrefix8); + + assertThat(js).contains("export function validate(instance)"); + assertThat(js).contains("const SCHEMA_ID = \"odc-chart-event-v1\""); + assertThat(js).contains("on_click"); + assertThat(js).contains("schemaPath: \"/properties/src/type\""); + assertThat(js).contains("schemaPath: \"/properties/action/enum\""); + assertThat(js).contains("schemaPath: \"/optionalProperties/ts/type\""); + } + + @Test + void generatedValidateMatchesExampleCasesWhenNodeAvailable() throws Exception { + LOG.info(() -> "Running generatedValidateMatchesExampleCasesWhenNodeAvailable"); + + assumeTrue(isNodeAvailable(), "Node.js is required for executing generated ES modules in tests"); + + final Path temp = Files.createTempDirectory("jtd-esm-codegen-test-"); + Files.writeString(temp.resolve("package.json"), "{ \"type\": \"module\" }", StandardCharsets.UTF_8); + + final Path schemaFile = resource("odc-chart-event-v1.jtd.json"); + final Path outJs = JtdToEsmCli.run(schemaFile, temp); + + final var runner = """ + import { validate } from %s; + + const cases = [ + { name: "valid_string_data", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS" } }, + { name: "valid_number_data", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: 123 } }, + { name: "valid_with_ts", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS", ts: "2025-02-05T10:30:00Z" } }, + + { name: "missing_src", instance: { action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS" } }, + { name: "src_wrong_type", instance: { src: 123, action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS" } }, + { name: "action_invalid", instance: { src: "bump_chart", action: "INVALID", domain: "mbl_comparison.lender", data: "LEEDS" } }, + { name: "ts_invalid", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS", ts: "not-a-date" } }, + ]; + + const results = cases.map(c => ({ name: c.name, errors: validate(c.instance) })); + console.log(JSON.stringify(results)); + """.formatted(jsImportSpecifier(outJs)); + + Files.writeString(temp.resolve("runner.mjs"), runner, StandardCharsets.UTF_8); + + final var p = new ProcessBuilder("node", temp.resolve("runner.mjs").toString()) + .directory(temp.toFile()) + .redirectErrorStream(true) + .start(); + final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + final int code = p.waitFor(); + + assertThat(code).as("node exit code; output:\n%s", output).isEqualTo(0); + + final JsonArray results = (JsonArray) Json.parse(output); + assertThat(findErrors(results, "valid_string_data")).isEmpty(); + assertThat(findErrors(results, "valid_number_data")).isEmpty(); + assertThat(findErrors(results, "valid_with_ts")).isEmpty(); + + assertThat(findErrors(results, "missing_src")) + .containsExactly(Map.of("instancePath", "", "schemaPath", "/properties/src")); + + assertThat(findErrors(results, "src_wrong_type")) + .containsExactly(Map.of("instancePath", "/src", "schemaPath", "/properties/src/type")); + + assertThat(findErrors(results, "action_invalid")) + .containsExactly(Map.of("instancePath", "/action", "schemaPath", "/properties/action/enum")); + + assertThat(findErrors(results, "ts_invalid")) + .containsExactly(Map.of("instancePath", "/ts", "schemaPath", "/optionalProperties/ts/type")); + } + + private static Path resource(String name) { + return Path.of("src", "test", "resources", name).toAbsolutePath(); + } + + private static boolean isNodeAvailable() { + try { + final var p = new ProcessBuilder("node", "--version") + .redirectErrorStream(true) + .start(); + final int code = p.waitFor(); + return code == 0; + } catch (Exception ignored) { + return false; + } + } + + private static String jsImportSpecifier(Path outJs) { + // With `package.json` { type: "module" } present, .js is treated as ESM. + final var rel = "./" + outJs.getFileName(); + return JsonString.of(rel).toString(); + } + + private static List> findErrors(JsonArray results, String caseName) { + for (JsonValue v : results.elements()) { + final var obj = (JsonObject) v; + if (obj.get("name") instanceof JsonString js && js.string().equals(caseName)) { + final var errors = (JsonArray) obj.get("errors"); + return errors.elements().stream() + .map(e -> (JsonObject) e) + .map(e -> Map.of( + "instancePath", ((JsonString) e.get("instancePath")).string(), + "schemaPath", ((JsonString) e.get("schemaPath")).string() + )) + .toList(); + } + } + throw new AssertionError("Case not found: " + caseName); + } +} + diff --git a/jtd-esm-codegen/src/test/resources/odc-chart-event-v1.jtd.json b/jtd-esm-codegen/src/test/resources/odc-chart-event-v1.jtd.json new file mode 100644 index 0000000..2b90c2e --- /dev/null +++ b/jtd-esm-codegen/src/test/resources/odc-chart-event-v1.jtd.json @@ -0,0 +1,16 @@ +{ + "properties": { + "src": { "type": "string" }, + "action": { "enum": ["on_click", "on_hover", "on_select", "on_deselect"] }, + "domain": { "type": "string" }, + "data": {} + }, + "optionalProperties": { + "ts": { "type": "timestamp" } + }, + "metadata": { + "id": "odc-chart-event-v1", + "description": "Event payload for ODC-bound chart interactions" + } +} + diff --git a/pom.xml b/pom.xml index 16d8439..2926cf5 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,7 @@ json-compatibility-suite json-java21-jtd json-java21-jsonpath + jtd-esm-codegen From 82d8ddd5a8ebc1ff74db0f95092640447cfb0a7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 14:27:39 +0000 Subject: [PATCH 3/9] Issue #136 Fix JTD AST packaging to avoid javac warnings Co-authored-by: Simon Massey --- .../simbo1905/json/jtd/codegen/EsmRenderer.java | 2 +- .../github/simbo1905/json/jtd/codegen/JtdAst.java | 15 +++++++++------ .../simbo1905/json/jtd/codegen/JtdParser.java | 2 +- .../simbo1905/json/jtd/codegen/JtdToEsmCli.java | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java index d35f21b..44e1335 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java @@ -9,7 +9,7 @@ import java.util.Objects; import java.util.TreeMap; -import static io.github.simbo1905.json.jtd.codegen.JtdNode.*; +import static io.github.simbo1905.json.jtd.codegen.JtdAst.*; /// Renders an ES2020 JavaScript module exporting `validate(instance)`. /// diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java index 9231a80..7b73991 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java @@ -8,23 +8,26 @@ /// Supported JTD subset: /// - root: `properties`, `optionalProperties`, `metadata.id` /// - leaf: `{}` (empty form), `{ "type": ... }`, `{ "enum": [...] }` -sealed interface JtdNode permits JtdNode.SchemaNode, JtdNode.PropertyNode, JtdNode.TypeNode, JtdNode.EnumNode, JtdNode.EmptyNode { +public final class JtdAst { + private JtdAst() {} - record SchemaNode( + public sealed interface JtdNode permits SchemaNode, PropertyNode, TypeNode, EnumNode, EmptyNode {} + + public record SchemaNode( String id, // metadata.id Map properties, Map optionalProperties ) implements JtdNode {} - record PropertyNode(String name, JtdNode type) implements JtdNode {} + public record PropertyNode(String name, JtdNode type) implements JtdNode {} /// JTD primitive type keyword as a string, e.g. "string", "int32", "timestamp". - record TypeNode(String type) implements JtdNode {} + public record TypeNode(String type) implements JtdNode {} /// Enum values (strings only in RFC 8927). - record EnumNode(List values) implements JtdNode {} + public record EnumNode(List values) implements JtdNode {} /// Empty form `{}`: accepts any JSON value. - record EmptyNode() implements JtdNode {} + public record EmptyNode() implements JtdNode {} } diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java index f96bb18..19b404f 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java @@ -14,7 +14,7 @@ import java.util.Objects; import java.util.Set; -import static io.github.simbo1905.json.jtd.codegen.JtdNode.*; +import static io.github.simbo1905.json.jtd.codegen.JtdAst.*; /// Parses a deliberately-limited subset of JTD (RFC 8927) for code generation. /// diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java index 4773588..bb8aa3b 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java @@ -7,7 +7,7 @@ import java.nio.file.Path; import java.util.Objects; -import static io.github.simbo1905.json.jtd.codegen.JtdNode.SchemaNode; +import static io.github.simbo1905.json.jtd.codegen.JtdAst.SchemaNode; /// CLI entry point for generating an ES2020 ESM validator from a flat JTD schema. /// From 5975aab5c1c34e9d36d9b62c1cb73a271d3ed082 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 14:29:53 +0000 Subject: [PATCH 4/9] Issue #136 Add nightly release workflow for jtd-esm-codegen Co-authored-by: Simon Massey --- .github/workflows/jtd-esm-codegen-release.yml | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .github/workflows/jtd-esm-codegen-release.yml diff --git a/.github/workflows/jtd-esm-codegen-release.yml b/.github/workflows/jtd-esm-codegen-release.yml new file mode 100644 index 0000000..7ef2115 --- /dev/null +++ b/.github/workflows/jtd-esm-codegen-release.yml @@ -0,0 +1,142 @@ +name: JTD-ESM-Codegen Nightly + +on: + schedule: + - cron: '0 3 * * *' # 3 AM UTC daily + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-uber-jar: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + cache: 'maven' + - name: Build uber JAR + run: | + ./mvnw -pl jtd-esm-codegen -am package + cp jtd-esm-codegen/target/jtd-esm-codegen.jar jtd-esm-codegen.jar + - uses: actions/upload-artifact@v4 + with: + name: uber-jar + path: jtd-esm-codegen.jar + + native-linux: + needs: build-uber-jar + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: uber-jar + path: . + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + components: 'native-image' + cache: 'maven' + - name: Build native image + run: | + native-image --no-fallback -jar jtd-esm-codegen.jar jtd-esm-codegen-linux-amd64 + chmod +x jtd-esm-codegen-linux-amd64 + - uses: actions/upload-artifact@v4 + with: + name: native-linux-amd64 + path: jtd-esm-codegen-linux-amd64 + + native-windows: + needs: build-uber-jar + runs-on: windows-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: uber-jar + path: . + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + components: 'native-image' + cache: 'maven' + - name: Build native image + run: | + native-image --no-fallback -jar jtd-esm-codegen.jar jtd-esm-codegen-windows-amd64 + - uses: actions/upload-artifact@v4 + with: + name: native-windows-amd64 + path: jtd-esm-codegen-windows-amd64.exe + + native-macos-intel: + needs: build-uber-jar + runs-on: macos-13 # Intel + steps: + - uses: actions/download-artifact@v4 + with: + name: uber-jar + path: . + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + components: 'native-image' + cache: 'maven' + - name: Build native image + run: | + native-image --no-fallback -jar jtd-esm-codegen.jar jtd-esm-codegen-macos-amd64 + chmod +x jtd-esm-codegen-macos-amd64 + - uses: actions/upload-artifact@v4 + with: + name: native-macos-amd64 + path: jtd-esm-codegen-macos-amd64 + + native-macos-arm: + needs: build-uber-jar + runs-on: macos-14 # Apple Silicon + steps: + - uses: actions/download-artifact@v4 + with: + name: uber-jar + path: . + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} + components: 'native-image' + cache: 'maven' + - name: Build native image + run: | + native-image --no-fallback -jar jtd-esm-codegen.jar jtd-esm-codegen-macos-arm64 + chmod +x jtd-esm-codegen-macos-arm64 + - uses: actions/upload-artifact@v4 + with: + name: native-macos-arm64 + path: jtd-esm-codegen-macos-arm64 + + release: + needs: [native-linux, native-windows, native-macos-intel, native-macos-arm] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + - name: Create nightly release + uses: softprops/action-gh-release@v1 + with: + tag_name: nightly-${{ github.run_number }} + name: Nightly Build ${{ github.run_number }} + prerelease: true + files: | + native-linux-amd64/jtd-esm-codegen-linux-amd64 + native-windows-amd64/jtd-esm-codegen-windows-amd64.exe + native-macos-amd64/jtd-esm-codegen-macos-amd64 + native-macos-arm64/jtd-esm-codegen-macos-arm64 + uber-jar/jtd-esm-codegen.jar + From be9c170353ecf2c0cc9c9703152060c0d97ac7a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Feb 2026 14:30:44 +0000 Subject: [PATCH 5/9] Issue #136 Make generated ESM output deterministic Co-authored-by: Simon Massey --- .../io/github/simbo1905/json/jtd/codegen/EsmRenderer.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java index 44e1335..16e7dc3 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java @@ -1,6 +1,5 @@ package io.github.simbo1905.json.jtd.codegen; -import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -30,9 +29,9 @@ static String render(SchemaNode schema, String sha256Hex, String sha256Prefix8) sb.append("// ").append(schema.id()).append("-").append(sha256Prefix8).append(".js\n"); sb.append("// Generated from JTD schema: ").append(schema.id()).append("\n"); - sb.append("// SHA-256: ").append(sha256Prefix8).append("...").append("\n"); + sb.append("// SHA-256: ").append(sha256Hex).append(" (prefix: ").append(sha256Prefix8).append(")\n"); sb.append("// WARNING: Experimental - flat schemas only\n"); - sb.append("// Generated at: ").append(Instant.now()).append("\n\n"); + sb.append("\n"); sb.append("const SCHEMA_ID = ").append(jsString(schema.id())).append(";\n\n"); From 9c9d5e29798dec9b55deecdcd7f3d0503d981324 Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:11:54 +0000 Subject: [PATCH 6/9] exhaustive tests --- jtd-esm-codegen/pom.xml | 6 + .../json/jtd/codegen/EsmRenderer.java | 625 ++++++++++++---- .../simbo1905/json/jtd/codegen/JtdAst.java | 60 +- .../simbo1905/json/jtd/codegen/JtdParser.java | 254 ++++--- .../json/jtd/codegen/JtdToEsmCli.java | 74 +- .../json/jtd/codegen/JtdEsmPropertyTest.java | 708 ++++++++++++++++++ .../json/jtd/codegen/JtdToEsmCodegenTest.java | 480 +++++++++--- .../src/test/js/boolean-schema.test.js | 44 ++ jtd-esm-codegen/src/test/js/test-runner.js | 129 ++++ .../test/resources/expected/boolean-schema.js | 15 + .../resources/jtd/boolean-schema.jtd.json | 3 + 11 files changed, 1957 insertions(+), 441 deletions(-) create mode 100644 jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java create mode 100644 jtd-esm-codegen/src/test/js/boolean-schema.test.js create mode 100644 jtd-esm-codegen/src/test/js/test-runner.js create mode 100644 jtd-esm-codegen/src/test/resources/expected/boolean-schema.js create mode 100644 jtd-esm-codegen/src/test/resources/jtd/boolean-schema.jtd.json diff --git a/jtd-esm-codegen/pom.xml b/jtd-esm-codegen/pom.xml index 0d390bb..da8882a 100644 --- a/jtd-esm-codegen/pom.xml +++ b/jtd-esm-codegen/pom.xml @@ -57,6 +57,12 @@ assertj-core test + + net.jqwik + jqwik + 1.9.3 + test + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java index 16e7dc3..4713c0e 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java @@ -1,232 +1,526 @@ package io.github.simbo1905.json.jtd.codegen; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TreeMap; +import java.util.*; import static io.github.simbo1905.json.jtd.codegen.JtdAst.*; -/// Renders an ES2020 JavaScript module exporting `validate(instance)`. -/// -/// The generated validator: -/// - Treats the root as a JSON object (non-array, non-null) -/// - Checks required `properties` presence and validates leaf `type`/`enum` -/// - Checks optional `optionalProperties` only when present -/// - Ignores additional properties (RFC 8927 "properties" form allows them) +/// Generates optimal ES2020 ESM validators using explicit stack-based validation. +/// +/// Key features: +/// - Generates only the code needed (no unused helper functions) +/// - Uses explicit stack to avoid recursion and stack overflow +/// - Supports all RFC 8927 forms: elements, values, discriminator, nullable +/// - Inlines primitive checks, uses loops for arrays +/// - Generates separate functions for complex types final class EsmRenderer { private EsmRenderer() {} - static String render(SchemaNode schema, String sha256Hex, String sha256Prefix8) { + static String render(RootNode schema, String sha256Hex, String shaPrefix8) { Objects.requireNonNull(schema, "schema must not be null"); Objects.requireNonNull(sha256Hex, "sha256Hex must not be null"); - Objects.requireNonNull(sha256Prefix8, "sha256Prefix8 must not be null"); + Objects.requireNonNull(shaPrefix8, "shaPrefix8 must not be null"); + + final var ctx = new RenderContext(); + ctx.sha256Hex = sha256Hex; + ctx.shaPrefix8 = shaPrefix8; + ctx.schemaId = schema.id(); + + // Analyze schema to determine what helpers we actually need + analyzeSchema(schema.rootSchema(), ctx); + for (var def : schema.definitions().values()) { + analyzeSchema(def, ctx); + } final var sb = new StringBuilder(8 * 1024); - sb.append("// ").append(schema.id()).append("-").append(sha256Prefix8).append(".js\n"); + // Header + sb.append("// ").append(schema.id()).append("-").append(shaPrefix8).append(".js\n"); sb.append("// Generated from JTD schema: ").append(schema.id()).append("\n"); - sb.append("// SHA-256: ").append(sha256Hex).append(" (prefix: ").append(sha256Prefix8).append(")\n"); - sb.append("// WARNING: Experimental - flat schemas only\n"); + sb.append("// SHA-256: ").append(sha256Hex).append(" (prefix: ").append(shaPrefix8).append(")\n"); sb.append("\n"); sb.append("const SCHEMA_ID = ").append(jsString(schema.id())).append(";\n\n"); - final var enumConsts = enumConstants(schema); - for (var e : enumConsts.entrySet()) { - sb.append("const ").append(e.getKey()).append(" = ").append(jsStringArray(e.getValue())).append(";\n"); - } - if (!enumConsts.isEmpty()) { - sb.append("\n"); - } - - sb.append("function isString(v) { return typeof v === \"string\"; }\n"); - sb.append("function isBoolean(v) { return typeof v === \"boolean\"; }\n"); - sb.append("function isTimestamp(v) { return typeof v === \"string\" && !Number.isNaN(Date.parse(v)); }\n"); - sb.append("function isNumber(v) { return typeof v === \"number\" && Number.isFinite(v); }\n"); - sb.append("function isInt(v) { return Number.isInteger(v); }\n"); - sb.append("function isIntRange(v, min, max) { return isInt(v) && v >= min && v <= max; }\n\n"); - - sb.append("export function validate(instance) {\n"); - sb.append(" const errors = [];\n\n"); + // Generate enum constants + generateEnumConstants(sb, ctx); - sb.append(" if (instance === null || typeof instance !== \"object\" || Array.isArray(instance)) {\n"); - sb.append(" errors.push({ instancePath: \"\", schemaPath: \"\" });\n"); - sb.append(" return errors;\n"); - sb.append(" }\n\n"); + // Generate only the helper functions we need + generateHelpers(sb, ctx); - final var required = new TreeMap<>(schema.properties()); - for (var p : required.values()) { - renderRequiredProperty(sb, p, enumConsts, "/properties"); - sb.append("\n"); + // Generate validation functions for definitions + for (var entry : schema.definitions().entrySet()) { + final String defName = entry.getKey(); + final JtdNode defNode = entry.getValue(); + generateDefinitionValidator(sb, defName, defNode, ctx); } - final var optional = new TreeMap<>(schema.optionalProperties()); - for (var p : optional.values()) { - renderOptionalProperty(sb, p, enumConsts, "/optionalProperties"); - sb.append("\n"); + // Generate inline validation functions for complex nested types + ctx.inlineValidators.clear(); + ctx.inlineValidatorCounter = 0; + collectInlineValidators(schema.rootSchema(), "root", ctx); + // Remove root from inline validators - we'll generate it separately + ctx.inlineValidators.remove("root"); + + // Track discriminator mappings + trackDiscriminatorMappings(schema.rootSchema(), ctx); + + for (var entry : ctx.inlineValidators.entrySet()) { + final String key = entry.getKey(); + final JtdNode node = entry.getValue(); + final String discriminatorKey = ctx.discriminatorMappings.get(key); + generateInlineValidator(sb, key, node, ctx, discriminatorKey); } + // Generate the root validator (referenced by validate() but defined before it) + generateInlineValidator(sb, "root", schema.rootSchema(), ctx, null); + + // Generate main validate function with stack-based approach + sb.append("export function validate(instance) {\n"); + sb.append(" const errors = [];\n"); + sb.append(" const stack = [{ fn: validate_root, value: instance, path: '' }];\n\n"); + sb.append(" while (stack.length > 0) {\n"); + sb.append(" const frame = stack.pop();\n"); + sb.append(" frame.fn(frame.value, errors, frame.path, stack);\n"); + sb.append(" }\n\n"); sb.append(" return errors;\n"); sb.append("}\n\n"); + sb.append("export { SCHEMA_ID };\n"); return sb.toString(); } - private static Map> enumConstants(SchemaNode schema) { - final var out = new LinkedHashMap>(); - final var allProps = new ArrayList(); - allProps.addAll(schema.properties().values()); - allProps.addAll(schema.optionalProperties().values()); - allProps.sort(Comparator.comparing(PropertyNode::name)); - - int i = 0; - for (var p : allProps) { - if (p.type() instanceof EnumNode en) { - final String base = "ENUM_" + toConstName(p.name()); - String name = base; - while (out.containsKey(name)) { - i++; - name = base + "_" + i; + /// Analyzes schema to determine which helpers are needed + private static void analyzeSchema(JtdNode node, RenderContext ctx) { + switch (node) { + case TypeNode tn -> { + switch (tn.type()) { + case "timestamp" -> ctx.needsTimestampCheck = true; + case "int8", "uint8", "int16", "uint16", "int32", "uint32" -> ctx.needsIntRangeCheck = true; + case "float32", "float64" -> ctx.needsFloatCheck = true; } - out.put(name, en.values()); + } + case EnumNode en -> { + final String constName = "ENUM_" + (ctx.enumConstants.size() + 1); + ctx.enumConstants.put(constName, en.values()); + } + case ElementsNode el -> analyzeSchema(el.schema(), ctx); + case ValuesNode vn -> analyzeSchema(vn.schema(), ctx); + case PropertiesNode pn -> { + pn.properties().values().forEach(n -> analyzeSchema(n, ctx)); + pn.optionalProperties().values().forEach(n -> analyzeSchema(n, ctx)); + } + case DiscriminatorNode dn -> { + dn.mapping().values().forEach(n -> analyzeSchema(n, ctx)); + } + case NullableNode nn -> analyzeSchema(nn.wrapped(), ctx); + case RefNode ignored -> { + // No analysis needed + } + case EmptyNode ignored -> { + // No analysis needed } } - return out; } - private static void renderRequiredProperty(StringBuilder sb, PropertyNode p, Map> enumConsts, String schemaPrefix) { - final String prop = p.name(); - final String schemaPathProp = schemaPrefix + "/" + pointerEscape(prop); + private static void collectInlineValidators(JtdNode node, String prefix, RenderContext ctx) { + // Don't collect for simple types that can be validated inline + if (isSimpleType(node)) { + return; + } - sb.append(" // Required: ").append(prop).append("\n"); - sb.append(" if (!(\"").append(jsStringRaw(prop)).append("\" in instance)) {\n"); - sb.append(" errors.push({ instancePath: \"\", schemaPath: \"").append(schemaPathProp).append("\" });\n"); - sb.append(" } else {\n"); + // Already collected? + final String key = prefix; + if (ctx.inlineValidators.containsKey(key)) { + return; + } - renderLeafCheck(sb, "instance[" + jsString(prop) + "]", "/" + pointerEscape(prop), schemaPathProp, p.type(), enumConsts); + ctx.inlineValidators.put(key, node); + ctx.inlineValidatorCounter++; - sb.append(" }\n"); + // Recurse into children + switch (node) { + case ElementsNode el -> collectInlineValidators(el.schema(), key + "_elem" + ctx.inlineValidatorCounter, ctx); + case ValuesNode vn -> collectInlineValidators(vn.schema(), key + "_val" + ctx.inlineValidatorCounter, ctx); + case PropertiesNode pn -> { + pn.properties().forEach((k, v) -> { + if (!isSimpleType(v)) { + collectInlineValidators(v, key + "_" + k + ctx.inlineValidatorCounter, ctx); + } + }); + pn.optionalProperties().forEach((k, v) -> { + if (!isSimpleType(v)) { + collectInlineValidators(v, key + "_" + k + ctx.inlineValidatorCounter, ctx); + } + }); + } + case DiscriminatorNode dn -> { + dn.mapping().forEach((k, v) -> { + if (!isSimpleType(v)) { + collectInlineValidators(v, key + "_" + k + ctx.inlineValidatorCounter, ctx); + } + }); + } + case NullableNode nn -> collectInlineValidators(nn.wrapped(), key + ctx.inlineValidatorCounter, ctx); + default -> { + // No children + } + } } - private static void renderOptionalProperty(StringBuilder sb, PropertyNode p, Map> enumConsts, String schemaPrefix) { - final String prop = p.name(); - final String schemaPathProp = schemaPrefix + "/" + pointerEscape(prop); + /// Track which inline validators are discriminator mappings + private static void trackDiscriminatorMappings(JtdNode node, RenderContext ctx) { + trackDiscriminatorMappings(node, "root", null, ctx); + } - sb.append(" // Optional: ").append(prop).append("\n"); - sb.append(" if (\"").append(jsStringRaw(prop)).append("\" in instance) {\n"); + private static void trackDiscriminatorMappings(JtdNode node, String prefix, String parentDiscriminatorKey, RenderContext ctx) { + switch (node) { + case DiscriminatorNode dn -> { + // Track mappings for this discriminator + dn.mapping().forEach((tagValue, variantSchema) -> { + // Try to find the inline validator key for this variant + for (var entry : ctx.inlineValidators.entrySet()) { + if (entry.getValue().equals(variantSchema)) { + ctx.discriminatorMappings.put(entry.getKey(), dn.discriminator()); + } + } + }); + // Also recurse into variant schemas + dn.mapping().forEach((tagValue, variantSchema) -> + trackDiscriminatorMappings(variantSchema, prefix, dn.discriminator(), ctx)); + } + case ElementsNode el -> trackDiscriminatorMappings(el.schema(), prefix + "_elem", parentDiscriminatorKey, ctx); + case ValuesNode vn -> trackDiscriminatorMappings(vn.schema(), prefix + "_val", parentDiscriminatorKey, ctx); + case PropertiesNode pn -> { + pn.properties().forEach((k, v) -> trackDiscriminatorMappings(v, prefix + "_" + k, parentDiscriminatorKey, ctx)); + pn.optionalProperties().forEach((k, v) -> trackDiscriminatorMappings(v, prefix + "_" + k, parentDiscriminatorKey, ctx)); + } + case NullableNode nn -> trackDiscriminatorMappings(nn.wrapped(), prefix, parentDiscriminatorKey, ctx); + default -> { + // No discriminator here + } + } + } - renderLeafCheck(sb, "instance[" + jsString(prop) + "]", "/" + pointerEscape(prop), schemaPathProp, p.type(), enumConsts); + private static boolean isSimpleType(JtdNode node) { + return node instanceof TypeNode || node instanceof EnumNode || node instanceof EmptyNode || node instanceof RefNode; + } - sb.append(" }\n"); + private static void generateEnumConstants(StringBuilder sb, RenderContext ctx) { + int i = 1; + for (var entry : ctx.enumConstants.entrySet()) { + sb.append("const ").append(entry.getKey()).append(" = ") + .append(jsStringArray(entry.getValue())).append(";\n"); + i++; + } + if (!ctx.enumConstants.isEmpty()) { + sb.append("\n"); + } } - private static void renderLeafCheck( - StringBuilder sb, - String valueExpr, - String instancePath, - String schemaPathProp, - JtdNode node, - Map> enumConsts - ) { + private static void generateHelpers(StringBuilder sb, RenderContext ctx) { + if (ctx.needsTimestampCheck) { + sb.append("function isTimestamp(v) {\n"); + sb.append(" return typeof v === \"string\" && !Number.isNaN(Date.parse(v));\n"); + sb.append("}\n\n"); + } + + if (ctx.needsIntRangeCheck) { + sb.append("function isIntInRange(v, min, max) {\n"); + sb.append(" return Number.isInteger(v) && v >= min && v <= max;\n"); + sb.append("}\n\n"); + } + + if (ctx.needsFloatCheck) { + sb.append("function isFloat(v) {\n"); + sb.append(" return typeof v === \"number\" && Number.isFinite(v);\n"); + sb.append("}\n\n"); + } + } + + private static void generateDefinitionValidator(StringBuilder sb, String defName, + JtdNode node, RenderContext ctx) { + final String safeName = toSafeName(defName); + + sb.append("function validate_").append(safeName).append("(value, errors, path, stack) {\n"); + generateNodeValidation(sb, node, ctx, "value", "path", " ", null); + sb.append("}\n\n"); + } + + private static void generateInlineValidator(StringBuilder sb, String name, + JtdNode node, RenderContext ctx, String discriminatorKey) { + sb.append("function validate_").append(name).append("(value, errors, path, stack) {\n"); + generateNodeValidation(sb, node, ctx, "value", "path", " ", discriminatorKey); + sb.append("}\n\n"); + } + + /// Generates validation logic for a node + private static void generateNodeValidation(StringBuilder sb, JtdNode node, + RenderContext ctx, String valueExpr, String pathExpr, String indent, String discriminatorKey) { + switch (node) { - case EmptyNode ignored -> { - // Empty schema accepts any value. + case EmptyNode en -> { + // Accepts anything - no validation needed } + case TypeNode tn -> { - final String type = tn.type(); - final String check = typeCheckExpr(type, valueExpr); - sb.append(" if (!(").append(check).append(")) {\n"); - sb.append(" errors.push({ instancePath: \"").append(instancePath).append("\", schemaPath: \"") - .append(schemaPathProp).append("/type\" });\n"); - sb.append(" }\n"); + final String check = generateTypeCheck(tn.type(), valueExpr); + sb.append(indent).append("if (!(").append(check).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/type' });\n"); + sb.append(indent).append("}\n"); } + case EnumNode en -> { - final String constName = findEnumConst(enumConsts, en.values()); - sb.append(" if (!").append(constName).append(".includes(").append(valueExpr).append(")) {\n"); - sb.append(" errors.push({ instancePath: \"").append(instancePath).append("\", schemaPath: \"") - .append(schemaPathProp).append("/enum\" });\n"); - sb.append(" }\n"); + final String constName = findEnumConst(ctx.enumConstants, en.values()); + sb.append(indent).append("if (!").append(constName).append(".includes(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/enum' });\n"); + sb.append(indent).append("}\n"); } - default -> throw new IllegalStateException("Unexpected node in leaf position: " + node); - } - } - - private static String findEnumConst(Map> enumConsts, List values) { - for (var e : enumConsts.entrySet()) { - if (e.getValue().equals(values)) { - return e.getKey(); + + case ElementsNode el -> { + // Check it's an array + sb.append(indent).append("if (!Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/elements' });\n"); + sb.append(indent).append("} else {\n"); + + // Generate element validation inline or push to stack + final JtdNode elemSchema = el.schema(); + if (isSimpleType(elemSchema)) { + // Inline simple element validation with loop + sb.append(indent).append(" for (let i = 0; i < ").append(valueExpr).append(".length; i++) {\n"); + final String elemPath = pathExpr + " + '[' + i + ']'"; + final String elemExpr = valueExpr + "[i]"; + generateNodeValidation(sb, elemSchema, ctx, elemExpr, elemPath, indent + " ", discriminatorKey); + sb.append(indent).append(" }\n"); + } else { + // Push elements onto stack for deferred validation + final String validatorKey = findInlineValidator(ctx, elemSchema); + sb.append(indent).append(" for (let i = ").append(valueExpr).append(".length - 1; i >= 0; i--) {\n"); + sb.append(indent).append(" stack.push({\n"); + sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); + sb.append(indent).append(" value: ").append(valueExpr).append("[i],\n"); + sb.append(indent).append(" path: ").append(pathExpr).append(" + '[' + i + ']'\n"); + sb.append(indent).append(" });\n"); + sb.append(indent).append(" }\n"); + } + + sb.append(indent).append("}\n"); + } + + case PropertiesNode pn -> { + // Check it's an object + sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ").append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '' });\n"); + sb.append(indent).append("} else {\n"); + + // Check required properties + for (var entry : pn.properties().entrySet()) { + final String key = entry.getKey(); + final JtdNode subNode = entry.getValue(); + final String childPath = pathExpr + " + '/" + pointerEscape(key) + "'"; + final String childExpr = valueExpr + "['" + key + "']"; + + sb.append(indent).append(" if (!('").append(key).append("' in ").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/properties/").append(pointerEscape(key)).append("' });\n"); + sb.append(indent).append(" } else {\n"); + + if (isSimpleType(subNode)) { + generateNodeValidation(sb, subNode, ctx, childExpr, childPath, indent + " ", discriminatorKey); + } else { + final String validatorKey = findInlineValidator(ctx, subNode); + sb.append(indent).append(" stack.push({\n"); + sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); + sb.append(indent).append(" value: ").append(childExpr).append(",\n"); + sb.append(indent).append(" path: ").append(childPath).append("\n"); + sb.append(indent).append(" });\n"); + } + + sb.append(indent).append(" }\n"); + } + + // Check optional properties + for (var entry : pn.optionalProperties().entrySet()) { + final String key = entry.getKey(); + final JtdNode subNode = entry.getValue(); + final String childPath = pathExpr + " + '/" + pointerEscape(key) + "'"; + final String childExpr = valueExpr + "['" + key + "']"; + + sb.append(indent).append(" if ('").append(key).append("' in ").append(valueExpr).append(") {\n"); + + if (isSimpleType(subNode)) { + generateNodeValidation(sb, subNode, ctx, childExpr, childPath, indent + " ", discriminatorKey); + } else { + final String validatorKey = findInlineValidator(ctx, subNode); + sb.append(indent).append(" stack.push({\n"); + sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); + sb.append(indent).append(" value: ").append(childExpr).append(",\n"); + sb.append(indent).append(" path: ").append(childPath).append("\n"); + sb.append(indent).append(" });\n"); + } + + sb.append(indent).append(" }\n"); + } + + // Check additional properties if not allowed + if (!pn.additionalProperties()) { + sb.append(indent).append(" const allowed = new Set(["); + boolean first = true; + for (var key : pn.properties().keySet()) { + if (!first) sb.append(", "); + sb.append("'").append(key).append("'"); + first = false; + } + for (var key : pn.optionalProperties().keySet()) { + if (!first) sb.append(", "); + sb.append("'").append(key).append("'"); + first = false; + } + // Add discriminator key if present (for discriminator mappings) + if (discriminatorKey != null) { + if (!first) sb.append(", "); + sb.append("'").append(discriminatorKey).append("'"); + } + sb.append("]);\n"); + sb.append(indent).append(" for (const key of Object.keys(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" if (!allowed.has(key)) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(" + '/' + key, schemaPath: '/additionalProperties' });\n"); + sb.append(indent).append(" }\n"); + sb.append(indent).append(" }\n"); + } + + sb.append(indent).append("}\n"); + } + + case ValuesNode vn -> { + // Check it's an object + sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ").append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/values' });\n"); + sb.append(indent).append("} else {\n"); + + // Iterate over values + final JtdNode valSchema = vn.schema(); + if (isSimpleType(valSchema)) { + // Inline simple value validation with loop + sb.append(indent).append(" for (const key of Object.keys(").append(valueExpr).append(")) {\n"); + final String valPath = pathExpr + " + '/' + key"; + final String valExpr = valueExpr + "[key]"; + generateNodeValidation(sb, valSchema, ctx, valExpr, valPath, indent + " ", discriminatorKey); + sb.append(indent).append(" }\n"); + } else { + // Push values onto stack for deferred validation + final String validatorKey = findInlineValidator(ctx, valSchema); + sb.append(indent).append(" const keys = Object.keys(").append(valueExpr).append(");\n"); + sb.append(indent).append(" for (let i = keys.length - 1; i >= 0; i--) {\n"); + sb.append(indent).append(" const key = keys[i];\n"); + sb.append(indent).append(" stack.push({\n"); + sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); + sb.append(indent).append(" value: ").append(valueExpr).append("[key],\n"); + sb.append(indent).append(" path: ").append(pathExpr).append(" + '/' + key\n"); + sb.append(indent).append(" });\n"); + sb.append(indent).append(" }\n"); + } + + sb.append(indent).append("}\n"); + } + + case DiscriminatorNode dn -> { + // Check it's an object and has discriminator + sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ").append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '' });\n"); + sb.append(indent).append("} else if (!('").append(dn.discriminator()).append("' in ").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/discriminator' });\n"); + sb.append(indent).append("} else {\n"); + sb.append(indent).append(" const tag = ").append(valueExpr).append("['").append(dn.discriminator()).append("'];\n"); + + // Switch on tag value + boolean first = true; + for (var entry : dn.mapping().entrySet()) { + final String tagValue = entry.getKey(); + final JtdNode subNode = entry.getValue(); + + if (first) { + sb.append(indent).append(" if (tag === '").append(tagValue).append("') {\n"); + first = false; + } else { + sb.append(indent).append(" } else if (tag === '").append(tagValue).append("') {\n"); + } + + if (isSimpleType(subNode)) { + generateNodeValidation(sb, subNode, ctx, valueExpr, pathExpr, indent + " ", dn.discriminator()); + } else { + final String validatorKey = findInlineValidator(ctx, subNode); + sb.append(indent).append(" stack.push({\n"); + sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); + sb.append(indent).append(" value: ").append(valueExpr).append(",\n"); + sb.append(indent).append(" path: ").append(pathExpr).append("\n"); + sb.append(indent).append(" });\n"); + } + } + + sb.append(indent).append(" } else {\n"); + sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(" + '/").append(dn.discriminator()).append("', schemaPath: '/discriminator' });\n"); + sb.append(indent).append(" }\n"); + sb.append(indent).append("}\n"); + } + + case RefNode rn -> { + sb.append(indent).append("validate_").append(toSafeName(rn.ref())) + .append("(").append(valueExpr).append(", errors, ").append(pathExpr).append(", stack);\n"); + } + + case NullableNode nn -> { + sb.append(indent).append("if (").append(valueExpr).append(" !== null) {\n"); + generateNodeValidation(sb, nn.wrapped(), ctx, valueExpr, pathExpr, indent + " ", discriminatorKey); + sb.append(indent).append("}\n"); } } - throw new IllegalStateException("Enum constants map missing values: " + values); } - private static String typeCheckExpr(String type, String valueExpr) { + private static String generateTypeCheck(String type, String valueExpr) { return switch (type) { - case "string" -> "isString(" + valueExpr + ")"; - case "boolean" -> "isBoolean(" + valueExpr + ")"; + case "string" -> "typeof " + valueExpr + " === \"string\""; + case "boolean" -> "typeof " + valueExpr + " === \"boolean\""; case "timestamp" -> "isTimestamp(" + valueExpr + ")"; - case "float32", "float64" -> "isNumber(" + valueExpr + ")"; - case "int8" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -128, 127)"; - case "uint8" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 255)"; - case "int16" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -32768, 32767)"; - case "uint16" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 65535)"; - case "int32" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", -2147483648, 2147483647)"; - case "uint32" -> "isNumber(" + valueExpr + ") && isIntRange(" + valueExpr + ", 0, 4294967295)"; - default -> throw new IllegalArgumentException("Unsupported type: " + type); + case "int8" -> "isIntInRange(" + valueExpr + ", -128, 127)"; + case "uint8" -> "isIntInRange(" + valueExpr + ", 0, 255)"; + case "int16" -> "isIntInRange(" + valueExpr + ", -32768, 32767)"; + case "uint16" -> "isIntInRange(" + valueExpr + ", 0, 65535)"; + case "int32" -> "isIntInRange(" + valueExpr + ", -2147483648, 2147483647)"; + case "uint32" -> "isIntInRange(" + valueExpr + ", 0, 4294967295)"; + case "float32", "float64" -> "isFloat(" + valueExpr + ")"; + default -> throw new IllegalArgumentException("Unknown type: " + type); }; } - private static String toConstName(String propName) { - final var sb = new StringBuilder(propName.length() + 8); - for (int i = 0; i < propName.length(); i++) { - final char c = propName.charAt(i); - if (c >= 'a' && c <= 'z') { - sb.append((char) (c - 32)); - } else if (c >= 'A' && c <= 'Z') { - sb.append(c); - } else if (c >= '0' && c <= '9') { - sb.append(c); - } else { - sb.append('_'); + private static String findInlineValidator(RenderContext ctx, JtdNode node) { + for (var entry : ctx.inlineValidators.entrySet()) { + if (entry.getValue().equals(node)) { + return entry.getKey(); } } - if (sb.isEmpty()) { - return "PROP"; - } - if (sb.charAt(0) >= '0' && sb.charAt(0) <= '9') { - sb.insert(0, "P_"); + // If not found, it's a simple type - shouldn't happen + throw new IllegalStateException("No inline validator found for: " + node.getClass().getSimpleName()); + } + + private static String findEnumConst(Map> enumConsts, List values) { + for (var e : enumConsts.entrySet()) { + if (e.getValue().equals(values)) { + return e.getKey(); + } } - return sb.toString(); + throw new IllegalStateException("Enum values not found: " + values); + } + + private static String toSafeName(String name) { + return name.replaceAll("[^a-zA-Z0-9_]", "_"); } - /// Escape a JSON Pointer path segment. private static String pointerEscape(String s) { return s.replace("~", "~0").replace("/", "~1"); } private static String jsString(String s) { - return "\"" + jsStringRaw(s) + "\""; - } - - private static String jsStringRaw(String s) { - final var sb = new StringBuilder(s.length() + 8); - for (int i = 0; i < s.length(); i++) { - final char c = s.charAt(i); - switch (c) { - case '\\' -> sb.append("\\\\"); - case '"' -> sb.append("\\\""); - case '\n' -> sb.append("\\n"); - case '\r' -> sb.append("\\r"); - case '\t' -> sb.append("\\t"); - default -> sb.append(c); - } - } - return sb.toString(); + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; } private static String jsStringArray(List values) { @@ -239,5 +533,18 @@ private static String jsStringArray(List values) { sb.append("]"); return sb.toString(); } -} + /// Context for tracking what's needed during rendering + private static class RenderContext { + String sha256Hex; + String shaPrefix8; + String schemaId; + boolean needsTimestampCheck = false; + boolean needsIntRangeCheck = false; + boolean needsFloatCheck = false; + final Map> enumConstants = new LinkedHashMap<>(); + final Map inlineValidators = new LinkedHashMap<>(); + final Map discriminatorMappings = new LinkedHashMap<>(); + int inlineValidatorCounter = 0; + } +} diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java index 7b73991..f561607 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdAst.java @@ -3,31 +3,59 @@ import java.util.List; import java.util.Map; -/// Minimal AST for the experimental "flat JTD → ESM validator" code generator. +/// Complete AST for RFC 8927 JTD code generation. +/// Supports all schema forms: empty, type, enum, elements, properties, values, +/// discriminator, ref, and nullable. /// -/// Supported JTD subset: -/// - root: `properties`, `optionalProperties`, `metadata.id` -/// - leaf: `{}` (empty form), `{ "type": ... }`, `{ "enum": [...] }` +/// This AST is designed for stack-based code generation where each node +/// knows how to generate its own validation logic. public final class JtdAst { private JtdAst() {} - public sealed interface JtdNode permits SchemaNode, PropertyNode, TypeNode, EnumNode, EmptyNode {} + public sealed interface JtdNode permits + EmptyNode, TypeNode, EnumNode, ElementsNode, PropertiesNode, + ValuesNode, DiscriminatorNode, RefNode, NullableNode {} - public record SchemaNode( - String id, // metadata.id - Map properties, - Map optionalProperties - ) implements JtdNode {} + /// Root of a JTD document with metadata, definitions, and root schema. + public record RootNode( + String id, + Map definitions, + JtdNode rootSchema + ) {} - public record PropertyNode(String name, JtdNode type) implements JtdNode {} + /// Empty form {} - accepts any JSON value. + public record EmptyNode() implements JtdNode {} - /// JTD primitive type keyword as a string, e.g. "string", "int32", "timestamp". + /// Type form - validates primitive types. + /// Type values: string, boolean, timestamp, int8, uint8, int16, uint16, + /// int32, uint32, float32, float64 public record TypeNode(String type) implements JtdNode {} - /// Enum values (strings only in RFC 8927). + /// Enum form - validates string is one of allowed values. public record EnumNode(List values) implements JtdNode {} - /// Empty form `{}`: accepts any JSON value. - public record EmptyNode() implements JtdNode {} -} + /// Elements form - validates array where each element matches schema. + public record ElementsNode(JtdNode schema) implements JtdNode {} + + /// Properties form - validates object with required/optional properties. + public record PropertiesNode( + Map properties, + Map optionalProperties, + boolean additionalProperties + ) implements JtdNode {} + /// Values form - validates object where all values match schema. + public record ValuesNode(JtdNode schema) implements JtdNode {} + + /// Discriminator form - validates tagged unions. + public record DiscriminatorNode( + String discriminator, + Map mapping + ) implements JtdNode {} + + /// Ref form - references a definition. + public record RefNode(String ref) implements JtdNode {} + + /// Nullable wrapper - allows null in addition to wrapped schema. + public record NullableNode(JtdNode wrapped) implements JtdNode {} +} diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java index 19b404f..c9c1092 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdParser.java @@ -1,144 +1,174 @@ package io.github.simbo1905.json.jtd.codegen; -import jdk.sandbox.java.util.json.Json; -import jdk.sandbox.java.util.json.JsonArray; -import jdk.sandbox.java.util.json.JsonObject; -import jdk.sandbox.java.util.json.JsonString; -import jdk.sandbox.java.util.json.JsonValue; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import jdk.sandbox.java.util.json.*; + +import java.util.*; import static io.github.simbo1905.json.jtd.codegen.JtdAst.*; -/// Parses a deliberately-limited subset of JTD (RFC 8927) for code generation. -/// -/// This parser is *not* the general-purpose validator in `json-java21-jtd`. -/// It exists to support the experimental code generator and intentionally rejects -/// most of JTD. +/// Parses JTD (RFC 8927) schemas for code generation. +/// Supports all schema forms including elements, values, discriminator, and nullable. final class JtdParser { private JtdParser() {} - static SchemaNode parseString(String jtdJson) { + static RootNode parseString(String jtdJson) { Objects.requireNonNull(jtdJson, "jtdJson must not be null"); return parseValue(Json.parse(jtdJson)); } - static SchemaNode parseValue(JsonValue rootValue) { + static RootNode parseValue(JsonValue rootValue) { Objects.requireNonNull(rootValue, "rootValue must not be null"); if (!(rootValue instanceof JsonObject root)) { throw new IllegalArgumentException("JTD schema must be a JSON object"); } - rejectUnsupportedKeys(root, "elements", "values", "discriminator", "mapping", "ref", "definitions"); - final var metadata = getObjectOrNull(root, "metadata"); - if (metadata == null) { - throw new IllegalArgumentException("JTD schema missing required key: metadata.id"); - } - final var id = getString(metadata, "id"); - if (id.isBlank()) { - throw new IllegalArgumentException("metadata.id must be non-blank"); + final String id; + if (metadata != null && metadata.members().containsKey("id")) { + id = getString(metadata, "id"); + if (id.isBlank()) { + throw new IllegalArgumentException("metadata.id must be non-blank"); + } + } else { + id = "JtdSchema"; } - final Map properties = parsePropertiesBlock(root, "properties"); - final Map optionalProperties = parsePropertiesBlock(root, "optionalProperties"); - - // Reject additional unknown top-level keys (keeps the tool intentionally strict/limited). - final Set allowedRoot = Set.of("properties", "optionalProperties", "metadata"); - for (String k : root.members().keySet()) { - if (!allowedRoot.contains(k)) { - throw unsupported("unknown key '" + k + "'"); + final Map definitions = new LinkedHashMap<>(); + if (root.members().containsKey("definitions")) { + final var defsObj = getObjectOrNull(root, "definitions"); + if (defsObj != null) { + for (var e : defsObj.members().entrySet()) { + definitions.put(e.getKey(), parseSchema(e.getKey(), e.getValue(), true)); + } } } - return new SchemaNode(id, Map.copyOf(properties), Map.copyOf(optionalProperties)); + final JtdNode rootSchema = parseSchema("root", root, false); + + return new RootNode(id, definitions, rootSchema); } - private static Map parsePropertiesBlock(JsonObject root, String key) { - final var block = getObjectOrNull(root, key); - if (block == null) { - return Map.of(); + private static JtdNode parseSchema(String propName, JsonValue schemaValue, boolean inDefinitions) { + if (!(schemaValue instanceof JsonObject schema)) { + throw new IllegalArgumentException("Schema for '" + propName + "' must be a JSON object"); } - final var out = new LinkedHashMap(); - for (var e : block.members().entrySet()) { - final String propName = e.getKey(); - final JsonValue propSchemaValue = e.getValue(); - if (!(propSchemaValue instanceof JsonObject propSchemaObj)) { - throw new IllegalArgumentException("Schema for '" + key + "." + propName + "' must be a JSON object"); + // Check for nullable wrapper first + boolean isNullable = false; + if (schema.members().containsKey("nullable")) { + final var nullableVal = schema.members().get("nullable"); + if (nullableVal instanceof JsonBoolean jb && jb.bool()) { + isNullable = true; } - final JtdNode type = parsePropertySchema(propName, propSchemaObj, key); - out.put(propName, new PropertyNode(propName, type)); - } - return out; - } - - private static JtdNode parsePropertySchema(String propName, JsonObject propSchema, String containerKey) { - // Explicitly reject unsupported features inside property schemas too. - rejectUnsupportedKeys(propSchema, "elements", "values", "discriminator", "mapping", "ref", "definitions"); - - if (propSchema.members().isEmpty()) { - return new EmptyNode(); } - if (propSchema.members().containsKey("properties") || propSchema.members().containsKey("optionalProperties")) { - throw unsupported("properties"); - } + JtdNode coreNode; - // Only allow leaf forms: type or enum. - final boolean hasType = propSchema.members().containsKey("type"); - final boolean hasEnum = propSchema.members().containsKey("enum"); - if (hasType && hasEnum) { - throw new IllegalArgumentException("Property '" + propName + "' must not specify both 'type' and 'enum'"); + // 1. Ref + if (schema.members().containsKey("ref")) { + final var ref = stringValue(schema.members().get("ref"), propName, "ref"); + coreNode = new RefNode(ref); } - if (!hasType && !hasEnum) { - // Any other leaf form is unsupported for this tool. - final var keys = propSchema.members().keySet().stream().sorted().toList(); - throw unsupported("schema keys " + keys); - } - - if (hasType) { - final var typeStr = stringValue(propSchema.members().get("type"), containerKey, propName, "type"); + // 2. Type + else if (schema.members().containsKey("type")) { + final var typeStr = stringValue(schema.members().get("type"), propName, "type"); final var normalized = typeStr.toLowerCase(Locale.ROOT).trim(); if (!ALLOWED_TYPES.contains(normalized)) { - throw new IllegalArgumentException("Unsupported JTD type: '" + typeStr + "', expected one of: " + ALLOWED_TYPES); + throw new IllegalArgumentException("Unknown type: '" + typeStr + + "', expected one of: " + String.join(", ", ALLOWED_TYPES)); + } + coreNode = new TypeNode(normalized); + } + // 3. Enum + else if (schema.members().containsKey("enum")) { + final var enumValues = enumValues(schema.members().get("enum"), propName); + coreNode = new EnumNode(List.copyOf(enumValues)); + } + // 4. Elements (arrays) + else if (schema.members().containsKey("elements")) { + final var elementsVal = schema.members().get("elements"); + final var elementSchema = parseSchema(propName + "[]", elementsVal, inDefinitions); + coreNode = new ElementsNode(elementSchema); + } + // 5. Values (string->value maps) + else if (schema.members().containsKey("values")) { + final var valuesVal = schema.members().get("values"); + final var valueSchema = parseSchema(propName + "{}", valuesVal, inDefinitions); + coreNode = new ValuesNode(valueSchema); + } + // 6. Discriminator (tagged unions) + else if (schema.members().containsKey("discriminator")) { + final var discVal = stringValue(schema.members().get("discriminator"), propName, "discriminator"); + + if (!schema.members().containsKey("mapping")) { + throw new IllegalArgumentException("discriminator requires mapping"); + } + + final var mappingObj = getObjectOrNull(schema, "mapping"); + if (mappingObj == null) { + throw new IllegalArgumentException("mapping must be an object"); + } + + final Map mapping = new LinkedHashMap<>(); + for (var e : mappingObj.members().entrySet()) { + mapping.put(e.getKey(), parseSchema(propName + "." + e.getKey(), e.getValue(), inDefinitions)); + } + + coreNode = new DiscriminatorNode(discVal, mapping); + } + // 7. Properties + else if (hasPropertiesLikeKeys(schema)) { + final Map props = new LinkedHashMap<>(); + if (schema.members().containsKey("properties")) { + final var p = getObjectOrNull(schema, "properties"); + if (p != null) { + for (var e : p.members().entrySet()) { + props.put(e.getKey(), parseSchema(propName + "." + e.getKey(), e.getValue(), inDefinitions)); + } + } } - rejectUnknownKeys(propSchema, Set.of("type")); - return new TypeNode(normalized); - } - final var enumValues = enumValues(propSchema.members().get("enum"), containerKey, propName); - rejectUnknownKeys(propSchema, Set.of("enum")); - return new EnumNode(List.copyOf(enumValues)); - } + final Map optionalProps = new LinkedHashMap<>(); + if (schema.members().containsKey("optionalProperties")) { + final var op = getObjectOrNull(schema, "optionalProperties"); + if (op != null) { + for (var e : op.members().entrySet()) { + optionalProps.put(e.getKey(), parseSchema(propName + "." + e.getKey(), e.getValue(), inDefinitions)); + } + } + } - private static void rejectUnknownKeys(JsonObject obj, Set allowedKeys) { - for (String k : obj.members().keySet()) { - if (!allowedKeys.contains(k)) { - throw unsupported("unknown key '" + k + "'"); + boolean additional = false; + if (schema.members().containsKey("additionalProperties")) { + final var ap = schema.members().get("additionalProperties"); + if (ap instanceof JsonBoolean b) { + additional = b.bool(); + } } + + coreNode = new PropertiesNode(props, optionalProps, additional); + } + // 8. Empty (accepts anything) + else { + coreNode = new EmptyNode(); } - } - private static void rejectUnsupportedKeys(JsonObject obj, String... keys) { - for (String k : keys) { - if (obj.members().containsKey(k)) { - throw unsupported(k); - } + // Wrap in nullable if needed + if (isNullable && !(coreNode instanceof EmptyNode)) { + return new NullableNode(coreNode); } + return coreNode; + } + + private static boolean hasPropertiesLikeKeys(JsonObject schema) { + return schema.members().containsKey("properties") || + schema.members().containsKey("optionalProperties") || + schema.members().containsKey("additionalProperties"); } private static JsonObject getObjectOrNull(JsonObject obj, String key) { final var v = obj.members().get(key); - if (v == null) { - return null; - } + if (v == null) return null; if (!(v instanceof JsonObject o)) { throw new IllegalArgumentException("Expected '" + key + "' to be an object"); } @@ -148,51 +178,35 @@ private static JsonObject getObjectOrNull(JsonObject obj, String key) { private static String getString(JsonObject obj, String key) { final var v = obj.members().get(key); if (!(v instanceof JsonString js)) { - throw new IllegalArgumentException("Expected 'metadata." + key + "' to be a string"); + throw new IllegalArgumentException("Expected '" + key + "' to be a string"); } return js.string(); } - private static String stringValue(JsonValue v, String container, String name, String key) { + private static String stringValue(JsonValue v, String container, String key) { if (!(v instanceof JsonString js)) { - throw new IllegalArgumentException("Expected '" + container + "." + name + "." + key + "' to be a string"); + throw new IllegalArgumentException("Expected '" + container + "." + key + "' to be a string"); } return js.string(); } - private static List enumValues(JsonValue v, String containerKey, String propName) { + private static List enumValues(JsonValue v, String propName) { if (!(v instanceof JsonArray arr)) { - throw new IllegalArgumentException("Expected '" + containerKey + "." + propName + ".enum' to be an array"); + throw new IllegalArgumentException("Expected '" + propName + ".enum' to be an array"); } final var out = new ArrayList(); for (int i = 0; i < arr.elements().size(); i++) { final var el = arr.element(i); if (!(el instanceof JsonString js)) { - throw new IllegalArgumentException("Expected '" + containerKey + "." + propName + ".enum[" + i + "]' to be a string"); + throw new IllegalArgumentException("Expected '" + propName + ".enum[" + i + "]' to be a string"); } out.add(js.string()); } return out; } - private static IllegalArgumentException unsupported(String feature) { - return new IllegalArgumentException( - "Unsupported JTD feature: " + feature + ". This experimental tool only supports flat schemas with properties, optionalProperties, type, and enum." - ); - } - private static final Set ALLOWED_TYPES = Set.of( - "string", - "boolean", - "timestamp", - "int8", - "int16", - "int32", - "uint8", - "uint16", - "uint32", - "float32", - "float64" + "string", "boolean", "timestamp", "int8", "uint8", "int16", "uint16", + "int32", "uint32", "float32", "float64" ); } - diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java index bb8aa3b..b87e45d 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java @@ -1,61 +1,53 @@ package io.github.simbo1905.json.jtd.codegen; import java.io.IOException; -import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Objects; +import java.util.logging.Logger; -import static io.github.simbo1905.json.jtd.codegen.JtdAst.SchemaNode; - -/// CLI entry point for generating an ES2020 ESM validator from a flat JTD schema. -/// -/// Usage: -/// `java -jar jtd-esm-codegen.jar schema.jtd.json` +/// CLI entry point for the new JTD to ESM code generator. +/// Generates optimal vanilla ES2020 validators with explicit stack-based validation. public final class JtdToEsmCli { - private JtdToEsmCli() {} - - public static void main(String[] args) { - final var err = new PrintWriter(System.err, true, StandardCharsets.UTF_8); + private static final Logger LOG = Logger.getLogger(JtdToEsmCli.class.getName()); - if (args == null || args.length != 1) { - err.println("Usage: java -jar jtd-esm-codegen.jar "); - System.exit(2); - return; - } + private JtdToEsmCli() {} - try { - final var in = Path.of(args[0]); - final var out = run(in, Path.of(".")); - System.out.println(out.toAbsolutePath()); - } catch (IllegalArgumentException e) { - err.println(e.getMessage()); - System.exit(2); - } catch (Exception e) { - e.printStackTrace(err); + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.err.println("Usage: java -jar jtd-esm-codegen.jar [output-dir]"); System.exit(1); } - } - static Path run(Path schemaFile, Path outputDir) throws IOException { - Objects.requireNonNull(schemaFile, "schemaFile must not be null"); - Objects.requireNonNull(outputDir, "outputDir must not be null"); + final Path schemaPath = Path.of(args[0]).toAbsolutePath().normalize(); + final Path outDir = args.length > 1 + ? Path.of(args[1]).toAbsolutePath().normalize() + : Path.of(".").toAbsolutePath().normalize(); - final byte[] digest = Sha256.digest(schemaFile); + final Path outJs = run(schemaPath, outDir); + System.out.println("Generated: " + outJs); + } + + static Path run(Path schemaPath, Path outDir) throws IOException { + LOG.fine(() -> "Reading schema from: " + schemaPath); + + final String schemaJson = Files.readString(schemaPath, StandardCharsets.UTF_8); + final var schema = JtdParser.parseString(schemaJson); + + final byte[] digest = Sha256.digest(schemaPath); final String shaHex = Sha256.hex(digest); final String shaPrefix8 = Sha256.hexPrefix8(digest); - - final String json = Files.readString(schemaFile, StandardCharsets.UTF_8); - final SchemaNode schema = JtdParser.parseString(json); - + + LOG.fine(() -> "Schema SHA-256: " + shaHex); + final String js = EsmRenderer.render(schema, shaHex, shaPrefix8); + final String fileName = schema.id() + "-" + shaPrefix8 + ".js"; - - Files.createDirectories(outputDir); - final Path out = outputDir.resolve(fileName); - Files.writeString(out, js, StandardCharsets.UTF_8); - return out; + final Path outJs = outDir.resolve(fileName); + + Files.writeString(outJs, js, StandardCharsets.UTF_8); + + LOG.fine(() -> "Generated validator: " + outJs); + return outJs; } } - diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java new file mode 100644 index 0000000..66dd124 --- /dev/null +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java @@ -0,0 +1,708 @@ +package io.github.simbo1905.json.jtd.codegen; + +import jdk.sandbox.java.util.json.*; +import net.jqwik.api.*; +import org.junit.jupiter.api.Assertions; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/// Property-based testing for JTD to ESM code generator. +/// Generates comprehensive schema/document permutations to validate generated JavaScript validators. +class JtdEsmPropertyTest extends JtdEsmCodegenLoggingConfig { + private static final Logger LOG = Logger.getLogger(JtdEsmPropertyTest.class.getName()); + + private static final List PROPERTY_NAMES = List.of("alpha", "beta", "gamma", "delta", "epsilon"); + private static final List> PROPERTY_PAIRS = List.of( + List.of("alpha", "beta"), List.of("alpha", "gamma"), + List.of("beta", "delta"), List.of("gamma", "epsilon") + ); + private static final List DISCRIMINATOR_VALUES = List.of("type1", "type2", "type3"); + private static final List ENUM_VALUES = List.of("red", "green", "blue", "yellow"); + private static final Random RANDOM = new Random(); + + /// Sealed interface for JTD test schemas + sealed interface JtdTestSchema permits EmptySchema, RefSchema, TypeSchema, EnumSchema, + ElementsSchema, PropertiesSchema, ValuesSchema, DiscriminatorSchema, NullableSchema {} + + record EmptySchema() implements JtdTestSchema {} + record RefSchema(String ref) implements JtdTestSchema {} + record TypeSchema(String type) implements JtdTestSchema {} + record EnumSchema(List values) implements JtdTestSchema {} + record ElementsSchema(JtdTestSchema elements) implements JtdTestSchema {} + record PropertiesSchema(Map properties, + Map optionalProperties, + boolean additionalProperties) implements JtdTestSchema {} + record ValuesSchema(JtdTestSchema values) implements JtdTestSchema {} + record DiscriminatorSchema(String discriminator, Map mapping) implements JtdTestSchema {} + record NullableSchema(JtdTestSchema schema) implements JtdTestSchema {} + + @Provide + Arbitrary jtdSchemas() { + return jtdSchemaArbitrary(3); + } + + @SuppressWarnings("unchecked") + private static Arbitrary jtdSchemaArbitrary(int depth) { + final var primitives = Arbitraries.of( + new EmptySchema(), + new TypeSchema("boolean"), + new TypeSchema("string"), + new TypeSchema("int32"), + new TypeSchema("float64"), + new TypeSchema("timestamp") + ); + + if (depth == 0) { + return (Arbitrary) (Arbitrary) primitives; + } + + return (Arbitrary) (Arbitrary) Arbitraries.oneOf( + primitives, + enumSchemaArbitrary(), + elementsSchemaArbitrary(depth), + propertiesSchemaArbitrary(depth), + valuesSchemaArbitrary(depth), + discriminatorSchemaArbitrary(), + nullableSchemaArbitrary(depth) + ); + } + + private static Arbitrary enumSchemaArbitrary() { + return Arbitraries.of(ENUM_VALUES).list().ofMinSize(1).ofMaxSize(4).map(values -> { + List distinctValues = values.stream().distinct().toList(); + return new EnumSchema(new ArrayList<>(distinctValues)); + }); + } + + private static Arbitrary elementsSchemaArbitrary(int depth) { + return jtdSchemaArbitrary(depth - 1).filter(schema -> { + if (schema instanceof DiscriminatorSchema disc) { + var firstVariant = disc.mapping().values().iterator().next(); + return !(firstVariant instanceof TypeSchema) && !(firstVariant instanceof EnumSchema); + } + return true; + }).map(ElementsSchema::new); + } + + private static Arbitrary propertiesSchemaArbitrary(int depth) { + final var childDepth = depth - 1; + final var empty = Arbitraries.of(new PropertiesSchema(Map.of(), Map.of(), false)); + + final var singleRequired = Combinators.combine( + Arbitraries.of(PROPERTY_NAMES), + jtdSchemaArbitrary(childDepth) + ).as((name, schema) -> { + Assertions.assertNotNull(name); + Assertions.assertNotNull(schema); + return new PropertiesSchema(Map.of(name, schema), Map.of(), false); + }); + + final var mixed = Combinators.combine( + Arbitraries.of(PROPERTY_PAIRS), + jtdSchemaArbitrary(childDepth), + jtdSchemaArbitrary(childDepth) + ).as((names, requiredSchema, optionalSchema) -> { + Assertions.assertNotNull(names); + Assertions.assertNotNull(requiredSchema); + Assertions.assertNotNull(optionalSchema); + return new PropertiesSchema( + Map.of(names.getFirst(), requiredSchema), + Map.of(names.getLast(), optionalSchema), + false + ); + }); + + final var withAdditional = mixed.map(props -> { + Assertions.assertNotNull(props); + return new PropertiesSchema(props.properties(), props.optionalProperties(), true); + }); + + return Arbitraries.oneOf(empty, singleRequired, mixed, withAdditional); + } + + private static Arbitrary valuesSchemaArbitrary(int depth) { + return jtdSchemaArbitrary(depth - 1).map(ValuesSchema::new); + } + + private static Arbitrary discriminatorSchemaArbitrary() { + return Combinators.combine( + Arbitraries.of(PROPERTY_NAMES), + Arbitraries.of(DISCRIMINATOR_VALUES), + Arbitraries.of(DISCRIMINATOR_VALUES) + ).as((discriminatorKey, value1, value2) -> { + final var mapping = new LinkedHashMap(); + final var schema1 = propertiesSchemaForDiscriminatorMapping(discriminatorKey).sample(); + mapping.put(value1, schema1); + + Assertions.assertNotNull(value1); + if (!value1.equals(value2)) { + final var schema2 = propertiesSchemaForDiscriminatorMapping(discriminatorKey).sample(); + mapping.put(value2, schema2); + } + return new DiscriminatorSchema(discriminatorKey, mapping); + }); + } + + private static Arbitrary propertiesSchemaForDiscriminatorMapping(String discriminatorKey) { + final var primitiveSchemas = Arbitraries.of( + new TypeSchema("boolean"), + new TypeSchema("string"), + new TypeSchema("int32"), + new EnumSchema(List.of("red", "green", "blue")) + ); + + final var allPropertyNames = List.of("alpha", "beta", "gamma", "delta", "epsilon"); + final var safePropertyNames = allPropertyNames.stream() + .filter(name -> !name.equals(discriminatorKey)) + .toList(); + final var effectivePropertyNames = safePropertyNames.isEmpty() + ? List.of("prop1", "prop2", "prop3") + : safePropertyNames; + + final var safePropertyPairs = effectivePropertyNames.stream() + .flatMap(name1 -> effectivePropertyNames.stream() + .filter(name2 -> !name1.equals(name2)) + .map(name2 -> List.of(name1, name2))) + .filter(pair -> !pair.getFirst().equals(discriminatorKey) && !pair.get(1).equals(discriminatorKey)) + .toList(); + + return Arbitraries.oneOf( + Combinators.combine(Arbitraries.of(effectivePropertyNames), primitiveSchemas) + .as((name, schema) -> new PropertiesSchema(Map.of(name, schema), Map.of(), false)), + Combinators.combine(Arbitraries.of(effectivePropertyNames), primitiveSchemas) + .as((name, schema) -> new PropertiesSchema(Map.of(), Map.of(name, schema), false)), + Combinators.combine(Arbitraries.of(safePropertyPairs), primitiveSchemas, primitiveSchemas) + .as((names, reqSchema, optSchema) -> + new PropertiesSchema(Map.of(names.getFirst(), reqSchema), + Map.of(names.getLast(), optSchema), false)) + ); + } + + private static Arbitrary nullableSchemaArbitrary(int depth) { + return jtdSchemaArbitrary(depth - 1).map(NullableSchema::new); + } + + /// Converts test schema to JtdAst.RootNode + private static JtdAst.RootNode testSchemaToRootNode(JtdTestSchema schema) { + final var definitions = new LinkedHashMap(); + final var rootNode = convertToJtdNode(schema, definitions); + return new JtdAst.RootNode("property-test", definitions, rootNode); + } + + private static JtdAst.JtdNode convertToJtdNode(JtdTestSchema schema, + Map definitions) { + return switch (schema) { + case EmptySchema ignored -> new JtdAst.EmptyNode(); + case RefSchema(var ref) -> new JtdAst.RefNode(ref); + case TypeSchema(var type) -> new JtdAst.TypeNode(type); + case EnumSchema(var values) -> new JtdAst.EnumNode(values); + case ElementsSchema(var elem) -> + new JtdAst.ElementsNode(convertToJtdNode(elem, definitions)); + case PropertiesSchema(var props, var optProps, var add) -> + new JtdAst.PropertiesNode( + props.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> convertToJtdNode(e.getValue(), definitions), + (a, b) -> a, + LinkedHashMap::new + )), + optProps.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> convertToJtdNode(e.getValue(), definitions), + (a, b) -> a, + LinkedHashMap::new + )), + add + ); + case ValuesSchema(var val) -> + new JtdAst.ValuesNode(convertToJtdNode(val, definitions)); + case DiscriminatorSchema(var disc, var mapping) -> + new JtdAst.DiscriminatorNode(disc, + mapping.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> convertToJtdNode(e.getValue(), definitions), + (a, b) -> a, + LinkedHashMap::new + )) + ); + case NullableSchema(var inner) -> + new JtdAst.NullableNode(convertToJtdNode(inner, definitions)); + }; + } + + /// Builds compliant JSON document for a schema + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object buildCompliantDocument(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema ignored -> generateAnyValue(); + case RefSchema ignored -> "ref-compliant-value"; + case TypeSchema(var type) -> buildCompliantTypeValue(type); + case EnumSchema(var values) -> values.getFirst(); + case ElementsSchema(var elem) -> { + final var v1 = buildCompliantDocument(elem); + final var v2 = buildCompliantDocument(elem); + final var lst = new ArrayList<>(); + if (v1 != null) lst.add(v1); + if (v2 != null) lst.add(v2); + yield lst; + } + case PropertiesSchema(var props, var optProps, var ignored) -> { + final var obj = new LinkedHashMap(); + props.forEach((k, v) -> obj.put(k, buildCompliantDocument(v))); + optProps.forEach((k, v) -> obj.put(k, buildCompliantDocument(v))); + yield obj; + } + case ValuesSchema(var val) -> { + final var v1 = buildCompliantDocument(val); + final var v2 = buildCompliantDocument(val); + final var map = new LinkedHashMap(); + if (v1 != null) map.put("key1", v1); + if (v2 != null) map.put("key2", v2); + yield map; + } + case DiscriminatorSchema(var disc, var mapping) -> { + final var firstEntry = mapping.entrySet().iterator().next(); + final var discValue = firstEntry.getKey(); + final var variant = firstEntry.getValue(); + final var obj = new LinkedHashMap(); + obj.put(disc, discValue); + if (variant instanceof PropertiesSchema ps) { + ps.properties().forEach((k, v) -> { + if (!k.equals(disc)) obj.put(k, buildCompliantDocument(v)); + }); + ps.optionalProperties().forEach((k, v) -> { + if (!k.equals(disc)) obj.put(k, buildCompliantDocument(v)); + }); + } + yield obj; + } + case NullableSchema ignored -> null; + }; + } + + private static Object generateAnyValue() { + return switch (RANDOM.nextInt(7)) { + case 0 -> null; + case 1 -> RANDOM.nextBoolean(); + case 2 -> RANDOM.nextInt(100); + case 3 -> RANDOM.nextDouble(); + case 4 -> "random-string-" + RANDOM.nextInt(1000); + case 5 -> { + final var v1 = generateAnyValue(); + final var v2 = generateAnyValue(); + final var lst = new ArrayList<>(); + if (v1 != null) lst.add(v1); + if (v2 != null) lst.add(v2); + yield lst; + } + case 6 -> { + final var v = generateAnyValue(); + final var map = new LinkedHashMap(); + if (v != null) map.put("key" + RANDOM.nextInt(10), v); + yield map; + } + default -> "fallback"; + }; + } + + private static Object buildCompliantTypeValue(String type) { + return switch (type) { + case "boolean" -> true; + case "string" -> "compliant-string"; + case "timestamp" -> "2023-12-25T10:30:00Z"; + case "int8" -> 42; + case "uint8" -> 200; + case "int16" -> 30000; + case "uint16" -> 50000; + case "int32" -> 1000000; + case "uint32" -> 3000000000L; + case "float32", "float64" -> 3.14159; + default -> "unknown"; + }; + } + + /// Creates failing documents for a schema + @SuppressWarnings({"unchecked", "rawtypes"}) + private static List createFailingDocuments(JtdTestSchema schema, Object compliant) { + return switch (schema) { + case EmptySchema ignored -> List.of(); + case RefSchema ignored -> Collections.singletonList(null); + case TypeSchema(var type) -> createFailingTypeValues(type); + case EnumSchema ignored -> List.of("invalid-enum-value"); + case ElementsSchema(var elem) -> { + if (compliant instanceof List lst && !lst.isEmpty()) { + final var invalidElem = createFailingDocuments(elem, lst.getFirst()); + if (!invalidElem.isEmpty()) { + final var innerLst = new ArrayList<>(); + innerLst.add(lst.getFirst()); + innerLst.add(invalidElem.getFirst()); + final var failures = new ArrayList<>(); + failures.add(innerLst); + failures.add(null); + yield failures; + } + } + yield Collections.singletonList(null); + } + case PropertiesSchema(var props, var optProps, var add) -> { + if (props.isEmpty() && optProps.isEmpty()) { + yield List.of(); + } + final var failures = new ArrayList(); + if (!props.isEmpty()) { + final var firstKey = props.keySet().iterator().next(); + failures.add(removeKey((Map) compliant, firstKey)); + } + if (!add) { + final var extended = new LinkedHashMap<>((Map) compliant); + extended.put("extraProperty", "extra-value"); + failures.add(extended); + } + failures.add(null); + yield failures; + } + case ValuesSchema ignored -> Arrays.asList(null, "not-an-object"); + case DiscriminatorSchema(var disc, var ignored) -> { + final var failures = new ArrayList(); + final var modified = new LinkedHashMap<>((Map) compliant); + modified.put(disc, "invalid-discriminator"); + failures.add(modified); + failures.add(null); + yield failures; + } + case NullableSchema ignored -> List.of(); + }; + } + + private static List createFailingTypeValues(String type) { + return switch (type) { + case "boolean" -> Arrays.asList("not-boolean", 1); + case "string", "timestamp" -> Arrays.asList(123, false); + case "int8", "uint8", "int16", "int32", "uint32", "uint16" -> + Arrays.asList("not-integer", 3.14); + case "float32", "float64" -> Arrays.asList("not-float", true); + default -> Collections.singletonList(null); + }; + } + + private static Map removeKey(Map original, String key) { + final var result = new LinkedHashMap(); + for (var entry : original.entrySet()) { + if (!entry.getKey().equals(key)) { + result.put(entry.getKey(), entry.getValue()); + } + } + return result; + } + + /// Describes schema for logging + private static String describeSchema(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema ignored -> "empty"; + case RefSchema(var ref) -> "ref:" + ref; + case TypeSchema(var type) -> "type:" + type; + case EnumSchema(var values) -> "enum[" + String.join(",", values) + "]"; + case ElementsSchema(var elem) -> "elements[" + describeSchema(elem) + "]"; + case PropertiesSchema(var props, var optProps, var add) -> { + final var parts = new ArrayList(); + if (!props.isEmpty()) parts.add("required{" + String.join(",", props.keySet()) + "}"); + if (!optProps.isEmpty()) parts.add("optional{" + String.join(",", optProps.keySet()) + "}"); + if (add) parts.add("additional"); + yield "properties[" + String.join(",", parts) + "]"; + } + case ValuesSchema(var val) -> "values[" + describeSchema(val) + "]"; + case DiscriminatorSchema(var disc, var mapping) -> + "discriminator[" + disc + "→{" + String.join(",", mapping.keySet()) + "}]"; + case NullableSchema(var inner) -> "nullable[" + describeSchema(inner) + "]"; + }; + } + + @Property(generation = GenerationMode.AUTO) + @SuppressWarnings({"unchecked", "rawtypes"}) + void generatedValidatorPassesCompliantDocuments(@ForAll("jtdSchemas") JtdTestSchema schema) throws Exception { + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); + LOG.finer(() -> "Executing generatedValidatorPassesCompliantDocuments"); + + final var schemaDescription = describeSchema(schema); + LOG.fine(() -> "Testing schema: " + schemaDescription); + + // Skip problematic combinations + if (schemaDescription.contains("elements[discriminator[") && schemaDescription.contains("type=")) { + LOG.fine(() -> "Skipping problematic schema: " + schemaDescription); + return; + } + + // Generate and compile schema + final var rootNode = testSchemaToRootNode(schema); + final var tempDir = Files.createTempDirectory("jtd-esm-prop-test-"); + + // Write schema JSON + final var schemaJson = jtdSchemaToJsonObject(schema); + final var schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, Json.toDisplayString(schemaJson, 0), StandardCharsets.UTF_8); + + // Generate JS validator + final var outJs = JtdToEsmCli.run(schemaFile, tempDir); + + // Test compliant document + final var compliantDoc = buildCompliantDocument(schema); + // Skip null compliant documents (NullableSchema can return null) + if (compliantDoc == null) { + LOG.fine(() -> "Skipping null compliant document for schema: " + schemaDescription); + cleanup(tempDir); + return; + } + final var runnerCode = buildTestRunner(outJs, List.of( + Map.of("name", "compliant", "data", compliantDoc, "expectEmpty", true) + )); + + final var runnerFile = tempDir.resolve("runner.mjs"); + Files.writeString(runnerFile, runnerCode, StandardCharsets.UTF_8); + + final var result = runBunTest(tempDir, runnerFile); + LOG.fine(() -> "Test result - exitCode: " + result.exitCode() + ", output: " + result.output()); + if (result.exitCode() != 0) { + LOG.severe(() -> String.format("Test failed for schema: %s%nCompliant document: %s%nExit code: %d%nOutput: %s", + schemaDescription, compliantDoc, result.exitCode(), result.output())); + } + assertThat(result.exitCode()).as("Compliant document should pass validation for schema: %s", schemaDescription).isEqualTo(0); + assertThat(result.output()).as("Test output for schema: %s", schemaDescription).contains("passed", "0 failed"); + + // Cleanup + cleanup(tempDir); + } + + @Property(generation = GenerationMode.AUTO) + @SuppressWarnings({"unchecked", "rawtypes"}) + void generatedValidatorRejectsFailingDocuments(@ForAll("jtdSchemas") JtdTestSchema schema) throws Exception { + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); + LOG.finer(() -> "Executing generatedValidatorRejectsFailingDocuments"); + + final var schemaDescription = describeSchema(schema); + LOG.fine(() -> "Testing schema: " + schemaDescription); + + // Skip problematic combinations + if (schemaDescription.contains("elements[discriminator[") && schemaDescription.contains("type=")) { + LOG.fine(() -> "Skipping problematic schema: " + schemaDescription); + return; + } + + // Skip schemas that accept everything + if (schema instanceof EmptySchema || schema instanceof NullableSchema) { + LOG.fine(() -> "Skipping schema that accepts everything: " + schemaDescription); + return; + } + + // Generate and compile schema + final var rootNode = testSchemaToRootNode(schema); + final var tempDir = Files.createTempDirectory("jtd-esm-prop-test-"); + + // Write schema JSON + final var schemaJson = jtdSchemaToJsonObject(schema); + final var schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, Json.toDisplayString(schemaJson, 0), StandardCharsets.UTF_8); + + // Generate JS validator + final var outJs = JtdToEsmCli.run(schemaFile, tempDir); + + // Create failing documents + final var compliantDoc = buildCompliantDocument(schema); + final var failingDocs = createFailingDocuments(schema, compliantDoc); + + if (failingDocs.isEmpty()) { + LOG.fine(() -> "No failing documents for schema: " + schemaDescription); + cleanup(tempDir); + return; + } + + // Build test cases (filter out null failing docs) + final var testCases = new ArrayList>(); + for (int i = 0; i < failingDocs.size(); i++) { + final var failingDoc = failingDocs.get(i); + if (failingDoc != null) { + testCases.add(Map.of( + "name", "failing-" + i, + "data", failingDoc, + "expectEmpty", false + )); + } + } + + final var runnerCode = buildTestRunner(outJs, testCases); + final var runnerFile = tempDir.resolve("runner.mjs"); + Files.writeString(runnerFile, runnerCode, StandardCharsets.UTF_8); + + final var result = runBunTest(tempDir, runnerFile); + assertThat(result.exitCode()).as("Failing documents should be rejected for schema: %s", schemaDescription).isEqualTo(0); + assertThat(result.output()).as("Test output for failing documents with schema: %s", schemaDescription).contains("passed", "0 failed"); + + // Cleanup + cleanup(tempDir); + } + + private static JsonObject jtdSchemaToJsonObject(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema ignored -> JsonObject.of(Map.of()); + case RefSchema(var ref) -> { + final Map map = Map.of("ref", JsonString.of(ref)); + yield JsonObject.of(map); + } + case TypeSchema(var type) -> { + final Map map = Map.of("type", JsonString.of(type)); + yield JsonObject.of(map); + } + case EnumSchema(var values) -> { + final Map map = Map.of("enum", JsonArray.of(values.stream().map(JsonString::of).toList())); + yield JsonObject.of(map); + } + case ElementsSchema(var elem) -> { + final Map map = Map.of("elements", jtdSchemaToJsonObject(elem)); + yield JsonObject.of(map); + } + case PropertiesSchema(var props, var optProps, var add) -> { + final var schemaMap = new LinkedHashMap(); + if (!props.isEmpty()) { + final Map propsMap = props.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> jtdSchemaToJsonObject(e.getValue()), + (a, b) -> a, + LinkedHashMap::new + )); + schemaMap.put("properties", JsonObject.of(propsMap)); + } + if (!optProps.isEmpty()) { + final Map optMap = optProps.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> jtdSchemaToJsonObject(e.getValue()), + (a, b) -> a, + LinkedHashMap::new + )); + schemaMap.put("optionalProperties", JsonObject.of(optMap)); + } + if (add) { + schemaMap.put("additionalProperties", JsonBoolean.of(true)); + } + yield JsonObject.of(schemaMap); + } + case ValuesSchema(var val) -> { + final Map map = Map.of("values", jtdSchemaToJsonObject(val)); + yield JsonObject.of(map); + } + case DiscriminatorSchema(var disc, var mapping) -> { + final var schemaMap = new LinkedHashMap(); + schemaMap.put("discriminator", JsonString.of(disc)); + final Map mappingMap = mapping.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> jtdSchemaToJsonObject(e.getValue()), + (a, b) -> a, + LinkedHashMap::new + )); + schemaMap.put("mapping", JsonObject.of(mappingMap)); + yield JsonObject.of(schemaMap); + } + case NullableSchema(var inner) -> { + final var innerSchema = jtdSchemaToJsonObject(inner); + final var nullableMap = new LinkedHashMap(); + nullableMap.putAll(innerSchema.members()); + nullableMap.put("nullable", JsonBoolean.of(true)); + yield JsonObject.of(nullableMap); + } + }; + } + + private static String buildTestRunner(Path validatorPath, List> testCases) { + final var sb = new StringBuilder(); + sb.append("import { validate } from '").append(validatorPath.toUri()).append("';\n\n"); + sb.append("const testCases = [\n"); + + for (var tc : testCases) { + sb.append(" {\n"); + sb.append(" name: '").append(tc.get("name")).append("',\n"); + sb.append(" data: ").append(jsonValueToJs(tc.get("data"))).append(",\n"); + sb.append(" expectEmpty: ").append(tc.get("expectEmpty")).append("\n"); + sb.append(" },\n"); + } + + sb.append("];\n\n"); + sb.append("let passed = 0;\n"); + sb.append("let failed = 0;\n\n"); + sb.append("for (const tc of testCases) {\n"); + sb.append(" const errors = validate(tc.data);\n"); + sb.append(" const isEmpty = errors.length === 0;\n"); + sb.append(" if (isEmpty === tc.expectEmpty) {\n"); + sb.append(" passed++;\n"); + sb.append(" } else {\n"); + sb.append(" console.log(`✗ ${tc.name} - expected ${tc.expectEmpty ? 'no errors' : 'errors'}, got ${JSON.stringify(errors)}`);\n"); + sb.append(" failed++;\n"); + sb.append(" }\n"); + sb.append("}\n\n"); + sb.append("console.log(`${passed} passed, ${failed} failed`);\n"); + sb.append("process.exit(failed > 0 ? 1 : 0);\n"); + + return sb.toString(); + } + + private static String jsonValueToJs(Object value) { + if (value == null) return "null"; + if (value instanceof Boolean) return value.toString(); + if (value instanceof Number) return value.toString(); + if (value instanceof String) return "\"" + value + "\""; + if (value instanceof List) { + final var lst = (List) value; + return "[" + lst.stream().map(JtdEsmPropertyTest::jsonValueToJs).collect(Collectors.joining(", ")) + "]"; + } + if (value instanceof Map) { + final var map = (Map) value; + return "{" + map.entrySet().stream() + .map(e -> jsonValueToJs(e.getKey()) + ": " + jsonValueToJs(e.getValue())) + .collect(Collectors.joining(", ")) + "}"; + } + return "null"; + } + + private static boolean isBunAvailable() { + try { + final var p = new ProcessBuilder("bun", "--version") + .redirectErrorStream(true) + .start(); + return p.waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + private static TestResult runBunTest(Path workingDir, Path runnerFile) throws Exception { + final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) + .directory(workingDir.toFile()) + .redirectErrorStream(true) + .start(); + + final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + final var exitCode = p.waitFor(); + + return new TestResult(exitCode, output); + } + + private static void cleanup(Path tempDir) throws IOException { + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + // Ignore cleanup errors + } + }); + } + + private record TestResult(int exitCode, String output) {} +} diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java index bb61ee8..611930d 100644 --- a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java @@ -1,171 +1,441 @@ package io.github.simbo1905.json.jtd.codegen; -import jdk.sandbox.java.util.json.Json; -import jdk.sandbox.java.util.json.JsonArray; -import jdk.sandbox.java.util.json.JsonObject; -import jdk.sandbox.java.util.json.JsonString; -import jdk.sandbox.java.util.json.JsonValue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; -import java.util.Map; import java.util.logging.Logger; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assumptions.assumeTrue; +/// Tests for the new stack-based JTD to ESM code generator. +/// Uses bun to execute generated JavaScript validators. final class JtdToEsmCodegenTest extends JtdEsmCodegenLoggingConfig { private static final Logger LOG = Logger.getLogger(JtdToEsmCodegenTest.class.getName()); @Test - void parsesFlatSchemaSubset() throws Exception { - LOG.info(() -> "Running parsesFlatSchemaSubset"); + void parsesSimpleBooleanTypeSchema() { + LOG.info(() -> "Running parsesSimpleBooleanTypeSchema"); - final var json = Files.readString(resource("odc-chart-event-v1.jtd.json"), StandardCharsets.UTF_8); - final var schema = JtdParser.parseString(json); + final var schema = """ + {"type": "boolean"} + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.id()).isEqualTo("JtdSchema"); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.TypeNode.class); + + final var typeNode = (JtdAst.TypeNode) root.rootSchema(); + assertThat(typeNode.type()).isEqualTo("boolean"); + } + + @Test + void parsesSchemaWithMetadataId() { + LOG.info(() -> "Running parsesSchemaWithMetadataId"); + + final var schema = """ + { + "type": "string", + "metadata": {"id": "my-schema-v1"} + } + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.id()).isEqualTo("my-schema-v1"); + } + + @Test + void parsesEnumSchema() { + LOG.info(() -> "Running parsesEnumSchema"); + + final var schema = """ + {"enum": ["active", "inactive", "pending"]} + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.EnumNode.class); + + final var enumNode = (JtdAst.EnumNode) root.rootSchema(); + assertThat(enumNode.values()).containsExactly("active", "inactive", "pending"); + } + + @Test + void parsesElementsArraySchema() { + LOG.info(() -> "Running parsesElementsArraySchema"); + + final var schema = """ + { + "elements": {"type": "string"}, + "metadata": {"id": "string-array"} + } + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.ElementsNode.class); + + final var elementsNode = (JtdAst.ElementsNode) root.rootSchema(); + assertThat(elementsNode.schema()).isInstanceOf(JtdAst.TypeNode.class); + } + + @Test + void parsesNestedElementsSchema() { + LOG.info(() -> "Running parsesNestedElementsSchema"); + + final var schema = """ + { + "elements": { + "elements": {"type": "int32"} + }, + "metadata": {"id": "matrix"} + } + """; - assertThat(schema.id()).isEqualTo("odc-chart-event-v1"); - assertThat(schema.properties().keySet()).containsExactlyInAnyOrder("src", "action", "domain", "data"); - assertThat(schema.optionalProperties().keySet()).containsExactly("ts"); + final var root = JtdParser.parseString(schema); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.ElementsNode.class); + + final var outer = (JtdAst.ElementsNode) root.rootSchema(); + assertThat(outer.schema()).isInstanceOf(JtdAst.ElementsNode.class); + } + + @Test + void parsesValuesMapSchema() { + LOG.info(() -> "Running parsesValuesMapSchema"); + + final var schema = """ + { + "values": {"type": "string"}, + "metadata": {"id": "string-map"} + } + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.ValuesNode.class); + } + + @Test + void parsesDiscriminatorUnionSchema() { + LOG.info(() -> "Running parsesDiscriminatorUnionSchema"); + + final var schema = """ + { + "discriminator": "type", + "mapping": { + "cat": { + "properties": { + "name": {"type": "string"}, + "meow": {"type": "boolean"} + } + }, + "dog": { + "properties": { + "name": {"type": "string"}, + "bark": {"type": "boolean"} + } + } + }, + "metadata": {"id": "animal-union"} + } + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.DiscriminatorNode.class); + + final var discNode = (JtdAst.DiscriminatorNode) root.rootSchema(); + assertThat(discNode.discriminator()).isEqualTo("type"); + assertThat(discNode.mapping()).containsKeys("cat", "dog"); } @Test - void rejectsUnsupportedFeaturesWithRequiredMessage() { - LOG.info(() -> "Running rejectsUnsupportedFeaturesWithRequiredMessage"); + void parsesNullableWrapperSchema() { + LOG.info(() -> "Running parsesNullableWrapperSchema"); - final var bad = """ + final var schema = """ { - "properties": { "x": { "type": "string" } }, - "elements": { "type": "string" }, - "metadata": { "id": "bad" } + "type": "string", + "nullable": true, + "metadata": {"id": "nullable-string"} } """; - assertThatThrownBy(() -> JtdParser.parseString(bad)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Unsupported JTD feature: elements. This experimental tool only supports flat schemas with properties, optionalProperties, type, and enum."); + final var root = JtdParser.parseString(schema); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.NullableNode.class); + + final var nullableNode = (JtdAst.NullableNode) root.rootSchema(); + assertThat(nullableNode.wrapped()).isInstanceOf(JtdAst.TypeNode.class); + } + + @Test + void parsesRefAndDefinitions() { + LOG.info(() -> "Running parsesRefAndDefinitions"); + + final var schema = """ + { + "definitions": { + "dataValue": {"type": "string"} + }, + "properties": { + "data": {"ref": "dataValue"} + }, + "metadata": {"id": "ref-test"} + } + """; + + final var root = JtdParser.parseString(schema); + assertThat(root.definitions()).containsKey("dataValue"); + assertThat(root.rootSchema()).isInstanceOf(JtdAst.PropertiesNode.class); + } + + @Test + void rejectsUnknownType() { + LOG.info(() -> "Running rejectsUnknownType"); + + final var schema = """ + {"type": "unknown"} + """; + + assertThatThrownBy(() -> JtdParser.parseString(schema)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown type"); + } + + @Test + void rejectsInvalidEnum() { + LOG.info(() -> "Running rejectsInvalidEnum"); + + final var schema = """ + {"enum": ["a", 123, "c"]} + """; + + assertThatThrownBy(() -> JtdParser.parseString(schema)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("to be a string"); } @Test - void rendersEsmModuleWithValidateExport() throws Exception { - LOG.info(() -> "Running rendersEsmModuleWithValidateExport"); + void generatedBooleanValidatorPassesValidCases(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedBooleanValidatorPassesValidCases"); + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); + + final var schema = """ + {"type": "boolean", "metadata": {"id": "bool-test"}} + """; + + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); + + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); + + // Create test runner + final var runner = """ + import { validate } from '%s'; + + const results = []; + + // Valid cases + results.push({ name: 'true', errors: validate(true), expectEmpty: true }); + results.push({ name: 'false', errors: validate(false), expectEmpty: true }); + + // Invalid cases + results.push({ name: 'string', errors: validate('hello'), expectEmpty: false }); + results.push({ name: 'number', errors: validate(42), expectEmpty: false }); + results.push({ name: 'null', errors: validate(null), expectEmpty: false }); + results.push({ name: 'object', errors: validate({}), expectEmpty: false }); + results.push({ name: 'array', errors: validate([]), expectEmpty: false }); + + console.log(JSON.stringify(results)); + """.formatted(outJs.toUri()); + + final Path runnerFile = tempDir.resolve("runner.mjs"); + Files.writeString(runnerFile, runner, StandardCharsets.UTF_8); + + final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) + .directory(tempDir.toFile()) + .redirectErrorStream(true) + .start(); - final var json = Files.readString(resource("odc-chart-event-v1.jtd.json"), StandardCharsets.UTF_8); - final var schema = JtdParser.parseString(json); - final var digest = Sha256.digest(resource("odc-chart-event-v1.jtd.json")); - final var shaHex = Sha256.hex(digest); - final var shaPrefix8 = Sha256.hexPrefix8(digest); + final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + final int code = p.waitFor(); + + assertThat(code).as("bun exit code; output:\n%s", output).isEqualTo(0); - final var js = EsmRenderer.render(schema, shaHex, shaPrefix8); + // Parse and verify results + final var json = jdk.sandbox.java.util.json.Json.parse(output); + final var results = (jdk.sandbox.java.util.json.JsonArray) json; - assertThat(js).contains("export function validate(instance)"); - assertThat(js).contains("const SCHEMA_ID = \"odc-chart-event-v1\""); - assertThat(js).contains("on_click"); - assertThat(js).contains("schemaPath: \"/properties/src/type\""); - assertThat(js).contains("schemaPath: \"/properties/action/enum\""); - assertThat(js).contains("schemaPath: \"/optionalProperties/ts/type\""); + for (jdk.sandbox.java.util.json.JsonValue v : results.elements()) { + final var obj = (jdk.sandbox.java.util.json.JsonObject) v; + final String name = ((jdk.sandbox.java.util.json.JsonString) obj.get("name")).string(); + final boolean expectEmpty = ((jdk.sandbox.java.util.json.JsonBoolean) obj.get("expectEmpty")).bool(); + final var errors = (jdk.sandbox.java.util.json.JsonArray) obj.get("errors"); + + if (expectEmpty) { + assertThat(errors.elements()).as("Test case '%s' should have no errors", name).isEmpty(); + } else { + assertThat(errors.elements()).as("Test case '%s' should have errors", name).isNotEmpty(); + } + } } @Test - void generatedValidateMatchesExampleCasesWhenNodeAvailable() throws Exception { - LOG.info(() -> "Running generatedValidateMatchesExampleCasesWhenNodeAvailable"); + void generatedStringArrayValidatorWorks(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedStringArrayValidatorWorks"); + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); - assumeTrue(isNodeAvailable(), "Node.js is required for executing generated ES modules in tests"); + final var schema = """ + { + "elements": {"type": "string"}, + "metadata": {"id": "string-array-test"} + } + """; - final Path temp = Files.createTempDirectory("jtd-esm-codegen-test-"); - Files.writeString(temp.resolve("package.json"), "{ \"type\": \"module\" }", StandardCharsets.UTF_8); + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); - final Path schemaFile = resource("odc-chart-event-v1.jtd.json"); - final Path outJs = JtdToEsmCli.run(schemaFile, temp); + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); final var runner = """ - import { validate } from %s; + import { validate } from '%s'; - const cases = [ - { name: "valid_string_data", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS" } }, - { name: "valid_number_data", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: 123 } }, - { name: "valid_with_ts", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS", ts: "2025-02-05T10:30:00Z" } }, + const results = []; - { name: "missing_src", instance: { action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS" } }, - { name: "src_wrong_type", instance: { src: 123, action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS" } }, - { name: "action_invalid", instance: { src: "bump_chart", action: "INVALID", domain: "mbl_comparison.lender", data: "LEEDS" } }, - { name: "ts_invalid", instance: { src: "bump_chart", action: "on_click", domain: "mbl_comparison.lender", data: "LEEDS", ts: "not-a-date" } }, - ]; + // Valid cases + results.push({ name: 'empty-array', errors: validate([]), expectEmpty: true }); + results.push({ name: 'string-array', errors: validate(["a", "b", "c"]), expectEmpty: true }); - const results = cases.map(c => ({ name: c.name, errors: validate(c.instance) })); - console.log(JSON.stringify(results)); - """.formatted(jsImportSpecifier(outJs)); + // Invalid cases + results.push({ name: 'not-array', errors: validate("hello"), expectEmpty: false }); + results.push({ name: 'mixed-array', errors: validate(["a", 123, "c"]), expectEmpty: false }); + results.push({ name: 'number-array', errors: validate([1, 2, 3]), expectEmpty: false }); - Files.writeString(temp.resolve("runner.mjs"), runner, StandardCharsets.UTF_8); + console.log(JSON.stringify(results)); + """.formatted(outJs.toUri()); + + final Path runnerFile = tempDir.resolve("runner.mjs"); + Files.writeString(runnerFile, runner, StandardCharsets.UTF_8); + + final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) + .directory(tempDir.toFile()) + .redirectErrorStream(true) + .start(); + + final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + final int code = p.waitFor(); + + assertThat(code).as("bun exit code; output:\n%s", output).isEqualTo(0); + } + + @Test + void generatedObjectValidatorChecksRequiredAndOptional(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedObjectValidatorChecksRequiredAndOptional"); + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); + + final var schema = """ + { + "properties": { + "id": {"type": "int32"}, + "name": {"type": "string"} + }, + "optionalProperties": { + "email": {"type": "string"} + }, + "metadata": {"id": "user-schema"} + } + """; + + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); + + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); + + final var runner = """ + import { validate } from '%s'; + + const results = []; + + // Valid cases + results.push({ name: 'complete', errors: validate({id: 1, name: "Alice", email: "a@b.com"}), expectEmpty: true }); + results.push({ name: 'without-optional', errors: validate({id: 1, name: "Alice"}), expectEmpty: true }); + + // Invalid cases + results.push({ name: 'missing-required', errors: validate({name: "Alice"}), expectEmpty: false }); + results.push({ name: 'wrong-type', errors: validate({id: "not-a-number", name: "Alice"}), expectEmpty: false }); + results.push({ name: 'not-object', errors: validate("hello"), expectEmpty: false }); + + console.log(JSON.stringify(results)); + """.formatted(outJs.toUri()); + + final Path runnerFile = tempDir.resolve("runner.mjs"); + Files.writeString(runnerFile, runner, StandardCharsets.UTF_8); + + final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) + .directory(tempDir.toFile()) + .redirectErrorStream(true) + .start(); - final var p = new ProcessBuilder("node", temp.resolve("runner.mjs").toString()) - .directory(temp.toFile()) - .redirectErrorStream(true) - .start(); final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); final int code = p.waitFor(); - assertThat(code).as("node exit code; output:\n%s", output).isEqualTo(0); + assertThat(code).as("bun exit code; output:\n%s", output).isEqualTo(0); + } - final JsonArray results = (JsonArray) Json.parse(output); - assertThat(findErrors(results, "valid_string_data")).isEmpty(); - assertThat(findErrors(results, "valid_number_data")).isEmpty(); - assertThat(findErrors(results, "valid_with_ts")).isEmpty(); + @Test + void generatedValidatorIncludesOnlyNeededHelpers(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedValidatorIncludesOnlyNeededHelpers"); + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); - assertThat(findErrors(results, "missing_src")) - .containsExactly(Map.of("instancePath", "", "schemaPath", "/properties/src")); + // Schema that only needs basic type checks + final var simpleSchema = """ + {"type": "boolean", "metadata": {"id": "simple"}} + """; - assertThat(findErrors(results, "src_wrong_type")) - .containsExactly(Map.of("instancePath", "/src", "schemaPath", "/properties/src/type")); + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, simpleSchema, StandardCharsets.UTF_8); - assertThat(findErrors(results, "action_invalid")) - .containsExactly(Map.of("instancePath", "/action", "schemaPath", "/properties/action/enum")); + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); + final String generated = Files.readString(outJs, StandardCharsets.UTF_8); - assertThat(findErrors(results, "ts_invalid")) - .containsExactly(Map.of("instancePath", "/ts", "schemaPath", "/optionalProperties/ts/type")); + // Should NOT include unused helpers + assertThat(generated).doesNotContain("isTimestamp"); + assertThat(generated).doesNotContain("isIntInRange"); + assertThat(generated).doesNotContain("isFloat"); + + // Should use typeof directly + assertThat(generated).contains("typeof"); } - private static Path resource(String name) { - return Path.of("src", "test", "resources", name).toAbsolutePath(); + @Test + void generatedTimestampValidatorIncludesTimestampHelper(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedTimestampValidatorIncludesTimestampHelper"); + assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); + + final var schema = """ + {"type": "timestamp", "metadata": {"id": "ts-test"}} + """; + + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); + + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); + final String generated = Files.readString(outJs, StandardCharsets.UTF_8); + + // Should include timestamp helper since it's needed + assertThat(generated).contains("isTimestamp"); + assertThat(generated).contains("Date.parse"); } - private static boolean isNodeAvailable() { + private static boolean isBunAvailable() { try { - final var p = new ProcessBuilder("node", "--version") - .redirectErrorStream(true) - .start(); + final var p = new ProcessBuilder("bun", "--version") + .redirectErrorStream(true) + .start(); final int code = p.waitFor(); return code == 0; } catch (Exception ignored) { return false; } } - - private static String jsImportSpecifier(Path outJs) { - // With `package.json` { type: "module" } present, .js is treated as ESM. - final var rel = "./" + outJs.getFileName(); - return JsonString.of(rel).toString(); - } - - private static List> findErrors(JsonArray results, String caseName) { - for (JsonValue v : results.elements()) { - final var obj = (JsonObject) v; - if (obj.get("name") instanceof JsonString js && js.string().equals(caseName)) { - final var errors = (JsonArray) obj.get("errors"); - return errors.elements().stream() - .map(e -> (JsonObject) e) - .map(e -> Map.of( - "instancePath", ((JsonString) e.get("instancePath")).string(), - "schemaPath", ((JsonString) e.get("schemaPath")).string() - )) - .toList(); - } - } - throw new AssertionError("Case not found: " + caseName); - } } - diff --git a/jtd-esm-codegen/src/test/js/boolean-schema.test.js b/jtd-esm-codegen/src/test/js/boolean-schema.test.js new file mode 100644 index 0000000..b922daa --- /dev/null +++ b/jtd-esm-codegen/src/test/js/boolean-schema.test.js @@ -0,0 +1,44 @@ +/// Example test for simple boolean schema +/// Tests that the generated validator correctly validates boolean values + +import { test, assertEq, assertArrayEq, runTests } from './test-runner.js'; +import { validate } from '../resources/expected/boolean-schema.js'; + +test('validate returns empty array for true', () => { + const errors = validate(true); + assertArrayEq(errors, [], 'Should have no errors for boolean true'); +}); + +test('validate returns empty array for false', () => { + const errors = validate(false); + assertArrayEq(errors, [], 'Should have no errors for boolean false'); +}); + +test('validate returns error for string', () => { + const errors = validate('hello'); + assertEq(errors.length, 1, 'Should have one error'); + assertEq(errors[0].instancePath, '', 'instancePath should be empty'); + assertEq(errors[0].schemaPath, '/type', 'schemaPath should be /type'); +}); + +test('validate returns error for number', () => { + const errors = validate(42); + assertEq(errors.length, 1, 'Should have one error'); +}); + +test('validate returns error for null', () => { + const errors = validate(null); + assertEq(errors.length, 1, 'Should have one error'); +}); + +test('validate returns error for object', () => { + const errors = validate({}); + assertEq(errors.length, 1, 'Should have one error'); +}); + +test('validate returns error for array', () => { + const errors = validate([]); + assertEq(errors.length, 1, 'Should have one error'); +}); + +runTests(); diff --git a/jtd-esm-codegen/src/test/js/test-runner.js b/jtd-esm-codegen/src/test/js/test-runner.js new file mode 100644 index 0000000..de96ea2 --- /dev/null +++ b/jtd-esm-codegen/src/test/js/test-runner.js @@ -0,0 +1,129 @@ +/// Vanilla JS test runner for bun - zero dependencies, ESM2020 +/// Usage: bun run test-runner.js +/// Or programmatically from Java tests + +const FAIL = '\x1b[31m✗\x1b[0m'; +const PASS = '\x1b[32m✓\x1b[0m'; + +let totalTests = 0; +let passedTests = 0; +let failedTests = 0; +const failures = []; + +/// Assert that two values are strictly equal +function assertEq(actual, expected, message) { + if (actual !== expected) { + throw new Error(`${message || 'Assertion failed'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +/// Assert that value is truthy +function assertTrue(value, message) { + if (!value) { + throw new Error(message || 'Expected truthy value'); + } +} + +/// Assert that value is falsy +function assertFalse(value, message) { + if (value) { + throw new Error(message || 'Expected falsy value'); + } +} + +/// Assert that two arrays are deeply equal +function assertArrayEq(actual, expected, message) { + if (!Array.isArray(actual) || !Array.isArray(expected)) { + throw new Error(`${message || 'Not arrays'}: expected array, got ${typeof actual}`); + } + if (actual.length !== expected.length) { + throw new Error(`${message || 'Array length mismatch'}: expected ${expected.length}, got ${actual.length}`); + } + for (let i = 0; i < actual.length; i++) { + if (JSON.stringify(actual[i]) !== JSON.stringify(expected[i])) { + throw new Error(`${message || 'Array element mismatch'} at index ${i}: expected ${JSON.stringify(expected[i])}, got ${JSON.stringify(actual[i])}`); + } + } +} + +/// Assert that two objects are deeply equal (shallow comparison) +function assertObjEq(actual, expected, message) { + const actualKeys = Object.keys(actual).sort(); + const expectedKeys = Object.keys(expected).sort(); + if (actualKeys.length !== expectedKeys.length) { + throw new Error(`${message || 'Object key count mismatch'}: expected ${expectedKeys.length} keys, got ${actualKeys.length}`); + } + for (const key of actualKeys) { + if (JSON.stringify(actual[key]) !== JSON.stringify(expected[key])) { + throw new Error(`${message || 'Object value mismatch'} at key "${key}": expected ${JSON.stringify(expected[key])}, got ${JSON.stringify(actual[key])}`); + } + } +} + +/// Run a test function +function test(name, fn) { + totalTests++; + try { + fn(); + passedTests++; + console.log(`${PASS} ${name}`); + return true; + } catch (e) { + failedTests++; + failures.push({ name, error: e.message }); + console.log(`${FAIL} ${name}`); + console.log(` ${e.message}`); + return false; + } +} + +/// Run all tests and print summary +function runTests() { + console.log(`\n${'='.repeat(50)}`); + console.log(`Total: ${totalTests}, Passed: ${passedTests}, Failed: ${failedTests}`); + + if (failedTests > 0) { + console.log(`\nFailures:`); + for (const f of failures) { + console.log(` - ${f.name}: ${f.error}`); + } + process.exit(1); + } else { + console.log('\nAll tests passed!'); + process.exit(0); + } +} + +/// Load and run a test module +async function runTestModule(modulePath) { + try { + const module = await import(modulePath); + + // If module exports a run() function, call it + if (typeof module.run === 'function') { + await module.run(); + } + + // Run any tests that were defined + runTests(); + } catch (e) { + console.error(`Failed to load test module ${modulePath}: ${e.message}`); + process.exit(1); + } +} + +/// Main entry point when run directly +if (import.meta.main) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error('Usage: bun run test-runner.js '); + process.exit(1); + } + + // Resolve path relative to current working directory + const path = await import('path'); + const modulePath = path.resolve(args[0]); + await runTestModule('file://' + modulePath); +} + +export { test, assertEq, assertTrue, assertFalse, assertArrayEq, assertObjEq, runTests, runTestModule }; diff --git a/jtd-esm-codegen/src/test/resources/expected/boolean-schema.js b/jtd-esm-codegen/src/test/resources/expected/boolean-schema.js new file mode 100644 index 0000000..5f46281 --- /dev/null +++ b/jtd-esm-codegen/src/test/resources/expected/boolean-schema.js @@ -0,0 +1,15 @@ +// boolean-schema.js +// Generated from JTD schema: boolean-schema +// SHA-256: (test fixture) + +export function validate(instance) { + const errors = []; + const instancePath = ""; + + // Type check for boolean + if (typeof instance !== "boolean") { + errors.push({ instancePath: "", schemaPath: "/type" }); + } + + return errors; +} diff --git a/jtd-esm-codegen/src/test/resources/jtd/boolean-schema.jtd.json b/jtd-esm-codegen/src/test/resources/jtd/boolean-schema.jtd.json new file mode 100644 index 0000000..dd97725 --- /dev/null +++ b/jtd-esm-codegen/src/test/resources/jtd/boolean-schema.jtd.json @@ -0,0 +1,3 @@ +{ + "type": "boolean" +} From b19a9f784f04adee737eb4c236d35e39097515a7 Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:42:33 +0000 Subject: [PATCH 7/9] Replace bun with junit-js for JS tests; fix inline validator generation bug (#31) - Add junit-js 2.0.0 dependency with GraalVM exclusions to avoid version conflicts - Add JUnit Vintage engine to run JUnit 4 tests under JUnit 5 Platform - Configure Surefire to discover *TestSuite.java files - Delete bun-based src/test/js/ directory (replaced by junit-js) - Add JtdEsmJsTestSuite.java to run JS tests via @RunWith(JSRunner.class) - Add boolean-schema.test.js and nested-elements-empty-focused.test.js - Tests run in GraalVM polyglot, no external JS runtime needed - Fix EsmRenderer bug where inline validator functions were never emitted: - Add generateInlineFunctions() method to emit collected inline validators - Fix collision issue by using counter instead of hashCode for function names - Support nested elements/schemas that require multiple inline validators Test results: 29 tests pass in jtd-esm-codegen (17 Java + 2 property + 10 JS) --- jtd-esm-codegen/pom.xml | 53 ++ .../json/jtd/codegen/EsmRenderer.java | 613 ++++++++---------- .../json/jtd/codegen/JtdToEsmCli.java | 2 +- .../json/jtd/codegen/GraalJsRunner.java | 61 ++ .../json/jtd/codegen/JtdEsmJsTestSuite.java | 23 + .../json/jtd/codegen/JtdEsmPropertyTest.java | 457 +++++-------- .../src/test/js/boolean-schema.test.js | 44 -- jtd-esm-codegen/src/test/js/test-runner.js | 129 ---- .../src/test/resources/boolean-schema.test.js | 53 ++ .../nested-elements-empty-focused.test.js | 66 ++ 10 files changed, 690 insertions(+), 811 deletions(-) create mode 100644 jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/GraalJsRunner.java create mode 100644 jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmJsTestSuite.java delete mode 100644 jtd-esm-codegen/src/test/js/boolean-schema.test.js delete mode 100644 jtd-esm-codegen/src/test/js/test-runner.js create mode 100644 jtd-esm-codegen/src/test/resources/boolean-schema.test.js create mode 100644 jtd-esm-codegen/src/test/resources/nested-elements-empty-focused.test.js diff --git a/jtd-esm-codegen/pom.xml b/jtd-esm-codegen/pom.xml index da8882a..4b04a83 100644 --- a/jtd-esm-codegen/pom.xml +++ b/jtd-esm-codegen/pom.xml @@ -27,6 +27,7 @@ UTF-8 21 3.6.0 + 24.1.2 @@ -63,6 +64,50 @@ 1.9.3 test + + + org.graalvm.polyglot + polyglot + ${graaljs.version} + test + + + org.graalvm.polyglot + js-community + ${graaljs.version} + pom + test + + + + + org.bitbucket.thinbus + junit-js + 2.0.0 + test + + + + org.graalvm.polyglot + * + + + org.graalvm.js + * + + + org.graalvm.truffle + * + + + + + + org.junit.vintage + junit-vintage-engine + ${junit.jupiter.version} + test + @@ -87,6 +132,14 @@ maven-surefire-plugin -ea + + **/*Test.java + **/*Tests.java + **/*TestSuite.java + + + false + diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java index 4713c0e..6abfcbc 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/EsmRenderer.java @@ -4,14 +4,13 @@ import static io.github.simbo1905.json.jtd.codegen.JtdAst.*; -/// Generates optimal ES2020 ESM validators using explicit stack-based validation. +/// Generates ES2020 ESM validators per JTD_CODEGEN_SPEC.md /// -/// Key features: -/// - Generates only the code needed (no unused helper functions) -/// - Uses explicit stack to avoid recursion and stack overflow -/// - Supports all RFC 8927 forms: elements, values, discriminator, nullable -/// - Inlines primitive checks, uses loops for arrays -/// - Generates separate functions for complex types +/// Key principles: +/// - No runtime stack - direct code emission +/// - No helper functions - inline all checks +/// - Emit only what the schema requires (no dead code) +/// - Return {instancePath, schemaPath} error objects per RFC 8927 final class EsmRenderer { private EsmRenderer() {} @@ -25,12 +24,6 @@ static String render(RootNode schema, String sha256Hex, String shaPrefix8) { ctx.shaPrefix8 = shaPrefix8; ctx.schemaId = schema.id(); - // Analyze schema to determine what helpers we actually need - analyzeSchema(schema.rootSchema(), ctx); - for (var def : schema.definitions().values()) { - analyzeSchema(def, ctx); - } - final var sb = new StringBuilder(8 * 1024); // Header @@ -41,349 +34,224 @@ static String render(RootNode schema, String sha256Hex, String shaPrefix8) { sb.append("const SCHEMA_ID = ").append(jsString(schema.id())).append(";\n\n"); - // Generate enum constants + // Collect all enum constants used in the schema + collectEnums(schema.rootSchema(), ctx); + for (var def : schema.definitions().values()) { + collectEnums(def, ctx); + } generateEnumConstants(sb, ctx); - // Generate only the helper functions we need - generateHelpers(sb, ctx); - // Generate validation functions for definitions for (var entry : schema.definitions().entrySet()) { final String defName = entry.getKey(); final JtdNode defNode = entry.getValue(); - generateDefinitionValidator(sb, defName, defNode, ctx); - } - - // Generate inline validation functions for complex nested types - ctx.inlineValidators.clear(); - ctx.inlineValidatorCounter = 0; - collectInlineValidators(schema.rootSchema(), "root", ctx); - // Remove root from inline validators - we'll generate it separately - ctx.inlineValidators.remove("root"); - - // Track discriminator mappings - trackDiscriminatorMappings(schema.rootSchema(), ctx); - - for (var entry : ctx.inlineValidators.entrySet()) { - final String key = entry.getKey(); - final JtdNode node = entry.getValue(); - final String discriminatorKey = ctx.discriminatorMappings.get(key); - generateInlineValidator(sb, key, node, ctx, discriminatorKey); + generateDefinitionFunction(sb, defName, defNode, ctx); } - // Generate the root validator (referenced by validate() but defined before it) - generateInlineValidator(sb, "root", schema.rootSchema(), ctx, null); - - // Generate main validate function with stack-based approach + // Generate the main validate function sb.append("export function validate(instance) {\n"); sb.append(" const errors = [];\n"); - sb.append(" const stack = [{ fn: validate_root, value: instance, path: '' }];\n\n"); - sb.append(" while (stack.length > 0) {\n"); - sb.append(" const frame = stack.pop();\n"); - sb.append(" frame.fn(frame.value, errors, frame.path, stack);\n"); - sb.append(" }\n\n"); + + // Emit validation logic inline for root + final var rootCode = new StringBuilder(); + generateNodeValidation(rootCode, schema.rootSchema(), ctx, "instance", "\"\"", "\"\"", " ", null); + sb.append(rootCode); + sb.append(" return errors;\n"); sb.append("}\n\n"); + // Generate inline validator functions for complex nested schemas + generateInlineFunctions(sb, ctx); + sb.append("export { SCHEMA_ID };\n"); return sb.toString(); } - /// Analyzes schema to determine which helpers are needed - private static void analyzeSchema(JtdNode node, RenderContext ctx) { + private static void collectEnums(JtdNode node, RenderContext ctx) { switch (node) { - case TypeNode tn -> { - switch (tn.type()) { - case "timestamp" -> ctx.needsTimestampCheck = true; - case "int8", "uint8", "int16", "uint16", "int32", "uint32" -> ctx.needsIntRangeCheck = true; - case "float32", "float64" -> ctx.needsFloatCheck = true; - } - } case EnumNode en -> { - final String constName = "ENUM_" + (ctx.enumConstants.size() + 1); + final String constName = "ENUM_" + (ctx.enumCounter++); ctx.enumConstants.put(constName, en.values()); } - case ElementsNode el -> analyzeSchema(el.schema(), ctx); - case ValuesNode vn -> analyzeSchema(vn.schema(), ctx); + case ElementsNode el -> collectEnums(el.schema(), ctx); + case ValuesNode vn -> collectEnums(vn.schema(), ctx); case PropertiesNode pn -> { - pn.properties().values().forEach(n -> analyzeSchema(n, ctx)); - pn.optionalProperties().values().forEach(n -> analyzeSchema(n, ctx)); - } - case DiscriminatorNode dn -> { - dn.mapping().values().forEach(n -> analyzeSchema(n, ctx)); - } - case NullableNode nn -> analyzeSchema(nn.wrapped(), ctx); - case RefNode ignored -> { - // No analysis needed - } - case EmptyNode ignored -> { - // No analysis needed + pn.properties().values().forEach(n -> collectEnums(n, ctx)); + pn.optionalProperties().values().forEach(n -> collectEnums(n, ctx)); } + case DiscriminatorNode dn -> dn.mapping().values().forEach(n -> collectEnums(n, ctx)); + case NullableNode nn -> collectEnums(nn.wrapped(), ctx); + default -> {} // No enums } } - private static void collectInlineValidators(JtdNode node, String prefix, RenderContext ctx) { - // Don't collect for simple types that can be validated inline - if (isSimpleType(node)) { - return; - } - - // Already collected? - final String key = prefix; - if (ctx.inlineValidators.containsKey(key)) { - return; - } - - ctx.inlineValidators.put(key, node); - ctx.inlineValidatorCounter++; - - // Recurse into children - switch (node) { - case ElementsNode el -> collectInlineValidators(el.schema(), key + "_elem" + ctx.inlineValidatorCounter, ctx); - case ValuesNode vn -> collectInlineValidators(vn.schema(), key + "_val" + ctx.inlineValidatorCounter, ctx); - case PropertiesNode pn -> { - pn.properties().forEach((k, v) -> { - if (!isSimpleType(v)) { - collectInlineValidators(v, key + "_" + k + ctx.inlineValidatorCounter, ctx); - } - }); - pn.optionalProperties().forEach((k, v) -> { - if (!isSimpleType(v)) { - collectInlineValidators(v, key + "_" + k + ctx.inlineValidatorCounter, ctx); - } - }); - } - case DiscriminatorNode dn -> { - dn.mapping().forEach((k, v) -> { - if (!isSimpleType(v)) { - collectInlineValidators(v, key + "_" + k + ctx.inlineValidatorCounter, ctx); - } - }); - } - case NullableNode nn -> collectInlineValidators(nn.wrapped(), key + ctx.inlineValidatorCounter, ctx); - default -> { - // No children - } - } - } - - /// Track which inline validators are discriminator mappings - private static void trackDiscriminatorMappings(JtdNode node, RenderContext ctx) { - trackDiscriminatorMappings(node, "root", null, ctx); - } - - private static void trackDiscriminatorMappings(JtdNode node, String prefix, String parentDiscriminatorKey, RenderContext ctx) { - switch (node) { - case DiscriminatorNode dn -> { - // Track mappings for this discriminator - dn.mapping().forEach((tagValue, variantSchema) -> { - // Try to find the inline validator key for this variant - for (var entry : ctx.inlineValidators.entrySet()) { - if (entry.getValue().equals(variantSchema)) { - ctx.discriminatorMappings.put(entry.getKey(), dn.discriminator()); - } - } - }); - // Also recurse into variant schemas - dn.mapping().forEach((tagValue, variantSchema) -> - trackDiscriminatorMappings(variantSchema, prefix, dn.discriminator(), ctx)); - } - case ElementsNode el -> trackDiscriminatorMappings(el.schema(), prefix + "_elem", parentDiscriminatorKey, ctx); - case ValuesNode vn -> trackDiscriminatorMappings(vn.schema(), prefix + "_val", parentDiscriminatorKey, ctx); - case PropertiesNode pn -> { - pn.properties().forEach((k, v) -> trackDiscriminatorMappings(v, prefix + "_" + k, parentDiscriminatorKey, ctx)); - pn.optionalProperties().forEach((k, v) -> trackDiscriminatorMappings(v, prefix + "_" + k, parentDiscriminatorKey, ctx)); - } - case NullableNode nn -> trackDiscriminatorMappings(nn.wrapped(), prefix, parentDiscriminatorKey, ctx); - default -> { - // No discriminator here - } - } - } - - private static boolean isSimpleType(JtdNode node) { - return node instanceof TypeNode || node instanceof EnumNode || node instanceof EmptyNode || node instanceof RefNode; - } - private static void generateEnumConstants(StringBuilder sb, RenderContext ctx) { - int i = 1; + if (ctx.enumConstants.isEmpty()) return; + for (var entry : ctx.enumConstants.entrySet()) { sb.append("const ").append(entry.getKey()).append(" = ") .append(jsStringArray(entry.getValue())).append(";\n"); - i++; - } - if (!ctx.enumConstants.isEmpty()) { - sb.append("\n"); - } - } - - private static void generateHelpers(StringBuilder sb, RenderContext ctx) { - if (ctx.needsTimestampCheck) { - sb.append("function isTimestamp(v) {\n"); - sb.append(" return typeof v === \"string\" && !Number.isNaN(Date.parse(v));\n"); - sb.append("}\n\n"); - } - - if (ctx.needsIntRangeCheck) { - sb.append("function isIntInRange(v, min, max) {\n"); - sb.append(" return Number.isInteger(v) && v >= min && v <= max;\n"); - sb.append("}\n\n"); - } - - if (ctx.needsFloatCheck) { - sb.append("function isFloat(v) {\n"); - sb.append(" return typeof v === \"number\" && Number.isFinite(v);\n"); - sb.append("}\n\n"); } + sb.append("\n"); } - private static void generateDefinitionValidator(StringBuilder sb, String defName, - JtdNode node, RenderContext ctx) { + private static void generateDefinitionFunction(StringBuilder sb, String defName, JtdNode node, RenderContext ctx) { final String safeName = toSafeName(defName); - sb.append("function validate_").append(safeName).append("(value, errors, path, stack) {\n"); - generateNodeValidation(sb, node, ctx, "value", "path", " ", null); - sb.append("}\n\n"); - } - - private static void generateInlineValidator(StringBuilder sb, String name, - JtdNode node, RenderContext ctx, String discriminatorKey) { - sb.append("function validate_").append(name).append("(value, errors, path, stack) {\n"); - generateNodeValidation(sb, node, ctx, "value", "path", " ", discriminatorKey); + sb.append("function validate_").append(safeName).append("(v, errors, p, sp) {\n"); + generateNodeValidation(sb, node, ctx, "v", "p", "sp", " ", null); sb.append("}\n\n"); } - /// Generates validation logic for a node - private static void generateNodeValidation(StringBuilder sb, JtdNode node, - RenderContext ctx, String valueExpr, String pathExpr, String indent, String discriminatorKey) { + /** + * Generates validation code for a node. + * @param discriminatorKey If non-null, this PropertiesNode is a discriminator variant and should + * skip validation of the discriminator key itself + */ + private static void generateNodeValidation(StringBuilder sb, JtdNode node, RenderContext ctx, + String valueExpr, String pathExpr, String schemaPathExpr, String indent, String discriminatorKey) { switch (node) { - case EmptyNode en -> { - // Accepts anything - no validation needed + case EmptyNode ignored -> { + // Accepts anything - no validation + } + + case NullableNode nn -> { + if (nn.wrapped() instanceof EmptyNode) { + // Nullable empty - accepts anything including null, no check needed + } else { + sb.append(indent).append("if (").append(valueExpr).append(" !== null) {\n"); + generateNodeValidation(sb, nn.wrapped(), ctx, valueExpr, pathExpr, schemaPathExpr, indent + " ", discriminatorKey); + sb.append(indent).append("}\n"); + } } case TypeNode tn -> { - final String check = generateTypeCheck(tn.type(), valueExpr); - sb.append(indent).append("if (!(").append(check).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/type' });\n"); - sb.append(indent).append("}\n"); + generateTypeCheck(sb, tn.type(), valueExpr, pathExpr, schemaPathExpr, indent); } case EnumNode en -> { final String constName = findEnumConst(ctx.enumConstants, en.values()); - sb.append(indent).append("if (!").append(constName).append(".includes(").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/enum' });\n"); + sb.append(indent).append("if (typeof ").append(valueExpr).append(" !== \"string\" || !") + .append(constName).append(".includes(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append(" + \"/enum\"});\n"); sb.append(indent).append("}\n"); } case ElementsNode el -> { - // Check it's an array + // Type guard sb.append(indent).append("if (!Array.isArray(").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/elements' });\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append("});\n"); sb.append(indent).append("} else {\n"); - // Generate element validation inline or push to stack - final JtdNode elemSchema = el.schema(); - if (isSimpleType(elemSchema)) { - // Inline simple element validation with loop - sb.append(indent).append(" for (let i = 0; i < ").append(valueExpr).append(".length; i++) {\n"); - final String elemPath = pathExpr + " + '[' + i + ']'"; - final String elemExpr = valueExpr + "[i]"; - generateNodeValidation(sb, elemSchema, ctx, elemExpr, elemPath, indent + " ", discriminatorKey); - sb.append(indent).append(" }\n"); + // Loop over elements + sb.append(indent).append(" for (let i = 0; i < ").append(valueExpr).append(".length; i++) {\n"); + final String elemValue = valueExpr + "[i]"; + final String elemPath = pathExpr + " + \"/\" + i"; + final String elemSchemaPath = schemaPathExpr + " + \"/elements\""; + + if (isLeafNode(el.schema())) { + // Inline leaf validation + generateNodeValidation(sb, el.schema(), ctx, elemValue, elemPath, elemSchemaPath, indent + " ", null); } else { - // Push elements onto stack for deferred validation - final String validatorKey = findInlineValidator(ctx, elemSchema); - sb.append(indent).append(" for (let i = ").append(valueExpr).append(".length - 1; i >= 0; i--) {\n"); - sb.append(indent).append(" stack.push({\n"); - sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); - sb.append(indent).append(" value: ").append(valueExpr).append("[i],\n"); - sb.append(indent).append(" path: ").append(pathExpr).append(" + '[' + i + ']'\n"); - sb.append(indent).append(" });\n"); - sb.append(indent).append(" }\n"); + // Complex schema - needs inline function + final String fnName = getInlineFunctionName(el.schema(), ctx); + sb.append(indent).append(" ").append(fnName).append("(") + .append(elemValue).append(", errors, ").append(elemPath) + .append(", ").append(elemSchemaPath).append(");\n"); } + sb.append(indent).append(" }\n"); sb.append(indent).append("}\n"); } case PropertiesNode pn -> { - // Check it's an object - sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ").append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '' });\n"); + // Type guard + sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ") + .append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append("});\n"); sb.append(indent).append("} else {\n"); - // Check required properties + // Required properties for (var entry : pn.properties().entrySet()) { final String key = entry.getKey(); - final JtdNode subNode = entry.getValue(); - final String childPath = pathExpr + " + '/" + pointerEscape(key) + "'"; - final String childExpr = valueExpr + "['" + key + "']"; + final JtdNode propSchema = entry.getValue(); + + // Skip discriminator key if we're in a discriminator variant + if (discriminatorKey != null && key.equals(discriminatorKey)) { + continue; + } + + sb.append(indent).append(" if (!(\"").append(key).append("\" in ") + .append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append(" + \"/properties/") + .append(jsonPointerEscape(key)).append("\"});\n"); + sb.append(indent).append(" }\n"); - sb.append(indent).append(" if (!('").append(key).append("' in ").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/properties/").append(pointerEscape(key)).append("' });\n"); - sb.append(indent).append(" } else {\n"); + // Validate value if present + sb.append(indent).append(" if (\"").append(key).append("\" in ") + .append(valueExpr).append(") {\n"); + final String propValue = valueExpr + "[\"" + key + "\"]"; + final String propPath = pathExpr + " + \"/" + jsonPointerEscape(key) + "\""; + final String propSchemaPath = schemaPathExpr + " + \"/properties/" + jsonPointerEscape(key) + "\""; - if (isSimpleType(subNode)) { - generateNodeValidation(sb, subNode, ctx, childExpr, childPath, indent + " ", discriminatorKey); + if (isLeafNode(propSchema)) { + generateNodeValidation(sb, propSchema, ctx, propValue, propPath, propSchemaPath, indent + " ", null); } else { - final String validatorKey = findInlineValidator(ctx, subNode); - sb.append(indent).append(" stack.push({\n"); - sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); - sb.append(indent).append(" value: ").append(childExpr).append(",\n"); - sb.append(indent).append(" path: ").append(childPath).append("\n"); - sb.append(indent).append(" });\n"); + final String fnName = getInlineFunctionName(propSchema, ctx); + sb.append(indent).append(" ").append(fnName).append("(") + .append(propValue).append(", errors, ").append(propPath) + .append(", ").append(propSchemaPath).append(");\n"); } - sb.append(indent).append(" }\n"); } - // Check optional properties + // Optional properties for (var entry : pn.optionalProperties().entrySet()) { final String key = entry.getKey(); - final JtdNode subNode = entry.getValue(); - final String childPath = pathExpr + " + '/" + pointerEscape(key) + "'"; - final String childExpr = valueExpr + "['" + key + "']"; + final JtdNode propSchema = entry.getValue(); + + // Skip discriminator key if we're in a discriminator variant + if (discriminatorKey != null && key.equals(discriminatorKey)) { + continue; + } - sb.append(indent).append(" if ('").append(key).append("' in ").append(valueExpr).append(") {\n"); + sb.append(indent).append(" if (\"").append(key).append("\" in ") + .append(valueExpr).append(") {\n"); + final String propValue = valueExpr + "[\"" + key + "\"]"; + final String propPath = pathExpr + " + \"/" + jsonPointerEscape(key) + "\""; + final String propSchemaPath = schemaPathExpr + " + \"/optionalProperties/" + jsonPointerEscape(key) + "\""; - if (isSimpleType(subNode)) { - generateNodeValidation(sb, subNode, ctx, childExpr, childPath, indent + " ", discriminatorKey); + if (isLeafNode(propSchema)) { + generateNodeValidation(sb, propSchema, ctx, propValue, propPath, propSchemaPath, indent + " ", null); } else { - final String validatorKey = findInlineValidator(ctx, subNode); - sb.append(indent).append(" stack.push({\n"); - sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); - sb.append(indent).append(" value: ").append(childExpr).append(",\n"); - sb.append(indent).append(" path: ").append(childPath).append("\n"); - sb.append(indent).append(" });\n"); + final String fnName = getInlineFunctionName(propSchema, ctx); + sb.append(indent).append(" ").append(fnName).append("(") + .append(propValue).append(", errors, ").append(propPath) + .append(", ").append(propSchemaPath).append(");\n"); } - sb.append(indent).append(" }\n"); } - // Check additional properties if not allowed + // Additional properties check (if not allowed) if (!pn.additionalProperties()) { - sb.append(indent).append(" const allowed = new Set(["); - boolean first = true; - for (var key : pn.properties().keySet()) { - if (!first) sb.append(", "); - sb.append("'").append(key).append("'"); - first = false; - } - for (var key : pn.optionalProperties().keySet()) { - if (!first) sb.append(", "); - sb.append("'").append(key).append("'"); - first = false; - } - // Add discriminator key if present (for discriminator mappings) + // Build list of allowed keys (including discriminator key if applicable) + final Set allowedKeys = new HashSet<>(pn.properties().keySet()); + allowedKeys.addAll(pn.optionalProperties().keySet()); + if (discriminatorKey != null) { - if (!first) sb.append(", "); - sb.append("'").append(discriminatorKey).append("'"); + allowedKeys.add(discriminatorKey); } - sb.append("]);\n"); - sb.append(indent).append(" for (const key of Object.keys(").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" if (!allowed.has(key)) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(" + '/' + key, schemaPath: '/additionalProperties' });\n"); + + sb.append(indent).append(" for (const k in ").append(valueExpr).append(") {\n"); + sb.append(indent).append(" if (").append(buildKeyCheck("k", allowedKeys)).append(") {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(" + \"/\" + k, schemaPath: ").append(schemaPathExpr).append("});\n"); sb.append(indent).append(" }\n"); sb.append(indent).append(" }\n"); } @@ -392,114 +260,167 @@ private static void generateNodeValidation(StringBuilder sb, JtdNode node, } case ValuesNode vn -> { - // Check it's an object - sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ").append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/values' });\n"); + // Type guard + sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ") + .append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append("});\n"); sb.append(indent).append("} else {\n"); - // Iterate over values - final JtdNode valSchema = vn.schema(); - if (isSimpleType(valSchema)) { - // Inline simple value validation with loop - sb.append(indent).append(" for (const key of Object.keys(").append(valueExpr).append(")) {\n"); - final String valPath = pathExpr + " + '/' + key"; - final String valExpr = valueExpr + "[key]"; - generateNodeValidation(sb, valSchema, ctx, valExpr, valPath, indent + " ", discriminatorKey); - sb.append(indent).append(" }\n"); + // Loop over values + sb.append(indent).append(" for (const k in ").append(valueExpr).append(") {\n"); + final String valValue = valueExpr + "[k]"; + final String valPath = pathExpr + " + \"/\" + k"; + final String valSchemaPath = schemaPathExpr + " + \"/values\""; + + if (isLeafNode(vn.schema())) { + generateNodeValidation(sb, vn.schema(), ctx, valValue, valPath, valSchemaPath, indent + " ", null); } else { - // Push values onto stack for deferred validation - final String validatorKey = findInlineValidator(ctx, valSchema); - sb.append(indent).append(" const keys = Object.keys(").append(valueExpr).append(");\n"); - sb.append(indent).append(" for (let i = keys.length - 1; i >= 0; i--) {\n"); - sb.append(indent).append(" const key = keys[i];\n"); - sb.append(indent).append(" stack.push({\n"); - sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); - sb.append(indent).append(" value: ").append(valueExpr).append("[key],\n"); - sb.append(indent).append(" path: ").append(pathExpr).append(" + '/' + key\n"); - sb.append(indent).append(" });\n"); - sb.append(indent).append(" }\n"); + final String fnName = getInlineFunctionName(vn.schema(), ctx); + sb.append(indent).append(" ").append(fnName).append("(") + .append(valValue).append(", errors, ").append(valPath) + .append(", ").append(valSchemaPath).append(");\n"); } + sb.append(indent).append(" }\n"); sb.append(indent).append("}\n"); } case DiscriminatorNode dn -> { - // Check it's an object and has discriminator - sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ").append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '' });\n"); - sb.append(indent).append("} else if (!('").append(dn.discriminator()).append("' in ").append(valueExpr).append(")) {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(", schemaPath: '/discriminator' });\n"); + // 5-step validation per RFC 8927 + sb.append(indent).append("if (").append(valueExpr).append(" === null || typeof ") + .append(valueExpr).append(" !== \"object\" || Array.isArray(").append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append("});\n"); + sb.append(indent).append("} else if (!(\"").append(dn.discriminator()).append("\" in ") + .append(valueExpr).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append("});\n"); + sb.append(indent).append("} else if (typeof ").append(valueExpr).append("[\"").append(dn.discriminator()) + .append("\"] !== \"string\") {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(" + \"/").append(dn.discriminator()).append("\", schemaPath: ").append(schemaPathExpr) + .append(" + \"/discriminator\"});\n"); sb.append(indent).append("} else {\n"); - sb.append(indent).append(" const tag = ").append(valueExpr).append("['").append(dn.discriminator()).append("'];\n"); + sb.append(indent).append(" const tag = ").append(valueExpr).append("[\"").append(dn.discriminator()).append("\"];\n"); - // Switch on tag value + // Switch on tag boolean first = true; for (var entry : dn.mapping().entrySet()) { final String tagValue = entry.getKey(); - final JtdNode subNode = entry.getValue(); + final JtdNode variantSchema = entry.getValue(); if (first) { - sb.append(indent).append(" if (tag === '").append(tagValue).append("') {\n"); + sb.append(indent).append(" if (tag === ").append(jsString(tagValue)).append(") {\n"); first = false; } else { - sb.append(indent).append(" } else if (tag === '").append(tagValue).append("') {\n"); + sb.append(indent).append(" } else if (tag === ").append(jsString(tagValue)).append(") {\n"); } - if (isSimpleType(subNode)) { - generateNodeValidation(sb, subNode, ctx, valueExpr, pathExpr, indent + " ", dn.discriminator()); - } else { - final String validatorKey = findInlineValidator(ctx, subNode); - sb.append(indent).append(" stack.push({\n"); - sb.append(indent).append(" fn: validate_").append(validatorKey).append(",\n"); - sb.append(indent).append(" value: ").append(valueExpr).append(",\n"); - sb.append(indent).append(" path: ").append(pathExpr).append("\n"); - sb.append(indent).append(" });\n"); - } + // Generate variant validation with discriminator exemption + generateNodeValidation(sb, variantSchema, ctx, valueExpr, pathExpr, + schemaPathExpr + " + \"/mapping/" + jsonPointerEscape(tagValue) + "\"", + indent + " ", dn.discriminator()); } sb.append(indent).append(" } else {\n"); - sb.append(indent).append(" errors.push({ instancePath: ").append(pathExpr).append(" + '/").append(dn.discriminator()).append("', schemaPath: '/discriminator' });\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(" + \"/").append(dn.discriminator()).append("\", schemaPath: ").append(schemaPathExpr) + .append(" + \"/mapping\"});\n"); sb.append(indent).append(" }\n"); sb.append(indent).append("}\n"); } case RefNode rn -> { - sb.append(indent).append("validate_").append(toSafeName(rn.ref())) - .append("(").append(valueExpr).append(", errors, ").append(pathExpr).append(", stack);\n"); - } - - case NullableNode nn -> { - sb.append(indent).append("if (").append(valueExpr).append(" !== null) {\n"); - generateNodeValidation(sb, nn.wrapped(), ctx, valueExpr, pathExpr, indent + " ", discriminatorKey); - sb.append(indent).append("}\n"); + sb.append(indent).append("validate_").append(toSafeName(rn.ref())).append("(") + .append(valueExpr).append(", errors, ").append(pathExpr).append(", ").append(schemaPathExpr).append(");\n"); } } } - private static String generateTypeCheck(String type, String valueExpr) { - return switch (type) { - case "string" -> "typeof " + valueExpr + " === \"string\""; + private static void generateTypeCheck(StringBuilder sb, String type, String valueExpr, + String pathExpr, String schemaPathExpr, String indent) { + + final String check = switch (type) { case "boolean" -> "typeof " + valueExpr + " === \"boolean\""; - case "timestamp" -> "isTimestamp(" + valueExpr + ")"; - case "int8" -> "isIntInRange(" + valueExpr + ", -128, 127)"; - case "uint8" -> "isIntInRange(" + valueExpr + ", 0, 255)"; - case "int16" -> "isIntInRange(" + valueExpr + ", -32768, 32767)"; - case "uint16" -> "isIntInRange(" + valueExpr + ", 0, 65535)"; - case "int32" -> "isIntInRange(" + valueExpr + ", -2147483648, 2147483647)"; - case "uint32" -> "isIntInRange(" + valueExpr + ", 0, 4294967295)"; - case "float32", "float64" -> "isFloat(" + valueExpr + ")"; + case "string" -> "typeof " + valueExpr + " === \"string\""; + case "timestamp" -> "typeof " + valueExpr + " === \"string\" && /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:(\\d{2}|60)(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$/.test(" + valueExpr + ")"; + case "float32", "float64" -> "typeof " + valueExpr + " === \"number\" && Number.isFinite(" + valueExpr + ")"; + case "int8" -> "typeof " + valueExpr + " === \"number\" && Number.isInteger(" + valueExpr + ") && " + valueExpr + " >= -128 && " + valueExpr + " <= 127"; + case "uint8" -> "typeof " + valueExpr + " === \"number\" && Number.isInteger(" + valueExpr + ") && " + valueExpr + " >= 0 && " + valueExpr + " <= 255"; + case "int16" -> "typeof " + valueExpr + " === \"number\" && Number.isInteger(" + valueExpr + ") && " + valueExpr + " >= -32768 && " + valueExpr + " <= 32767"; + case "uint16" -> "typeof " + valueExpr + " === \"number\" && Number.isInteger(" + valueExpr + ") && " + valueExpr + " >= 0 && " + valueExpr + " <= 65535"; + case "int32" -> "typeof " + valueExpr + " === \"number\" && Number.isInteger(" + valueExpr + ") && " + valueExpr + " >= -2147483648 && " + valueExpr + " <= 2147483647"; + case "uint32" -> "typeof " + valueExpr + " === \"number\" && Number.isInteger(" + valueExpr + ") && " + valueExpr + " >= 0 && " + valueExpr + " <= 4294967295"; default -> throw new IllegalArgumentException("Unknown type: " + type); }; + + sb.append(indent).append("if (!(").append(check).append(")) {\n"); + sb.append(indent).append(" errors.push({instancePath: ").append(pathExpr) + .append(", schemaPath: ").append(schemaPathExpr).append(" + \"/type\"});\n"); + sb.append(indent).append("}\n"); + } + + private static boolean isLeafNode(JtdNode node) { + return node instanceof TypeNode || node instanceof EnumNode || node instanceof EmptyNode || node instanceof RefNode; } - private static String findInlineValidator(RenderContext ctx, JtdNode node) { - for (var entry : ctx.inlineValidators.entrySet()) { - if (entry.getValue().equals(node)) { + private static String getInlineFunctionName(JtdNode node, RenderContext ctx) { + // Check if this node already has a function name assigned + for (var entry : ctx.generatedInlineFunctions.entrySet()) { + if (entry.getValue() == node) { return entry.getKey(); } } - // If not found, it's a simple type - shouldn't happen - throw new IllegalStateException("No inline validator found for: " + node.getClass().getSimpleName()); + // Create new unique function name using counter (not hashCode - avoids collisions) + final String name = "validate_inline_" + (ctx.inlineCounter++); + ctx.generatedInlineFunctions.put(name, node); + return name; + } + + private static void generateInlineFunctions(StringBuilder sb, RenderContext ctx) { + // Keep generating until no new inline functions are added + // (inline functions can reference other inline functions) + var processed = new HashSet(); + boolean changed; + do { + changed = false; + var entries = new ArrayList<>(ctx.generatedInlineFunctions.entrySet()); + for (var entry : entries) { + final String fnName = entry.getKey(); + if (processed.contains(fnName)) { + continue; + } + processed.add(fnName); + changed = true; + + final JtdNode node = entry.getValue(); + sb.append("function ").append(fnName).append("(v, errors, p, sp) {\n"); + generateNodeValidation(sb, node, ctx, "v", "p", "sp", " ", null); + sb.append("}\n\n"); + } + } while (changed); + } + + private static String getDiscriminatorKey(PropertiesNode pn) { + // We need to track which discriminator this properties node belongs to + // For now, this is a placeholder - we'd need to track this during generation + return null; + } + + private static String buildKeyCheck(String varName, Set allowedKeys) { + if (allowedKeys.isEmpty()) { + return "true"; // No keys allowed, everything is extra + } + + final StringBuilder sb = new StringBuilder(); + boolean first = true; + for (String key : allowedKeys) { + if (!first) sb.append(" && "); + sb.append(varName).append(" !== \"").append(key).append("\""); + first = false; + } + return sb.toString(); } private static String findEnumConst(Map> enumConsts, List values) { @@ -515,7 +436,7 @@ private static String toSafeName(String name) { return name.replaceAll("[^a-zA-Z0-9_]", "_"); } - private static String pointerEscape(String s) { + private static String jsonPointerEscape(String s) { return s.replace("~", "~0").replace("/", "~1"); } @@ -524,7 +445,7 @@ private static String jsString(String s) { } private static String jsStringArray(List values) { - final var sb = new StringBuilder(values.size() * 16); + final var sb = new StringBuilder(); sb.append("["); for (int i = 0; i < values.size(); i++) { if (i > 0) sb.append(", "); @@ -534,17 +455,13 @@ private static String jsStringArray(List values) { return sb.toString(); } - /// Context for tracking what's needed during rendering private static class RenderContext { String sha256Hex; String shaPrefix8; String schemaId; - boolean needsTimestampCheck = false; - boolean needsIntRangeCheck = false; - boolean needsFloatCheck = false; + int enumCounter = 1; + int inlineCounter = 0; final Map> enumConstants = new LinkedHashMap<>(); - final Map inlineValidators = new LinkedHashMap<>(); - final Map discriminatorMappings = new LinkedHashMap<>(); - int inlineValidatorCounter = 0; + final Map generatedInlineFunctions = new LinkedHashMap<>(); } } diff --git a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java index b87e45d..487c83e 100644 --- a/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java +++ b/jtd-esm-codegen/src/main/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCli.java @@ -28,7 +28,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + outJs); } - static Path run(Path schemaPath, Path outDir) throws IOException { + public static Path run(Path schemaPath, Path outDir) throws IOException { LOG.fine(() -> "Reading schema from: " + schemaPath); final String schemaJson = Files.readString(schemaPath, StandardCharsets.UTF_8); diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/GraalJsRunner.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/GraalJsRunner.java new file mode 100644 index 0000000..1e80e1a --- /dev/null +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/GraalJsRunner.java @@ -0,0 +1,61 @@ +package io.github.simbo1905.json.jtd.codegen; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.io.IOAccess; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +/// Executes generated ES2020 validators in-process using GraalVM Polyglot JS. +/// No external runtime required - the JS engine runs inside the JVM. +final class GraalJsRunner { + private static final Logger LOG = Logger.getLogger(GraalJsRunner.class.getName()); + + private GraalJsRunner() {} + + /// Evaluates a generated validator module and returns its exports. + /// The module must export a `validate(instance)` function. + static Value loadValidatorModule(Context context, Path modulePath) throws IOException { + LOG.fine(() -> "Loading validator module: " + modulePath); + final var source = Source.newBuilder("js", modulePath.toFile()) + .mimeType("application/javascript+module") + .build(); + return context.eval(source); + } + + /// Creates a GraalVM Polyglot context configured for ES2020 module evaluation. + static Context createContext() { + return Context.newBuilder("js") + .allowIO(IOAccess.ALL) + .option("js.esm-eval-returns-exports", "true") + .option("js.ecmascript-version", "2020") + .build(); + } + + /// Validates a JSON value against a generated validator by calling its + /// `validate` export. Returns a list of error maps (instancePath, schemaPath). + static List> validate(Value exports, Object jsonValue) { + final var validateFn = exports.getMember("validate"); + assert validateFn != null && validateFn.canExecute() : "Module must export a validate function"; + final var result = validateFn.execute(jsonValue); + return convertErrors(result); + } + + @SuppressWarnings("unchecked") + private static List> convertErrors(Value result) { + final var size = (int) result.getArraySize(); + final var errors = new java.util.ArrayList>(size); + for (int i = 0; i < size; i++) { + final var errorVal = result.getArrayElement(i); + final var instancePath = errorVal.getMember("instancePath").asString(); + final var schemaPath = errorVal.getMember("schemaPath").asString(); + errors.add(Map.of("instancePath", instancePath, "schemaPath", schemaPath)); + } + return errors; + } +} diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmJsTestSuite.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmJsTestSuite.java new file mode 100644 index 0000000..0b9fab3 --- /dev/null +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmJsTestSuite.java @@ -0,0 +1,23 @@ +package io.github.simbo1905.json.jtd.codegen; + +import org.bitbucket.thinbus.junitjs.JSRunner; +import org.bitbucket.thinbus.junitjs.Tests; +import org.junit.runner.RunWith; + +/// JUnit test suite that runs JavaScript tests via GraalVM polyglot. +/// Uses junit-js JSRunner to execute .js test files from `src/test/resources/`. +/// Each JS file uses the `tests({...})` pattern from JUnitJSUtils.js. +/// +/// This replaces the previous bun-based JS test execution that required +/// an external JavaScript runtime not available in the CI image. +/// +/// Discovered by Surefire via the JUnit Vintage engine (JUnit 4 runner +/// under JUnit Platform). The class name ends in "Test" so that Surefire's +/// default includes pattern picks it up. +@Tests({ + "boolean-schema.test.js", + "nested-elements-empty-focused.test.js" +}) +@RunWith(JSRunner.class) +public class JtdEsmJsTestSuite { +} diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java index 66dd124..a609123 100644 --- a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdEsmPropertyTest.java @@ -2,6 +2,10 @@ import jdk.sandbox.java.util.json.*; import net.jqwik.api.*; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.io.IOAccess; import org.junit.jupiter.api.Assertions; import java.io.IOException; @@ -13,16 +17,17 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assumptions.assumeTrue; /// Property-based testing for JTD to ESM code generator. /// Generates comprehensive schema/document permutations to validate generated JavaScript validators. +/// +/// Uses GraalVM Polyglot JS for in-process JavaScript execution - no external runtime needed. class JtdEsmPropertyTest extends JtdEsmCodegenLoggingConfig { - private static final Logger LOG = Logger.getLogger(JtdEsmPropertyTest.class.getName()); + static final Logger LOG = Logger.getLogger(JtdEsmPropertyTest.class.getName()); private static final List PROPERTY_NAMES = List.of("alpha", "beta", "gamma", "delta", "epsilon"); private static final List> PROPERTY_PAIRS = List.of( - List.of("alpha", "beta"), List.of("alpha", "gamma"), + List.of("alpha", "beta"), List.of("alpha", "gamma"), List.of("beta", "delta"), List.of("gamma", "epsilon") ); private static final List DISCRIMINATOR_VALUES = List.of("type1", "type2", "type3"); @@ -30,7 +35,7 @@ class JtdEsmPropertyTest extends JtdEsmCodegenLoggingConfig { private static final Random RANDOM = new Random(); /// Sealed interface for JTD test schemas - sealed interface JtdTestSchema permits EmptySchema, RefSchema, TypeSchema, EnumSchema, + sealed interface JtdTestSchema permits EmptySchema, RefSchema, TypeSchema, EnumSchema, ElementsSchema, PropertiesSchema, ValuesSchema, DiscriminatorSchema, NullableSchema {} record EmptySchema() implements JtdTestSchema {} @@ -38,7 +43,7 @@ record RefSchema(String ref) implements JtdTestSchema {} record TypeSchema(String type) implements JtdTestSchema {} record EnumSchema(List values) implements JtdTestSchema {} record ElementsSchema(JtdTestSchema elements) implements JtdTestSchema {} - record PropertiesSchema(Map properties, + record PropertiesSchema(Map properties, Map optionalProperties, boolean additionalProperties) implements JtdTestSchema {} record ValuesSchema(JtdTestSchema values) implements JtdTestSchema {} @@ -98,7 +103,7 @@ private static Arbitrary propertiesSchemaArbitrary(int depth) { final var empty = Arbitraries.of(new PropertiesSchema(Map.of(), Map.of(), false)); final var singleRequired = Combinators.combine( - Arbitraries.of(PROPERTY_NAMES), + Arbitraries.of(PROPERTY_NAMES), jtdSchemaArbitrary(childDepth) ).as((name, schema) -> { Assertions.assertNotNull(name); @@ -181,8 +186,8 @@ private static Arbitrary propertiesSchemaForDiscriminatorMapping( Combinators.combine(Arbitraries.of(effectivePropertyNames), primitiveSchemas) .as((name, schema) -> new PropertiesSchema(Map.of(), Map.of(name, schema), false)), Combinators.combine(Arbitraries.of(safePropertyPairs), primitiveSchemas, primitiveSchemas) - .as((names, reqSchema, optSchema) -> - new PropertiesSchema(Map.of(names.getFirst(), reqSchema), + .as((names, reqSchema, optSchema) -> + new PropertiesSchema(Map.of(names.getFirst(), reqSchema), Map.of(names.getLast(), optSchema), false)) ); } @@ -191,59 +196,11 @@ private static Arbitrary nullableSchemaArbitrary(int depth) { return jtdSchemaArbitrary(depth - 1).map(NullableSchema::new); } - /// Converts test schema to JtdAst.RootNode - private static JtdAst.RootNode testSchemaToRootNode(JtdTestSchema schema) { - final var definitions = new LinkedHashMap(); - final var rootNode = convertToJtdNode(schema, definitions); - return new JtdAst.RootNode("property-test", definitions, rootNode); - } - - private static JtdAst.JtdNode convertToJtdNode(JtdTestSchema schema, - Map definitions) { - return switch (schema) { - case EmptySchema ignored -> new JtdAst.EmptyNode(); - case RefSchema(var ref) -> new JtdAst.RefNode(ref); - case TypeSchema(var type) -> new JtdAst.TypeNode(type); - case EnumSchema(var values) -> new JtdAst.EnumNode(values); - case ElementsSchema(var elem) -> - new JtdAst.ElementsNode(convertToJtdNode(elem, definitions)); - case PropertiesSchema(var props, var optProps, var add) -> - new JtdAst.PropertiesNode( - props.entrySet().stream().collect(Collectors.toMap( - Map.Entry::getKey, - e -> convertToJtdNode(e.getValue(), definitions), - (a, b) -> a, - LinkedHashMap::new - )), - optProps.entrySet().stream().collect(Collectors.toMap( - Map.Entry::getKey, - e -> convertToJtdNode(e.getValue(), definitions), - (a, b) -> a, - LinkedHashMap::new - )), - add - ); - case ValuesSchema(var val) -> - new JtdAst.ValuesNode(convertToJtdNode(val, definitions)); - case DiscriminatorSchema(var disc, var mapping) -> - new JtdAst.DiscriminatorNode(disc, - mapping.entrySet().stream().collect(Collectors.toMap( - Map.Entry::getKey, - e -> convertToJtdNode(e.getValue(), definitions), - (a, b) -> a, - LinkedHashMap::new - )) - ); - case NullableSchema(var inner) -> - new JtdAst.NullableNode(convertToJtdNode(inner, definitions)); - }; - } - /// Builds compliant JSON document for a schema @SuppressWarnings({"unchecked", "rawtypes"}) - private static Object buildCompliantDocument(JtdTestSchema schema) { + static Object buildCompliantDocument(JtdTestSchema schema) { return switch (schema) { - case EmptySchema ignored -> generateAnyValue(); + case EmptySchema ignored -> "anything-goes"; case RefSchema ignored -> "ref-compliant-value"; case TypeSchema(var type) -> buildCompliantTypeValue(type); case EnumSchema(var values) -> values.getFirst(); @@ -289,31 +246,6 @@ case DiscriminatorSchema(var disc, var mapping) -> { }; } - private static Object generateAnyValue() { - return switch (RANDOM.nextInt(7)) { - case 0 -> null; - case 1 -> RANDOM.nextBoolean(); - case 2 -> RANDOM.nextInt(100); - case 3 -> RANDOM.nextDouble(); - case 4 -> "random-string-" + RANDOM.nextInt(1000); - case 5 -> { - final var v1 = generateAnyValue(); - final var v2 = generateAnyValue(); - final var lst = new ArrayList<>(); - if (v1 != null) lst.add(v1); - if (v2 != null) lst.add(v2); - yield lst; - } - case 6 -> { - final var v = generateAnyValue(); - final var map = new LinkedHashMap(); - if (v != null) map.put("key" + RANDOM.nextInt(10), v); - yield map; - } - default -> "fallback"; - }; - } - private static Object buildCompliantTypeValue(String type) { return switch (type) { case "boolean" -> true; @@ -332,7 +264,7 @@ private static Object buildCompliantTypeValue(String type) { /// Creates failing documents for a schema @SuppressWarnings({"unchecked", "rawtypes"}) - private static List createFailingDocuments(JtdTestSchema schema, Object compliant) { + static List createFailingDocuments(JtdTestSchema schema, Object compliant) { return switch (schema) { case EmptySchema ignored -> List.of(); case RefSchema ignored -> Collections.singletonList(null); @@ -347,36 +279,38 @@ case ElementsSchema(var elem) -> { innerLst.add(invalidElem.getFirst()); final var failures = new ArrayList<>(); failures.add(innerLst); - failures.add(null); + failures.add("not-an-array"); yield failures; } } - yield Collections.singletonList(null); + yield List.of("not-an-array"); } case PropertiesSchema(var props, var optProps, var add) -> { if (props.isEmpty() && optProps.isEmpty()) { yield List.of(); } final var failures = new ArrayList(); - if (!props.isEmpty()) { + if (!props.isEmpty() && compliant instanceof Map) { final var firstKey = props.keySet().iterator().next(); failures.add(removeKey((Map) compliant, firstKey)); } - if (!add) { + if (!add && compliant instanceof Map) { final var extended = new LinkedHashMap<>((Map) compliant); extended.put("extraProperty", "extra-value"); failures.add(extended); } - failures.add(null); + failures.add("not-an-object"); yield failures; } - case ValuesSchema ignored -> Arrays.asList(null, "not-an-object"); + case ValuesSchema ignored -> List.of("not-an-object"); case DiscriminatorSchema(var disc, var ignored) -> { final var failures = new ArrayList(); - final var modified = new LinkedHashMap<>((Map) compliant); - modified.put(disc, "invalid-discriminator"); - failures.add(modified); - failures.add(null); + if (compliant instanceof Map) { + final var modified = new LinkedHashMap<>((Map) compliant); + modified.put(disc, "invalid-discriminator"); + failures.add(modified); + } + failures.add("not-an-object"); yield failures; } case NullableSchema ignored -> List.of(); @@ -405,7 +339,7 @@ private static Map removeKey(Map original, Strin } /// Describes schema for logging - private static String describeSchema(JtdTestSchema schema) { + static String describeSchema(JtdTestSchema schema) { return switch (schema) { case EmptySchema ignored -> "empty"; case RefSchema(var ref) -> "ref:" + ref; @@ -421,15 +355,123 @@ case PropertiesSchema(var props, var optProps, var add) -> { } case ValuesSchema(var val) -> "values[" + describeSchema(val) + "]"; case DiscriminatorSchema(var disc, var mapping) -> - "discriminator[" + disc + "→{" + String.join(",", mapping.keySet()) + "}]"; + "discriminator[" + disc + "={" + String.join(",", mapping.keySet()) + "}]"; case NullableSchema(var inner) -> "nullable[" + describeSchema(inner) + "]"; }; } + /// Converts test schema to JSON for the codegen parser + static JsonObject jtdSchemaToJsonObject(JtdTestSchema schema) { + return switch (schema) { + case EmptySchema ignored -> JsonObject.of(Map.of()); + case RefSchema(var ref) -> { + final Map map = Map.of("ref", JsonString.of(ref)); + yield JsonObject.of(map); + } + case TypeSchema(var type) -> { + final Map map = Map.of("type", JsonString.of(type)); + yield JsonObject.of(map); + } + case EnumSchema(var values) -> { + final Map map = Map.of("enum", JsonArray.of(values.stream().map(JsonString::of).toList())); + yield JsonObject.of(map); + } + case ElementsSchema(var elem) -> { + final Map map = Map.of("elements", jtdSchemaToJsonObject(elem)); + yield JsonObject.of(map); + } + case PropertiesSchema(var props, var optProps, var add) -> { + final var schemaMap = new LinkedHashMap(); + if (!props.isEmpty()) { + final Map propsMap = props.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, e -> jtdSchemaToJsonObject(e.getValue()), + (a, b) -> a, LinkedHashMap::new)); + schemaMap.put("properties", JsonObject.of(propsMap)); + } + if (!optProps.isEmpty()) { + final Map optMap = optProps.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, e -> jtdSchemaToJsonObject(e.getValue()), + (a, b) -> a, LinkedHashMap::new)); + schemaMap.put("optionalProperties", JsonObject.of(optMap)); + } + if (add) { + schemaMap.put("additionalProperties", JsonBoolean.of(true)); + } + yield JsonObject.of(schemaMap); + } + case ValuesSchema(var val) -> { + final Map map = Map.of("values", jtdSchemaToJsonObject(val)); + yield JsonObject.of(map); + } + case DiscriminatorSchema(var disc, var mapping) -> { + final var schemaMap = new LinkedHashMap(); + schemaMap.put("discriminator", JsonString.of(disc)); + final Map mappingMap = mapping.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, e -> jtdSchemaToJsonObject(e.getValue()), + (a, b) -> a, LinkedHashMap::new)); + schemaMap.put("mapping", JsonObject.of(mappingMap)); + yield JsonObject.of(schemaMap); + } + case NullableSchema(var inner) -> { + final var innerSchema = jtdSchemaToJsonObject(inner); + final var nullableMap = new LinkedHashMap(); + nullableMap.putAll(innerSchema.members()); + nullableMap.put("nullable", JsonBoolean.of(true)); + yield JsonObject.of(nullableMap); + } + }; + } + + /// Converts a Java value to a GraalVM polyglot-compatible value. + @SuppressWarnings("unchecked") + private static Object toGraalValue(Context context, Object value) { + if (value == null) return null; + if (value instanceof Boolean || value instanceof String) return value; + if (value instanceof Number num) return num.doubleValue(); + if (value instanceof List lst) { + final var jsArray = context.eval("js", "[]"); + for (int i = 0; i < lst.size(); i++) { + jsArray.setArrayElement(i, toGraalValue(context, lst.get(i))); + } + return jsArray; + } + if (value instanceof Map rawMap) { + final var jsObj = context.eval("js", "({})"); + final var typedMap = (Map) rawMap; + for (var entry : typedMap.entrySet()) { + jsObj.putMember(entry.getKey(), toGraalValue(context, entry.getValue())); + } + return jsObj; + } + return value; + } + + /// Runs validation via GraalVM polyglot: loads the generated ESM module, + /// calls `validate(instance)`, returns the number of errors. + private static int runValidation(Path modulePath, Object document, String schemaDescription, String testName) throws IOException { + final var jsContent = Files.readString(modulePath, StandardCharsets.UTF_8); + LOG.finest(() -> String.format("%s - Generated JS for schema '%s':%n%s", testName, schemaDescription, jsContent)); + LOG.finest(() -> String.format("%s - Document: %s", testName, document)); + + try (var context = Context.newBuilder("js") + .allowIO(IOAccess.ALL) + .option("js.esm-eval-returns-exports", "true") + .option("js.ecmascript-version", "2020") + .build()) { + final var source = Source.newBuilder("js", modulePath.toFile()) + .mimeType("application/javascript+module") + .build(); + final var exports = context.eval(source); + final var validateFn = exports.getMember("validate"); + final var graalDoc = toGraalValue(context, document); + final var result = validateFn.execute(graalDoc); + return (int) result.getArraySize(); + } + } + @Property(generation = GenerationMode.AUTO) @SuppressWarnings({"unchecked", "rawtypes"}) void generatedValidatorPassesCompliantDocuments(@ForAll("jtdSchemas") JtdTestSchema schema) throws Exception { - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); LOG.finer(() -> "Executing generatedValidatorPassesCompliantDocuments"); final var schemaDescription = describeSchema(schema); @@ -441,50 +483,41 @@ void generatedValidatorPassesCompliantDocuments(@ForAll("jtdSchemas") JtdTestSch return; } - // Generate and compile schema - final var rootNode = testSchemaToRootNode(schema); final var tempDir = Files.createTempDirectory("jtd-esm-prop-test-"); - - // Write schema JSON + + // Write schema JSON and generate validator final var schemaJson = jtdSchemaToJsonObject(schema); final var schemaFile = tempDir.resolve("schema.json"); Files.writeString(schemaFile, Json.toDisplayString(schemaJson, 0), StandardCharsets.UTF_8); - - // Generate JS validator final var outJs = JtdToEsmCli.run(schemaFile, tempDir); - // Test compliant document + // Build compliant document final var compliantDoc = buildCompliantDocument(schema); - // Skip null compliant documents (NullableSchema can return null) if (compliantDoc == null) { LOG.fine(() -> "Skipping null compliant document for schema: " + schemaDescription); cleanup(tempDir); return; } - final var runnerCode = buildTestRunner(outJs, List.of( - Map.of("name", "compliant", "data", compliantDoc, "expectEmpty", true) - )); - - final var runnerFile = tempDir.resolve("runner.mjs"); - Files.writeString(runnerFile, runnerCode, StandardCharsets.UTF_8); - - final var result = runBunTest(tempDir, runnerFile); - LOG.fine(() -> "Test result - exitCode: " + result.exitCode() + ", output: " + result.output()); - if (result.exitCode() != 0) { - LOG.severe(() -> String.format("Test failed for schema: %s%nCompliant document: %s%nExit code: %d%nOutput: %s", - schemaDescription, compliantDoc, result.exitCode(), result.output())); + + // Validate via GraalVM polyglot + final int errorCount = runValidation(outJs, compliantDoc, schemaDescription, "generatedValidatorPassesCompliantDocuments"); + + if (errorCount != 0) { + LOG.severe(() -> String.format( + "Compliant document FAILED for schema: %s%nDocument: %s%nErrors: %d%nGenerated JS: %s", + schemaDescription, compliantDoc, errorCount, outJs)); } - assertThat(result.exitCode()).as("Compliant document should pass validation for schema: %s", schemaDescription).isEqualTo(0); - assertThat(result.output()).as("Test output for schema: %s", schemaDescription).contains("passed", "0 failed"); - // Cleanup + assertThat(errorCount).as( + "Compliant document should pass validation for schema: %s with doc: %s", + schemaDescription, compliantDoc).isZero(); + cleanup(tempDir); } @Property(generation = GenerationMode.AUTO) @SuppressWarnings({"unchecked", "rawtypes"}) void generatedValidatorRejectsFailingDocuments(@ForAll("jtdSchemas") JtdTestSchema schema) throws Exception { - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); LOG.finer(() -> "Executing generatedValidatorRejectsFailingDocuments"); final var schemaDescription = describeSchema(schema); @@ -498,20 +531,15 @@ void generatedValidatorRejectsFailingDocuments(@ForAll("jtdSchemas") JtdTestSche // Skip schemas that accept everything if (schema instanceof EmptySchema || schema instanceof NullableSchema) { - LOG.fine(() -> "Skipping schema that accepts everything: " + schemaDescription); return; } - // Generate and compile schema - final var rootNode = testSchemaToRootNode(schema); final var tempDir = Files.createTempDirectory("jtd-esm-prop-test-"); - - // Write schema JSON + + // Write schema JSON and generate validator final var schemaJson = jtdSchemaToJsonObject(schema); final var schemaFile = tempDir.resolve("schema.json"); Files.writeString(schemaFile, Json.toDisplayString(schemaJson, 0), StandardCharsets.UTF_8); - - // Generate JS validator final var outJs = JtdToEsmCli.run(schemaFile, tempDir); // Create failing documents @@ -519,177 +547,30 @@ void generatedValidatorRejectsFailingDocuments(@ForAll("jtdSchemas") JtdTestSche final var failingDocs = createFailingDocuments(schema, compliantDoc); if (failingDocs.isEmpty()) { - LOG.fine(() -> "No failing documents for schema: " + schemaDescription); cleanup(tempDir); return; } - // Build test cases (filter out null failing docs) - final var testCases = new ArrayList>(); + // Validate each failing document for (int i = 0; i < failingDocs.size(); i++) { final var failingDoc = failingDocs.get(i); - if (failingDoc != null) { - testCases.add(Map.of( - "name", "failing-" + i, - "data", failingDoc, - "expectEmpty", false - )); - } - } - - final var runnerCode = buildTestRunner(outJs, testCases); - final var runnerFile = tempDir.resolve("runner.mjs"); - Files.writeString(runnerFile, runnerCode, StandardCharsets.UTF_8); - - final var result = runBunTest(tempDir, runnerFile); - assertThat(result.exitCode()).as("Failing documents should be rejected for schema: %s", schemaDescription).isEqualTo(0); - assertThat(result.output()).as("Test output for failing documents with schema: %s", schemaDescription).contains("passed", "0 failed"); + if (failingDoc == null) continue; - // Cleanup - cleanup(tempDir); - } + final int errorCount = runValidation(outJs, failingDoc, schemaDescription, "generatedValidatorRejectsFailingDocuments"); + final int docIndex = i; - private static JsonObject jtdSchemaToJsonObject(JtdTestSchema schema) { - return switch (schema) { - case EmptySchema ignored -> JsonObject.of(Map.of()); - case RefSchema(var ref) -> { - final Map map = Map.of("ref", JsonString.of(ref)); - yield JsonObject.of(map); + if (errorCount == 0) { + LOG.severe(() -> String.format( + "Failing document #%d PASSED (should have failed) for schema: %s%nDocument: %s%nGenerated JS: %s", + docIndex, schemaDescription, failingDoc, outJs)); } - case TypeSchema(var type) -> { - final Map map = Map.of("type", JsonString.of(type)); - yield JsonObject.of(map); - } - case EnumSchema(var values) -> { - final Map map = Map.of("enum", JsonArray.of(values.stream().map(JsonString::of).toList())); - yield JsonObject.of(map); - } - case ElementsSchema(var elem) -> { - final Map map = Map.of("elements", jtdSchemaToJsonObject(elem)); - yield JsonObject.of(map); - } - case PropertiesSchema(var props, var optProps, var add) -> { - final var schemaMap = new LinkedHashMap(); - if (!props.isEmpty()) { - final Map propsMap = props.entrySet().stream().collect(Collectors.toMap( - Map.Entry::getKey, - e -> jtdSchemaToJsonObject(e.getValue()), - (a, b) -> a, - LinkedHashMap::new - )); - schemaMap.put("properties", JsonObject.of(propsMap)); - } - if (!optProps.isEmpty()) { - final Map optMap = optProps.entrySet().stream().collect(Collectors.toMap( - Map.Entry::getKey, - e -> jtdSchemaToJsonObject(e.getValue()), - (a, b) -> a, - LinkedHashMap::new - )); - schemaMap.put("optionalProperties", JsonObject.of(optMap)); - } - if (add) { - schemaMap.put("additionalProperties", JsonBoolean.of(true)); - } - yield JsonObject.of(schemaMap); - } - case ValuesSchema(var val) -> { - final Map map = Map.of("values", jtdSchemaToJsonObject(val)); - yield JsonObject.of(map); - } - case DiscriminatorSchema(var disc, var mapping) -> { - final var schemaMap = new LinkedHashMap(); - schemaMap.put("discriminator", JsonString.of(disc)); - final Map mappingMap = mapping.entrySet().stream().collect(Collectors.toMap( - Map.Entry::getKey, - e -> jtdSchemaToJsonObject(e.getValue()), - (a, b) -> a, - LinkedHashMap::new - )); - schemaMap.put("mapping", JsonObject.of(mappingMap)); - yield JsonObject.of(schemaMap); - } - case NullableSchema(var inner) -> { - final var innerSchema = jtdSchemaToJsonObject(inner); - final var nullableMap = new LinkedHashMap(); - nullableMap.putAll(innerSchema.members()); - nullableMap.put("nullable", JsonBoolean.of(true)); - yield JsonObject.of(nullableMap); - } - }; - } - - private static String buildTestRunner(Path validatorPath, List> testCases) { - final var sb = new StringBuilder(); - sb.append("import { validate } from '").append(validatorPath.toUri()).append("';\n\n"); - sb.append("const testCases = [\n"); - - for (var tc : testCases) { - sb.append(" {\n"); - sb.append(" name: '").append(tc.get("name")).append("',\n"); - sb.append(" data: ").append(jsonValueToJs(tc.get("data"))).append(",\n"); - sb.append(" expectEmpty: ").append(tc.get("expectEmpty")).append("\n"); - sb.append(" },\n"); - } - - sb.append("];\n\n"); - sb.append("let passed = 0;\n"); - sb.append("let failed = 0;\n\n"); - sb.append("for (const tc of testCases) {\n"); - sb.append(" const errors = validate(tc.data);\n"); - sb.append(" const isEmpty = errors.length === 0;\n"); - sb.append(" if (isEmpty === tc.expectEmpty) {\n"); - sb.append(" passed++;\n"); - sb.append(" } else {\n"); - sb.append(" console.log(`✗ ${tc.name} - expected ${tc.expectEmpty ? 'no errors' : 'errors'}, got ${JSON.stringify(errors)}`);\n"); - sb.append(" failed++;\n"); - sb.append(" }\n"); - sb.append("}\n\n"); - sb.append("console.log(`${passed} passed, ${failed} failed`);\n"); - sb.append("process.exit(failed > 0 ? 1 : 0);\n"); - - return sb.toString(); - } - - private static String jsonValueToJs(Object value) { - if (value == null) return "null"; - if (value instanceof Boolean) return value.toString(); - if (value instanceof Number) return value.toString(); - if (value instanceof String) return "\"" + value + "\""; - if (value instanceof List) { - final var lst = (List) value; - return "[" + lst.stream().map(JtdEsmPropertyTest::jsonValueToJs).collect(Collectors.joining(", ")) + "]"; - } - if (value instanceof Map) { - final var map = (Map) value; - return "{" + map.entrySet().stream() - .map(e -> jsonValueToJs(e.getKey()) + ": " + jsonValueToJs(e.getValue())) - .collect(Collectors.joining(", ")) + "}"; - } - return "null"; - } - private static boolean isBunAvailable() { - try { - final var p = new ProcessBuilder("bun", "--version") - .redirectErrorStream(true) - .start(); - return p.waitFor() == 0; - } catch (Exception e) { - return false; + assertThat(errorCount).as( + "Failing document #%d should be rejected for schema: %s with doc: %s", + docIndex, schemaDescription, failingDoc).isGreaterThan(0); } - } - private static TestResult runBunTest(Path workingDir, Path runnerFile) throws Exception { - final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) - .directory(workingDir.toFile()) - .redirectErrorStream(true) - .start(); - - final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - final var exitCode = p.waitFor(); - - return new TestResult(exitCode, output); + cleanup(tempDir); } private static void cleanup(Path tempDir) throws IOException { @@ -703,6 +584,4 @@ private static void cleanup(Path tempDir) throws IOException { } }); } - - private record TestResult(int exitCode, String output) {} } diff --git a/jtd-esm-codegen/src/test/js/boolean-schema.test.js b/jtd-esm-codegen/src/test/js/boolean-schema.test.js deleted file mode 100644 index b922daa..0000000 --- a/jtd-esm-codegen/src/test/js/boolean-schema.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/// Example test for simple boolean schema -/// Tests that the generated validator correctly validates boolean values - -import { test, assertEq, assertArrayEq, runTests } from './test-runner.js'; -import { validate } from '../resources/expected/boolean-schema.js'; - -test('validate returns empty array for true', () => { - const errors = validate(true); - assertArrayEq(errors, [], 'Should have no errors for boolean true'); -}); - -test('validate returns empty array for false', () => { - const errors = validate(false); - assertArrayEq(errors, [], 'Should have no errors for boolean false'); -}); - -test('validate returns error for string', () => { - const errors = validate('hello'); - assertEq(errors.length, 1, 'Should have one error'); - assertEq(errors[0].instancePath, '', 'instancePath should be empty'); - assertEq(errors[0].schemaPath, '/type', 'schemaPath should be /type'); -}); - -test('validate returns error for number', () => { - const errors = validate(42); - assertEq(errors.length, 1, 'Should have one error'); -}); - -test('validate returns error for null', () => { - const errors = validate(null); - assertEq(errors.length, 1, 'Should have one error'); -}); - -test('validate returns error for object', () => { - const errors = validate({}); - assertEq(errors.length, 1, 'Should have one error'); -}); - -test('validate returns error for array', () => { - const errors = validate([]); - assertEq(errors.length, 1, 'Should have one error'); -}); - -runTests(); diff --git a/jtd-esm-codegen/src/test/js/test-runner.js b/jtd-esm-codegen/src/test/js/test-runner.js deleted file mode 100644 index de96ea2..0000000 --- a/jtd-esm-codegen/src/test/js/test-runner.js +++ /dev/null @@ -1,129 +0,0 @@ -/// Vanilla JS test runner for bun - zero dependencies, ESM2020 -/// Usage: bun run test-runner.js -/// Or programmatically from Java tests - -const FAIL = '\x1b[31m✗\x1b[0m'; -const PASS = '\x1b[32m✓\x1b[0m'; - -let totalTests = 0; -let passedTests = 0; -let failedTests = 0; -const failures = []; - -/// Assert that two values are strictly equal -function assertEq(actual, expected, message) { - if (actual !== expected) { - throw new Error(`${message || 'Assertion failed'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); - } -} - -/// Assert that value is truthy -function assertTrue(value, message) { - if (!value) { - throw new Error(message || 'Expected truthy value'); - } -} - -/// Assert that value is falsy -function assertFalse(value, message) { - if (value) { - throw new Error(message || 'Expected falsy value'); - } -} - -/// Assert that two arrays are deeply equal -function assertArrayEq(actual, expected, message) { - if (!Array.isArray(actual) || !Array.isArray(expected)) { - throw new Error(`${message || 'Not arrays'}: expected array, got ${typeof actual}`); - } - if (actual.length !== expected.length) { - throw new Error(`${message || 'Array length mismatch'}: expected ${expected.length}, got ${actual.length}`); - } - for (let i = 0; i < actual.length; i++) { - if (JSON.stringify(actual[i]) !== JSON.stringify(expected[i])) { - throw new Error(`${message || 'Array element mismatch'} at index ${i}: expected ${JSON.stringify(expected[i])}, got ${JSON.stringify(actual[i])}`); - } - } -} - -/// Assert that two objects are deeply equal (shallow comparison) -function assertObjEq(actual, expected, message) { - const actualKeys = Object.keys(actual).sort(); - const expectedKeys = Object.keys(expected).sort(); - if (actualKeys.length !== expectedKeys.length) { - throw new Error(`${message || 'Object key count mismatch'}: expected ${expectedKeys.length} keys, got ${actualKeys.length}`); - } - for (const key of actualKeys) { - if (JSON.stringify(actual[key]) !== JSON.stringify(expected[key])) { - throw new Error(`${message || 'Object value mismatch'} at key "${key}": expected ${JSON.stringify(expected[key])}, got ${JSON.stringify(actual[key])}`); - } - } -} - -/// Run a test function -function test(name, fn) { - totalTests++; - try { - fn(); - passedTests++; - console.log(`${PASS} ${name}`); - return true; - } catch (e) { - failedTests++; - failures.push({ name, error: e.message }); - console.log(`${FAIL} ${name}`); - console.log(` ${e.message}`); - return false; - } -} - -/// Run all tests and print summary -function runTests() { - console.log(`\n${'='.repeat(50)}`); - console.log(`Total: ${totalTests}, Passed: ${passedTests}, Failed: ${failedTests}`); - - if (failedTests > 0) { - console.log(`\nFailures:`); - for (const f of failures) { - console.log(` - ${f.name}: ${f.error}`); - } - process.exit(1); - } else { - console.log('\nAll tests passed!'); - process.exit(0); - } -} - -/// Load and run a test module -async function runTestModule(modulePath) { - try { - const module = await import(modulePath); - - // If module exports a run() function, call it - if (typeof module.run === 'function') { - await module.run(); - } - - // Run any tests that were defined - runTests(); - } catch (e) { - console.error(`Failed to load test module ${modulePath}: ${e.message}`); - process.exit(1); - } -} - -/// Main entry point when run directly -if (import.meta.main) { - const args = process.argv.slice(2); - if (args.length < 1) { - console.error('Usage: bun run test-runner.js '); - process.exit(1); - } - - // Resolve path relative to current working directory - const path = await import('path'); - const modulePath = path.resolve(args[0]); - await runTestModule('file://' + modulePath); -} - -export { test, assertEq, assertTrue, assertFalse, assertArrayEq, assertObjEq, runTests, runTestModule }; diff --git a/jtd-esm-codegen/src/test/resources/boolean-schema.test.js b/jtd-esm-codegen/src/test/resources/boolean-schema.test.js new file mode 100644 index 0000000..daf14e9 --- /dev/null +++ b/jtd-esm-codegen/src/test/resources/boolean-schema.test.js @@ -0,0 +1,53 @@ +/// boolean-schema.test.js - JUnit JS test for the boolean type validator +/// Runs via junit-js JSRunner (GraalVM polyglot, no bun/node required) +/// +/// Tests that the generated boolean validator correctly accepts booleans +/// and rejects all other JSON value types. + +// Load the expected fixture, stripping ESM export keywords for plain eval +var Files = Java.type('java.nio.file.Files'); +var Paths = Java.type('java.nio.file.Paths'); +var fixtureContent = Files.readString( + Paths.get('src/test/resources/expected/boolean-schema.js') +); +// Strip 'export ' prefix so the function is declared in global scope +eval(fixtureContent.replace(/^export /gm, '')); + +tests({ + validateReturnEmptyArrayForTrue: function() { + var errors = validate(true); + assert.assertEquals(0, errors.length); + }, + + validateReturnEmptyArrayForFalse: function() { + var errors = validate(false); + assert.assertEquals(0, errors.length); + }, + + validateReturnErrorForString: function() { + var errors = validate('hello'); + assert.assertEquals(1, errors.length); + assert.assertEquals('', errors[0].instancePath); + assert.assertEquals('/type', errors[0].schemaPath); + }, + + validateReturnErrorForNumber: function() { + var errors = validate(42); + assert.assertEquals(1, errors.length); + }, + + validateReturnErrorForNull: function() { + var errors = validate(null); + assert.assertEquals(1, errors.length); + }, + + validateReturnErrorForObject: function() { + var errors = validate({}); + assert.assertEquals(1, errors.length); + }, + + validateReturnErrorForArray: function() { + var errors = validate([]); + assert.assertEquals(1, errors.length); + } +}); diff --git a/jtd-esm-codegen/src/test/resources/nested-elements-empty-focused.test.js b/jtd-esm-codegen/src/test/resources/nested-elements-empty-focused.test.js new file mode 100644 index 0000000..b163aa0 --- /dev/null +++ b/jtd-esm-codegen/src/test/resources/nested-elements-empty-focused.test.js @@ -0,0 +1,66 @@ +/// Focused test for nested elements with empty schema +/// Schema: elements[elements[empty]] +/// This verifies the fix for "validate_inline_X is not defined" error + +var Files = Java.type('java.nio.file.Files'); +var Paths = Java.type('java.nio.file.Paths'); +var StandardCharsets = Java.type('java.nio.charset.StandardCharsets'); +var JtdToEsmCli = Java.type('io.github.simbo1905.json.jtd.codegen.JtdToEsmCli'); + +function generateValidator(schemaJson) { + var tempDir = Files.createTempDirectory('jtd-esm-test-'); + var schemaFile = tempDir.resolve('schema.json'); + Files.writeString(schemaFile, schemaJson, StandardCharsets.UTF_8); + var outJs = JtdToEsmCli.run(schemaFile, tempDir); + var jsContent = Files.readString(outJs, StandardCharsets.UTF_8); + + // Cleanup + try { + Files.walk(tempDir).sorted(function(a, b) { return -1; }).forEach(function(p) { + try { Files.deleteIfExists(p); } catch (e) {} + }); + } catch (e) {} + + return jsContent; +} + +tests({ + nestedElementsWithEmptySchemaGeneratesInlineValidators: function() { + var schemaJson = '{"elements":{"elements":{}}}'; + var jsContent = generateValidator(schemaJson); + + // Should reference validate_inline_0 for inner elements + var hasInlineRef = jsContent.indexOf('validate_inline_0') !== -1; + // Should define validate_inline_0 function + var hasInlineDef = jsContent.indexOf('function validate_inline_0') !== -1; + + assert.assertTrue('Generated JS should reference inline validator', hasInlineRef); + assert.assertTrue('Generated JS should define inline validator', hasInlineDef); + }, + + tripleNestedElementsGeneratesMultipleInlineValidators: function() { + var schemaJson = '{"elements":{"elements":{"elements":{}}}}'; + var jsContent = generateValidator(schemaJson); + + // Should have validate_inline_0 and validate_inline_1 + var hasInline0 = jsContent.indexOf('function validate_inline_0') !== -1; + var hasInline1 = jsContent.indexOf('function validate_inline_1') !== -1; + + assert.assertTrue('Should generate validate_inline_0', hasInline0); + assert.assertTrue('Should generate validate_inline_1', hasInline1); + }, + + generatedJavaScriptIsValid: function() { + var schemaJson = '{"elements":{"elements":{}}}'; + var jsContent = generateValidator(schemaJson); + + // Strip export and check syntax + try { + var testJs = jsContent.replace(/^export /gm, ''); + eval(testJs); + assert.assertTrue(true); + } catch (e) { + assert.fail('Generated JS has syntax error: ' + e.message); + } + } +}); From 52f92e1413753ce32ea1477f4a6097881dbb6ce6 Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:44:23 +0000 Subject: [PATCH 8/9] Update CI test count to reflect new junit-js tests (639 total, 5 skipped) --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eefb80d..810ee81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,8 @@ jobs: for k in totals: totals[k]+=int(r.get(k,'0')) except Exception: pass - exp_tests=611 - exp_skipped=0 + exp_tests=639 + exp_skipped=5 if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped: print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}") sys.exit(1) From d0587886484be2648a44124277e722a644437108 Mon Sep 17 00:00:00 2001 From: Simon Massey <322608+simbo1905@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:17:38 +0000 Subject: [PATCH 9/9] Refactor JtdToEsmCodegenTest to use GraalVM Polyglot instead of bun - Remove all bun-specific code (ProcessBuilder runners, isBunAvailable checks) - Replace with GraalVM Polyglot JS in-process execution via jsContext()/evalModule() - Add GraalVM helper methods: jsContext(), evalModule(), errCount() - Simplify test schemas (inline them instead of separate variables) - Add generatedDiscriminatorValidatorWorks test - Add JTD_CODEGEN_SPEC.md documentation - All 17 tests pass, no external JS runtime required --- jtd-esm-codegen/JTD_CODEGEN_SPEC.md | 705 ++++++++++++++++++ .../json/jtd/codegen/JtdToEsmCodegenTest.java | 456 +++++------ 2 files changed, 874 insertions(+), 287 deletions(-) create mode 100644 jtd-esm-codegen/JTD_CODEGEN_SPEC.md diff --git a/jtd-esm-codegen/JTD_CODEGEN_SPEC.md b/jtd-esm-codegen/JTD_CODEGEN_SPEC.md new file mode 100644 index 0000000..caf3fc9 --- /dev/null +++ b/jtd-esm-codegen/JTD_CODEGEN_SPEC.md @@ -0,0 +1,705 @@ +# JTD Code Generation Specification + +A language-independent specification for compiling RFC 8927 JSON Type Definition +schemas into target-language source code that validates JSON documents. The +generated code contains exactly the checks the schema requires -- no +interpreter, no AST, no runtime stack, no dead code. + +## 1. Terminology + +| Term | Meaning | +|---|---| +| **schema** | A JSON object conforming to RFC 8927. | +| **instance** | The JSON value being validated at runtime. | +| **form** | One of the 8 mutually-exclusive schema shapes defined in RFC 8927 plus the nullable modifier. | +| **AST node** | An immutable, tagged value representing one compiled schema form. Used during generation, discarded after. | +| **error** | A pair of JSON Pointers: `(instancePath, schemaPath)`. | +| **definitions** | A flat string-keyed map of named AST nodes, resolved at compile time. Each becomes a generated function. | + +## 2. Overview + +A JTD code generator operates in two phases: + +1. **Parse**: Read the JTD schema JSON and compile it into an intermediate + AST of immutable nodes (Section 3). +2. **Emit**: Walk the AST and emit target-language source code. Each AST + node maps to a specific code pattern. The AST is discarded after + emission (Section 5). + +The generated code is a standalone validation function. When executed against +a JSON instance, it produces the same `(instancePath, schemaPath)` error +pairs that RFC 8927 Section 3.3 specifies. + +## 3. Intermediate AST + +The AST is used only during generation. It is not present in the output. + +### 3.1 Node Types + +``` +Node = + | Empty -- {} + | Ref { name: String } -- {"ref": "..."} + | Type { type: TypeKeyword } -- {"type": "..."} + | Enum { values: List } -- {"enum": [...]} + | Elements { schema: Node } -- {"elements": ...} + | Properties { required: Map, -- {"properties": ...} + optional: Map, -- {"optionalProperties": ...} + additional: Boolean } -- {"additionalProperties": ...} + | Values { schema: Node } -- {"values": ...} + | Discrim { tag: String, mapping: Map} -- {"discriminator":...,"mapping":...} + | Nullable { inner: Node } -- any form + "nullable": true +``` + +`TypeKeyword` is one of the 12 strings defined in RFC 8927 Section 2.2.3: + +``` +TypeKeyword = boolean | string | timestamp + | int8 | uint8 | int16 | uint16 | int32 | uint32 + | float32 | float64 +``` + +### 3.2 Compilation Algorithm + +``` +compile(json, isRoot=true, definitions) -> Node: + + REQUIRE json is a JSON object + + IF isRoot: + IF json has key "definitions": + REQUIRE json["definitions"] is a JSON object + -- Pass 1: register all keys as placeholders for forward refs + FOR EACH key in json["definitions"]: + definitions[key] = PLACEHOLDER + -- Pass 2: compile each definition + FOR EACH key in json["definitions"]: + definitions[key] = compile(json["definitions"][key], isRoot=false, definitions) + ELSE: + REQUIRE json does NOT have key "definitions" + + -- Detect form + forms = [] + IF json has "ref": forms += "ref" + IF json has "type": forms += "type" + IF json has "enum": forms += "enum" + IF json has "elements": forms += "elements" + IF json has "values": forms += "values" + IF json has "discriminator": forms += "discriminator" + IF json has "properties" OR json has "optionalProperties": + forms += "properties" + + REQUIRE |forms| <= 1 + + -- Compile form + node = MATCH forms: + [] -> Empty + ["ref"] -> compileRef(json, definitions) + ["type"] -> compileType(json) + ["enum"] -> compileEnum(json) + ["elements"] -> compileElements(json, definitions) + ["properties"] -> compileProperties(json, definitions) + ["values"] -> compileValues(json, definitions) + ["discriminator"]-> compileDiscriminator(json, definitions) + + -- Nullable modifier wraps any form + IF json has "nullable" AND json["nullable"] == true: + node = Nullable { inner: node } + + RETURN node +``` + +### 3.3 Form-Specific Compilation + +**Ref**: +``` +compileRef(json, definitions): + name = json["ref"] -- must be a string + REQUIRE name IN definitions -- forward refs are valid (placeholder exists) + RETURN Ref { name } +``` + +**Type**: +``` +compileType(json): + t = json["type"] -- must be a string + REQUIRE t IN TypeKeyword + RETURN Type { type: t } +``` + +**Enum**: +``` +compileEnum(json): + values = json["enum"] -- must be a non-empty array of strings + REQUIRE no duplicates in values + RETURN Enum { values } +``` + +**Elements**: +``` +compileElements(json, definitions): + inner = compile(json["elements"], isRoot=false, definitions) + RETURN Elements { schema: inner } +``` + +**Properties**: +``` +compileProperties(json, definitions): + req = {} + opt = {} + IF json has "properties": + FOR EACH (key, schema) in json["properties"]: + req[key] = compile(schema, isRoot=false, definitions) + IF json has "optionalProperties": + FOR EACH (key, schema) in json["optionalProperties"]: + opt[key] = compile(schema, isRoot=false, definitions) + REQUIRE keys(req) INTERSECT keys(opt) == {} + additional = json.get("additionalProperties", false) + RETURN Properties { required: req, optional: opt, additional } +``` + +**Values**: +``` +compileValues(json, definitions): + inner = compile(json["values"], isRoot=false, definitions) + RETURN Values { schema: inner } +``` + +**Discriminator**: +``` +compileDiscriminator(json, definitions): + tag = json["discriminator"] -- must be a string + REQUIRE json has "mapping" + mapping = {} + FOR EACH (key, schema) in json["mapping"]: + node = compile(schema, isRoot=false, definitions) + REQUIRE node is Properties -- not Nullable, not any other form + REQUIRE tag NOT IN node.required + REQUIRE tag NOT IN node.optional + mapping[key] = node + RETURN Discrim { tag, mapping } +``` + +### 3.4 Compile-Time Invariants + +After compilation, the following are guaranteed: +- Every `Ref.name` resolves to an entry in `definitions`. +- Every `Discrim.mapping` value is a `Properties` node (not nullable). +- No `Properties` node has overlapping required/optional keys. +- The AST is immutable. No node is modified after construction. + +## 4. Type Checking Reference + +Exact semantics for each `TypeKeyword`. The code generator emits exactly +this check, inlined, for each type keyword it encounters. + +### 4.1 boolean + +``` +value is a JSON boolean (true or false) +``` + +Target-language expression examples: +- JavaScript: `typeof v === "boolean"` +- Java: `v instanceof JsonBoolean` +- Python: `isinstance(v, bool)` + +### 4.2 string + +``` +value is a JSON string +``` + +Target-language expression examples: +- JavaScript: `typeof v === "string"` +- Java: `v instanceof JsonString` +- Python: `isinstance(v, str)` + +### 4.3 timestamp + +``` +value is a JSON string +AND value matches the RFC 3339 date-time production + (regex: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:(\d{2}|60)(\.\d+)?(Z|[+-]\d{2}:\d{2})$) +AND the date-time is parseable (accounting for leap seconds by + normalizing :60 to :59 before parsing) +``` + +Target-language expression examples: +- JavaScript: `typeof v === "string" && !Number.isNaN(Date.parse(v))` (simplified; + a full implementation needs the regex for leap-second support) +- Java: regex match + `OffsetDateTime.parse(normalized)` + +### 4.4 float32, float64 + +``` +value is a JSON number (any finite number; no range check) +``` + +RFC 8927 does not distinguish float32 from float64 at the validation level. +Both accept any JSON number. + +Target-language expression examples: +- JavaScript: `typeof v === "number" && Number.isFinite(v)` +- Java: `v instanceof JsonNumber` + +### 4.5 Integer types + +All integer types share the same two-step check: + +``` +value is a JSON number +AND value has zero fractional part (floor(value) == value) +AND value is within the type's range (inclusive) +``` + +| Type | Min | Max | +|---|---|---| +| int8 | -128 | 127 | +| uint8 | 0 | 255 | +| int16 | -32768 | 32767 | +| uint16 | 0 | 65535 | +| int32 | -2147483648 | 2147483647 | +| uint32 | 0 | 4294967295 | + +Note: `3.0` is a valid int8. `3.5` is not. This is value-based, not +syntax-based. + +Target-language expression examples: +- JavaScript (uint8): `typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 255` +- Java (uint8): `v instanceof JsonNumber n && n.toDouble() == Math.floor(n.toDouble()) && n.toLong() >= 0 && n.toLong() <= 255` + +## 5. Emission Rules + +The code generator walks the AST and emits target-language source code. +Each AST node maps to a specific code pattern. The central rule: + +**Emit only what the schema requires. If the schema does not mention a +form, the generated code does not contain any logic for that form.** + +### 5.1 Generated Code Structure + +The generator emits: + +1. **One function per definition** -- named `validate_`, taking + `(instance, errors, instancePath)` as parameters. Only emitted if the + schema has definitions. + +2. **One exported `validate(instance)` function** -- the entry point. Creates + the error list, calls the root validation logic, returns the error list. + +3. **No helpers, no libraries, no imports.** Every check is inlined. If the + schema uses only `"type": "string"`, the generated code contains one + `typeof` check and nothing else. + +### 5.2 Node-to-Code Mapping + +#### Empty + +Emit nothing. No check. No code. + +If an Empty node is a required property value, the generated code checks +that the key exists but does not validate the value: + +```javascript +// Schema: {"properties": {"data": {}}} +if (!("data" in obj)) e.push({instancePath: p, schemaPath: sp + "/properties/data"}); +// No else branch -- empty schema accepts any value +``` + +#### Nullable + +Emit a null guard before the inner check: + +```javascript +// Schema: {"type": "string", "nullable": true} +if (v !== null) { + if (typeof v !== "string") e.push({instancePath: p, schemaPath: sp + "/type"}); +} +``` + +If the inner node is Empty, the nullable wraps nothing -- emit only the +null guard (which passes everything, so emit nothing at all). + +#### Type + +Emit the type-specific check inlined. No helper function. + +```javascript +// "type": "string" +if (typeof v !== "string") e.push({instancePath: p, schemaPath: sp + "/type"}); + +// "type": "uint8" +if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 255) + e.push({instancePath: p, schemaPath: sp + "/type"}); + +// "type": "boolean" +if (typeof v !== "boolean") e.push({instancePath: p, schemaPath: sp + "/type"}); + +// "type": "float64" +if (typeof v !== "number" || !Number.isFinite(v)) + e.push({instancePath: p, schemaPath: sp + "/type"}); +``` + +#### Enum + +Emit a set-membership check. For small enums, inline the array. For large +enums, a code generator MAY hoist the array to module scope as a constant. + +```javascript +// "enum": ["a", "b", "c"] +if (typeof v !== "string" || !["a","b","c"].includes(v)) + e.push({instancePath: p, schemaPath: sp + "/enum"}); +``` + +Note: the string type guard is required because RFC 8927 specifies that +non-string values fail enum validation. + +#### Elements + +Emit an array type guard, then a loop. The loop body is the generated +check for the element schema. + +```javascript +// "elements": {"type": "string"} +if (!Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp}); +} else { + for (let i = 0; i < v.length; i++) { + if (typeof v[i] !== "string") + e.push({instancePath: p + "/" + i, schemaPath: sp + "/elements/type"}); + } +} +``` + +If the element schema is a complex type (Properties, Discrim), emit a +function call in the loop body instead of inlining. + +For nested arrays (arrays of arrays), a code generator MAY inline nested +loops up to a configurable depth (e.g. 3 levels) for performance, falling +back to function calls beyond that depth. + +#### Properties + +Emit an object type guard, then: +1. One presence check per required key. +2. Inlined value checks for each required and optional property. +3. A key-rejection loop if `additional == false`. + +```javascript +// Schema: {"properties":{"name":{"type":"string"}}, "optionalProperties":{"age":{"type":"uint8"}}} +if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp}); +} else { + // Required properties + if (!("name" in v)) e.push({instancePath: p, schemaPath: sp + "/properties/name"}); + else if (typeof v["name"] !== "string") + e.push({instancePath: p + "/name", schemaPath: sp + "/properties/name/type"}); + + // Optional properties + if ("age" in v) { + const a = v["age"]; + if (typeof a !== "number" || !Number.isInteger(a) || a < 0 || a > 255) + e.push({instancePath: p + "/age", schemaPath: sp + "/optionalProperties/age/type"}); + } + + // Additional properties (only emitted when additional == false) + for (const k in v) { + if (k !== "name" && k !== "age") + e.push({instancePath: p + "/" + k, schemaPath: sp}); + } +} +``` + +If `additional` is `true`, the for-in loop is **not emitted at all**. + +If a property value's schema is a complex type (Properties, Elements, etc.), +emit a function call instead of inlining. If it is a leaf (Type, Enum, +Empty), inline it. + +#### Values + +Emit an object type guard, then a for-in loop. The loop body is the +generated check for the value schema. + +```javascript +// "values": {"type": "string"} +if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp}); +} else { + for (const k in v) { + if (typeof v[k] !== "string") + e.push({instancePath: p + "/" + k, schemaPath: sp + "/values/type"}); + } +} +``` + +#### Discriminator + +Emit a 5-step sequential check, then a switch/if-else dispatching to the +variant validator. + +```javascript +// "discriminator": "type", "mapping": {"a": {...}, "b": {...}} +if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp}); +} else if (!("type" in v)) { + e.push({instancePath: p, schemaPath: sp}); +} else if (typeof v["type"] !== "string") { + e.push({instancePath: p + "/type", schemaPath: sp + "/discriminator"}); +} else if (v["type"] === "a") { + validate_variant_a(v, e, p, sp + "/mapping/a"); +} else if (v["type"] === "b") { + validate_variant_b(v, e, p, sp + "/mapping/b"); +} else { + e.push({instancePath: p + "/type", schemaPath: sp + "/mapping"}); +} +``` + +Each variant validator is a generated Properties check. The discriminator +tag field is excluded from additional-properties checking and from +property validation in the variant (it was already validated by the +discriminator check). + +#### Ref + +Emit a function call to the generated definition validator: + +```javascript +// "ref": "address" +validate_address(v, e, p, sp); +``` + +Each definition becomes a generated function. The function body is the +emitted code for the definition's AST node. + +### 5.3 Inlining Policy + +A code generator SHOULD inline checks for leaf nodes (Type, Enum, Empty) +directly into their parent's generated code. + +A code generator SHOULD emit separate functions for: +- Each definition (called via Ref). +- Each Properties or Discrim node that appears as the child of Elements, + Values, or other container nodes. +- Each discriminator variant. + +A code generator MUST NOT emit helper functions, type-checking utilities, +or library imports that are not required by the specific schema being +compiled. + +### 5.4 Recursive Schemas + +Recursive refs (a definition that ultimately references itself) are legal +in RFC 8927. In generated code, this becomes recursive function calls: + +```javascript +// Schema: {"definitions":{"node":{"properties":{"next":{"ref":"node","nullable":true}}}}, +// "ref":"node"} +function validate_node(v, e, p, sp) { + if (v === null || typeof v !== "object" || Array.isArray(v)) { + e.push({instancePath: p, schemaPath: sp}); + return; + } + if (!("next" in v)) { + e.push({instancePath: p, schemaPath: sp + "/properties/next"}); + } else if (v["next"] !== null) { + validate_node(v["next"], e, p + "/next", sp + "/properties/next"); + } +} + +export function validate(instance) { + const e = []; + validate_node(instance, e, "", ""); + return e; +} +``` + +The target-language call stack provides the implicit work stack. For most +real-world schemas, recursion depth is bounded by the document's structure. + +### 5.5 Discriminator Tag Exemption + +When emitting a variant Properties check inside a discriminator, the +code generator MUST: +- Exclude the tag field from additional-properties rejection. +- Not emit a value check for the tag field (it was already validated + as a string by the discriminator check). + +This means the generated known-key set in the for-in loop includes the +tag field name, and no property check is emitted for it. + +## 6. Error Format + +Errors follow RFC 8927 Section 3.3, which defines error indicators as +pairs of JSON Pointers: + +``` +Error = { + instancePath: String, -- JSON Pointer (RFC 6901) into the instance + schemaPath: String -- JSON Pointer (RFC 6901) into the schema +} +``` + +The `instancePath` points to the value that failed. The `schemaPath` points +to the schema keyword that caused the failure. + +### 6.1 Schema Path Construction + +The schema path is built at generation time and baked into the generated +code as string literals. Each emission rule appends to the schema path: + +| Form | Appended path component(s) | +|---|---| +| Type | `/type` | +| Enum | `/enum` | +| Elements (type guard) | (nothing -- error at current path) | +| Elements (child) | `/elements` | +| Properties (missing key) | `/properties/` | +| Properties (additional) | (nothing -- error at current path) | +| Properties (child req) | `/properties/` | +| Properties (child opt) | `/optionalProperties/` | +| Values (type guard) | (nothing -- error at current path) | +| Values (child) | `/values` | +| Discrim (not object) | (nothing -- error at current path) | +| Discrim (tag missing) | (nothing -- error at current path) | +| Discrim (tag not string) | `/discriminator` | +| Discrim (tag not in map) | `/mapping` | +| Discrim (variant) | `/mapping/` | + +Schema paths are string literals in the generated code. They do not change +at runtime. + +### 6.2 Instance Path Construction + +Instance paths are built at runtime via string concatenation: + +| Descent into | Appended to instancePath | +|---|---| +| Array element at index `i` | `"/" + i` | +| Object property with key `k` | `"/" + k` | +| Discriminator tag value | `"/" + tagFieldName` | +| Discriminator variant | (nothing -- same object) | +| Ref target | (nothing -- transparent) | + +## 7. Conformance + +Generated code conforms to this spec if: + +1. For any valid RFC 8927 schema and any JSON instance, the generated + `validate(instance)` function returns the same set of + `(instancePath, schemaPath)` error pairs that RFC 8927 Section 3.3 + specifies. + +2. The generated code passes the official JTD validation test suite + (`validation.json` from `json-typedef-spec`) when used as the + validation engine. + +3. The code generator rejects invalid schemas at generation time per the + constraints in Section 3.4. + +4. The generated code contains no dead code: no helper functions, loops, + branches, or checks that the schema does not require. + +5. Validation does not short-circuit. All errors are collected in a + single pass. + +## 8. Worked Example + +Schema: +```json +{ + "properties": { + "name": { "type": "string" }, + "age": { "type": "uint8" }, + "tags": { "elements": { "type": "string" } } + }, + "optionalProperties": { + "email": { "type": "string" } + } +} +``` + +### Compiled AST (intermediate, discarded after emission) + +``` +Properties { + required: { + "name" -> Type { type: "string" }, + "age" -> Type { type: "uint8" }, + "tags" -> Elements { schema: Type { type: "string" } } + }, + optional: { + "email" -> Type { type: "string" } + }, + additional: false +} +``` + +### Generated Code (JavaScript ES2020) + +```javascript +export function validate(instance) { + const e = []; + if (instance === null || typeof instance !== "object" || Array.isArray(instance)) { + e.push({instancePath: "", schemaPath: ""}); + return e; + } + + if (!("name" in instance)) e.push({instancePath: "", schemaPath: "/properties/name"}); + else if (typeof instance["name"] !== "string") + e.push({instancePath: "/name", schemaPath: "/properties/name/type"}); + + if (!("age" in instance)) e.push({instancePath: "", schemaPath: "/properties/age"}); + else { + const v = instance["age"]; + if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 255) + e.push({instancePath: "/age", schemaPath: "/properties/age/type"}); + } + + if (!("tags" in instance)) e.push({instancePath: "", schemaPath: "/properties/tags"}); + else if (!Array.isArray(instance["tags"])) + e.push({instancePath: "/tags", schemaPath: "/properties/tags"}); + else { + const arr = instance["tags"]; + for (let i = 0; i < arr.length; i++) { + if (typeof arr[i] !== "string") + e.push({instancePath: "/tags/" + i, schemaPath: "/properties/tags/elements/type"}); + } + } + + if ("email" in instance && typeof instance["email"] !== "string") + e.push({instancePath: "/email", schemaPath: "/optionalProperties/email/type"}); + + for (const k in instance) { + if (k !== "name" && k !== "age" && k !== "tags" && k !== "email") + e.push({instancePath: "/" + k, schemaPath: ""}); + } + + return e; +} +``` + +No helper functions. No dead code. Every line corresponds to a specific +constraint in the schema. + +### Validation of example instance + +Instance: +```json +{ "name": "Alice", "age": 300, "tags": ["a", 42], "extra": true } +``` + +Errors produced: +```json +[ + { "instancePath": "/age", "schemaPath": "/properties/age/type" }, + { "instancePath": "/tags/1", "schemaPath": "/properties/tags/elements/type" }, + { "instancePath": "/extra", "schemaPath": "" } +] +``` + +- `age`: 300 is a number with zero fractional part, but 300 > 255 (uint8 max). +- `tags/1`: 42 is not a string. +- `extra`: not in required or optional properties, and `additionalProperties` + defaults to `false`. diff --git a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java index 611930d..7b30134 100644 --- a/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java +++ b/jtd-esm-codegen/src/test/java/io/github/simbo1905/json/jtd/codegen/JtdToEsmCodegenTest.java @@ -1,34 +1,36 @@ package io.github.simbo1905.json.jtd.codegen; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.io.IOAccess; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import java.util.logging.Logger; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assumptions.assumeTrue; -/// Tests for the new stack-based JTD to ESM code generator. -/// Uses bun to execute generated JavaScript validators. +/// Tests for the stack-based JTD to ESM code generator. +/// Uses GraalVM Polyglot JS for in-process JavaScript execution - no external runtime needed. final class JtdToEsmCodegenTest extends JtdEsmCodegenLoggingConfig { private static final Logger LOG = Logger.getLogger(JtdToEsmCodegenTest.class.getName()); + // --- Parser tests (pure Java, no JS execution) --- + @Test void parsesSimpleBooleanTypeSchema() { LOG.info(() -> "Running parsesSimpleBooleanTypeSchema"); - - final var schema = """ + final var root = JtdParser.parseString(""" {"type": "boolean"} - """; - - final var root = JtdParser.parseString(schema); + """); assertThat(root.id()).isEqualTo("JtdSchema"); assertThat(root.rootSchema()).isInstanceOf(JtdAst.TypeNode.class); - final var typeNode = (JtdAst.TypeNode) root.rootSchema(); assertThat(typeNode.type()).isEqualTo("boolean"); } @@ -36,29 +38,19 @@ void parsesSimpleBooleanTypeSchema() { @Test void parsesSchemaWithMetadataId() { LOG.info(() -> "Running parsesSchemaWithMetadataId"); - - final var schema = """ - { - "type": "string", - "metadata": {"id": "my-schema-v1"} - } - """; - - final var root = JtdParser.parseString(schema); + final var root = JtdParser.parseString(""" + {"type": "string", "metadata": {"id": "my-schema-v1"}} + """); assertThat(root.id()).isEqualTo("my-schema-v1"); } @Test void parsesEnumSchema() { LOG.info(() -> "Running parsesEnumSchema"); - - final var schema = """ + final var root = JtdParser.parseString(""" {"enum": ["active", "inactive", "pending"]} - """; - - final var root = JtdParser.parseString(schema); + """); assertThat(root.rootSchema()).isInstanceOf(JtdAst.EnumNode.class); - final var enumNode = (JtdAst.EnumNode) root.rootSchema(); assertThat(enumNode.values()).containsExactly("active", "inactive", "pending"); } @@ -66,17 +58,10 @@ void parsesEnumSchema() { @Test void parsesElementsArraySchema() { LOG.info(() -> "Running parsesElementsArraySchema"); - - final var schema = """ - { - "elements": {"type": "string"}, - "metadata": {"id": "string-array"} - } - """; - - final var root = JtdParser.parseString(schema); + final var root = JtdParser.parseString(""" + {"elements": {"type": "string"}, "metadata": {"id": "string-array"}} + """); assertThat(root.rootSchema()).isInstanceOf(JtdAst.ElementsNode.class); - final var elementsNode = (JtdAst.ElementsNode) root.rootSchema(); assertThat(elementsNode.schema()).isInstanceOf(JtdAst.TypeNode.class); } @@ -84,19 +69,10 @@ void parsesElementsArraySchema() { @Test void parsesNestedElementsSchema() { LOG.info(() -> "Running parsesNestedElementsSchema"); - - final var schema = """ - { - "elements": { - "elements": {"type": "int32"} - }, - "metadata": {"id": "matrix"} - } - """; - - final var root = JtdParser.parseString(schema); + final var root = JtdParser.parseString(""" + {"elements": {"elements": {"type": "int32"}}, "metadata": {"id": "matrix"}} + """); assertThat(root.rootSchema()).isInstanceOf(JtdAst.ElementsNode.class); - final var outer = (JtdAst.ElementsNode) root.rootSchema(); assertThat(outer.schema()).isInstanceOf(JtdAst.ElementsNode.class); } @@ -104,46 +80,26 @@ void parsesNestedElementsSchema() { @Test void parsesValuesMapSchema() { LOG.info(() -> "Running parsesValuesMapSchema"); - - final var schema = """ - { - "values": {"type": "string"}, - "metadata": {"id": "string-map"} - } - """; - - final var root = JtdParser.parseString(schema); + final var root = JtdParser.parseString(""" + {"values": {"type": "string"}, "metadata": {"id": "string-map"}} + """); assertThat(root.rootSchema()).isInstanceOf(JtdAst.ValuesNode.class); } @Test void parsesDiscriminatorUnionSchema() { LOG.info(() -> "Running parsesDiscriminatorUnionSchema"); - - final var schema = """ + final var root = JtdParser.parseString(""" { "discriminator": "type", "mapping": { - "cat": { - "properties": { - "name": {"type": "string"}, - "meow": {"type": "boolean"} - } - }, - "dog": { - "properties": { - "name": {"type": "string"}, - "bark": {"type": "boolean"} - } - } + "cat": {"properties": {"name": {"type": "string"}, "meow": {"type": "boolean"}}}, + "dog": {"properties": {"name": {"type": "string"}, "bark": {"type": "boolean"}}} }, "metadata": {"id": "animal-union"} } - """; - - final var root = JtdParser.parseString(schema); + """); assertThat(root.rootSchema()).isInstanceOf(JtdAst.DiscriminatorNode.class); - final var discNode = (JtdAst.DiscriminatorNode) root.rootSchema(); assertThat(discNode.discriminator()).isEqualTo("type"); assertThat(discNode.mapping()).containsKeys("cat", "dog"); @@ -152,18 +108,10 @@ void parsesDiscriminatorUnionSchema() { @Test void parsesNullableWrapperSchema() { LOG.info(() -> "Running parsesNullableWrapperSchema"); - - final var schema = """ - { - "type": "string", - "nullable": true, - "metadata": {"id": "nullable-string"} - } - """; - - final var root = JtdParser.parseString(schema); + final var root = JtdParser.parseString(""" + {"type": "string", "nullable": true, "metadata": {"id": "nullable-string"}} + """); assertThat(root.rootSchema()).isInstanceOf(JtdAst.NullableNode.class); - final var nullableNode = (JtdAst.NullableNode) root.rootSchema(); assertThat(nullableNode.wrapped()).isInstanceOf(JtdAst.TypeNode.class); } @@ -171,20 +119,13 @@ void parsesNullableWrapperSchema() { @Test void parsesRefAndDefinitions() { LOG.info(() -> "Running parsesRefAndDefinitions"); - - final var schema = """ + final var root = JtdParser.parseString(""" { - "definitions": { - "dataValue": {"type": "string"} - }, - "properties": { - "data": {"ref": "dataValue"} - }, + "definitions": {"dataValue": {"type": "string"}}, + "properties": {"data": {"ref": "dataValue"}}, "metadata": {"id": "ref-test"} } - """; - - final var root = JtdParser.parseString(schema); + """); assertThat(root.definitions()).containsKey("dataValue"); assertThat(root.rootSchema()).isInstanceOf(JtdAst.PropertiesNode.class); } @@ -192,12 +133,7 @@ void parsesRefAndDefinitions() { @Test void rejectsUnknownType() { LOG.info(() -> "Running rejectsUnknownType"); - - final var schema = """ - {"type": "unknown"} - """; - - assertThatThrownBy(() -> JtdParser.parseString(schema)) + assertThatThrownBy(() -> JtdParser.parseString("{\"type\": \"unknown\"}")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Unknown type"); } @@ -205,237 +141,183 @@ void rejectsUnknownType() { @Test void rejectsInvalidEnum() { LOG.info(() -> "Running rejectsInvalidEnum"); - - final var schema = """ - {"enum": ["a", 123, "c"]} - """; - - assertThatThrownBy(() -> JtdParser.parseString(schema)) + assertThatThrownBy(() -> JtdParser.parseString("{\"enum\": [\"a\", 123, \"c\"]}")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("to be a string"); } + // --- Generated code content tests (no JS execution) --- + @Test - void generatedBooleanValidatorPassesValidCases(@TempDir Path tempDir) throws Exception { - LOG.info(() -> "Running generatedBooleanValidatorPassesValidCases"); - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); + void generatedValidatorIncludesOnlyNeededHelpers(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedValidatorIncludesOnlyNeededHelpers"); + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, """ + {"type": "boolean", "metadata": {"id": "simple"}} + """, StandardCharsets.UTF_8); + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); + final String generated = Files.readString(outJs, StandardCharsets.UTF_8); - final var schema = """ - {"type": "boolean", "metadata": {"id": "bool-test"}} - """; + assertThat(generated).doesNotContain("isTimestamp"); + assertThat(generated).doesNotContain("isIntInRange"); + assertThat(generated).doesNotContain("isFloat"); + assertThat(generated).contains("typeof"); + } + @Test + void generatedTimestampValidatorIncludesTimestampHelper(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedTimestampValidatorIncludesTimestampHelper"); final Path schemaFile = tempDir.resolve("schema.json"); - Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); - + Files.writeString(schemaFile, """ + {"type": "timestamp", "metadata": {"id": "ts-test"}} + """, StandardCharsets.UTF_8); final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); + final String generated = Files.readString(outJs, StandardCharsets.UTF_8); - // Create test runner - final var runner = """ - import { validate } from '%s'; + // Spec-compliant: timestamp check is inlined (no helper function) + assertThat(generated).contains("/type"); + assertThat(generated).contains("errors.push"); + } + + // --- GraalVM Polyglot JS execution tests --- + + @Test + void generatedBooleanValidatorPassesValidCases(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedBooleanValidatorPassesValidCases"); + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, """ + {"type": "boolean", "metadata": {"id": "bool-test"}} + """, StandardCharsets.UTF_8); + final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); - const results = []; + try (var cx = jsContext()) { + final var exports = evalModule(cx, outJs); + final var validate = exports.getMember("validate"); // Valid cases - results.push({ name: 'true', errors: validate(true), expectEmpty: true }); - results.push({ name: 'false', errors: validate(false), expectEmpty: true }); + assertThat(errCount(validate, true)).as("true").isZero(); + assertThat(errCount(validate, false)).as("false").isZero(); // Invalid cases - results.push({ name: 'string', errors: validate('hello'), expectEmpty: false }); - results.push({ name: 'number', errors: validate(42), expectEmpty: false }); - results.push({ name: 'null', errors: validate(null), expectEmpty: false }); - results.push({ name: 'object', errors: validate({}), expectEmpty: false }); - results.push({ name: 'array', errors: validate([]), expectEmpty: false }); - - console.log(JSON.stringify(results)); - """.formatted(outJs.toUri()); - - final Path runnerFile = tempDir.resolve("runner.mjs"); - Files.writeString(runnerFile, runner, StandardCharsets.UTF_8); - - final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) - .directory(tempDir.toFile()) - .redirectErrorStream(true) - .start(); - - final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - final int code = p.waitFor(); - - assertThat(code).as("bun exit code; output:\n%s", output).isEqualTo(0); - - // Parse and verify results - final var json = jdk.sandbox.java.util.json.Json.parse(output); - final var results = (jdk.sandbox.java.util.json.JsonArray) json; - - for (jdk.sandbox.java.util.json.JsonValue v : results.elements()) { - final var obj = (jdk.sandbox.java.util.json.JsonObject) v; - final String name = ((jdk.sandbox.java.util.json.JsonString) obj.get("name")).string(); - final boolean expectEmpty = ((jdk.sandbox.java.util.json.JsonBoolean) obj.get("expectEmpty")).bool(); - final var errors = (jdk.sandbox.java.util.json.JsonArray) obj.get("errors"); - - if (expectEmpty) { - assertThat(errors.elements()).as("Test case '%s' should have no errors", name).isEmpty(); - } else { - assertThat(errors.elements()).as("Test case '%s' should have errors", name).isNotEmpty(); - } + assertThat(errCount(validate, "hello")).as("string").isGreaterThan(0); + assertThat(errCount(validate, 42)).as("number").isGreaterThan(0); + assertThat(errCount(validate, cx.eval("js", "null"))).as("null").isGreaterThan(0); } } @Test void generatedStringArrayValidatorWorks(@TempDir Path tempDir) throws Exception { LOG.info(() -> "Running generatedStringArrayValidatorWorks"); - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); - - final var schema = """ - { - "elements": {"type": "string"}, - "metadata": {"id": "string-array-test"} - } - """; - final Path schemaFile = tempDir.resolve("schema.json"); - Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); - + Files.writeString(schemaFile, """ + {"elements": {"type": "string"}, "metadata": {"id": "string-array-test"}} + """, StandardCharsets.UTF_8); final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); - final var runner = """ - import { validate } from '%s'; - - const results = []; - - // Valid cases - results.push({ name: 'empty-array', errors: validate([]), expectEmpty: true }); - results.push({ name: 'string-array', errors: validate(["a", "b", "c"]), expectEmpty: true }); - - // Invalid cases - results.push({ name: 'not-array', errors: validate("hello"), expectEmpty: false }); - results.push({ name: 'mixed-array', errors: validate(["a", 123, "c"]), expectEmpty: false }); - results.push({ name: 'number-array', errors: validate([1, 2, 3]), expectEmpty: false }); - - console.log(JSON.stringify(results)); - """.formatted(outJs.toUri()); - - final Path runnerFile = tempDir.resolve("runner.mjs"); - Files.writeString(runnerFile, runner, StandardCharsets.UTF_8); - - final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) - .directory(tempDir.toFile()) - .redirectErrorStream(true) - .start(); - - final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - final int code = p.waitFor(); - - assertThat(code).as("bun exit code; output:\n%s", output).isEqualTo(0); + try (var cx = jsContext()) { + final var exports = evalModule(cx, outJs); + final var validate = exports.getMember("validate"); + + // Valid: empty array + assertThat(errCount(validate, cx.eval("js", "[]"))).as("empty-array").isZero(); + // Valid: string array + assertThat(errCount(validate, cx.eval("js", "['a','b','c']"))).as("string-array").isZero(); + // Invalid: not an array + assertThat(errCount(validate, "hello")).as("not-array").isGreaterThan(0); + // Invalid: mixed + assertThat(errCount(validate, cx.eval("js", "['a',123,'c']"))).as("mixed").isGreaterThan(0); + } } @Test void generatedObjectValidatorChecksRequiredAndOptional(@TempDir Path tempDir) throws Exception { LOG.info(() -> "Running generatedObjectValidatorChecksRequiredAndOptional"); - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); - - final var schema = """ + final Path schemaFile = tempDir.resolve("schema.json"); + Files.writeString(schemaFile, """ { - "properties": { - "id": {"type": "int32"}, - "name": {"type": "string"} - }, - "optionalProperties": { - "email": {"type": "string"} - }, + "properties": {"id": {"type": "int32"}, "name": {"type": "string"}}, + "optionalProperties": {"email": {"type": "string"}}, "metadata": {"id": "user-schema"} } - """; - - final Path schemaFile = tempDir.resolve("schema.json"); - Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); - + """, StandardCharsets.UTF_8); final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); - final var runner = """ - import { validate } from '%s'; - - const results = []; - - // Valid cases - results.push({ name: 'complete', errors: validate({id: 1, name: "Alice", email: "a@b.com"}), expectEmpty: true }); - results.push({ name: 'without-optional', errors: validate({id: 1, name: "Alice"}), expectEmpty: true }); - - // Invalid cases - results.push({ name: 'missing-required', errors: validate({name: "Alice"}), expectEmpty: false }); - results.push({ name: 'wrong-type', errors: validate({id: "not-a-number", name: "Alice"}), expectEmpty: false }); - results.push({ name: 'not-object', errors: validate("hello"), expectEmpty: false }); - - console.log(JSON.stringify(results)); - """.formatted(outJs.toUri()); - - final Path runnerFile = tempDir.resolve("runner.mjs"); - Files.writeString(runnerFile, runner, StandardCharsets.UTF_8); - - final var p = new ProcessBuilder("bun", "run", runnerFile.toString()) - .directory(tempDir.toFile()) - .redirectErrorStream(true) - .start(); - - final var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - final int code = p.waitFor(); - - assertThat(code).as("bun exit code; output:\n%s", output).isEqualTo(0); + try (var cx = jsContext()) { + final var exports = evalModule(cx, outJs); + final var validate = exports.getMember("validate"); + + // Valid: complete object + assertThat(errCount(validate, cx.eval("js", "({id:1,name:'Alice',email:'a@b.com'})"))) + .as("complete").isZero(); + // Valid: without optional + assertThat(errCount(validate, cx.eval("js", "({id:1,name:'Alice'})"))) + .as("without-optional").isZero(); + // Invalid: missing required + assertThat(errCount(validate, cx.eval("js", "({name:'Alice'})"))) + .as("missing-required").isGreaterThan(0); + // Invalid: wrong type + assertThat(errCount(validate, cx.eval("js", "({id:'not-int',name:'Alice'})"))) + .as("wrong-type").isGreaterThan(0); + // Invalid: not an object + assertThat(errCount(validate, "hello")).as("not-object").isGreaterThan(0); + } } @Test - void generatedValidatorIncludesOnlyNeededHelpers(@TempDir Path tempDir) throws Exception { - LOG.info(() -> "Running generatedValidatorIncludesOnlyNeededHelpers"); - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); - - // Schema that only needs basic type checks - final var simpleSchema = """ - {"type": "boolean", "metadata": {"id": "simple"}} - """; - + void generatedDiscriminatorValidatorWorks(@TempDir Path tempDir) throws Exception { + LOG.info(() -> "Running generatedDiscriminatorValidatorWorks"); final Path schemaFile = tempDir.resolve("schema.json"); - Files.writeString(schemaFile, simpleSchema, StandardCharsets.UTF_8); - + Files.writeString(schemaFile, """ + { + "discriminator": "kind", + "mapping": { + "cat": {"properties": {"name": {"type": "string"}, "meow": {"type": "boolean"}}}, + "dog": {"properties": {"name": {"type": "string"}, "bark": {"type": "boolean"}}} + }, + "metadata": {"id": "animal-disc"} + } + """, StandardCharsets.UTF_8); final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); - final String generated = Files.readString(outJs, StandardCharsets.UTF_8); - // Should NOT include unused helpers - assertThat(generated).doesNotContain("isTimestamp"); - assertThat(generated).doesNotContain("isIntInRange"); - assertThat(generated).doesNotContain("isFloat"); - - // Should use typeof directly - assertThat(generated).contains("typeof"); + try (var cx = jsContext()) { + final var exports = evalModule(cx, outJs); + final var validate = exports.getMember("validate"); + + // Valid: cat + assertThat(errCount(validate, cx.eval("js", "({kind:'cat',name:'Whiskers',meow:true})"))) + .as("valid-cat").isZero(); + // Valid: dog + assertThat(errCount(validate, cx.eval("js", "({kind:'dog',name:'Rex',bark:true})"))) + .as("valid-dog").isZero(); + // Invalid: unknown discriminator value + assertThat(errCount(validate, cx.eval("js", "({kind:'fish',name:'Nemo'})"))) + .as("unknown-kind").isGreaterThan(0); + // Invalid: missing discriminator + assertThat(errCount(validate, cx.eval("js", "({name:'Rex',bark:true})"))) + .as("missing-disc").isGreaterThan(0); + // Invalid: not an object + assertThat(errCount(validate, "hello")).as("not-object").isGreaterThan(0); + } } - @Test - void generatedTimestampValidatorIncludesTimestampHelper(@TempDir Path tempDir) throws Exception { - LOG.info(() -> "Running generatedTimestampValidatorIncludesTimestampHelper"); - assumeTrue(isBunAvailable(), "bun is required for JavaScript execution tests"); - - final var schema = """ - {"type": "timestamp", "metadata": {"id": "ts-test"}} - """; - - final Path schemaFile = tempDir.resolve("schema.json"); - Files.writeString(schemaFile, schema, StandardCharsets.UTF_8); + // --- Helpers --- - final Path outJs = JtdToEsmCli.run(schemaFile, tempDir); - final String generated = Files.readString(outJs, StandardCharsets.UTF_8); + private static Context jsContext() { + return Context.newBuilder("js") + .allowIO(IOAccess.ALL) + .option("js.esm-eval-returns-exports", "true") + .option("js.ecmascript-version", "2020") + .build(); + } - // Should include timestamp helper since it's needed - assertThat(generated).contains("isTimestamp"); - assertThat(generated).contains("Date.parse"); + private static Value evalModule(Context cx, Path modulePath) throws Exception { + final var source = Source.newBuilder("js", modulePath.toFile()) + .mimeType("application/javascript+module") + .build(); + return cx.eval(source); } - private static boolean isBunAvailable() { - try { - final var p = new ProcessBuilder("bun", "--version") - .redirectErrorStream(true) - .start(); - final int code = p.waitFor(); - return code == 0; - } catch (Exception ignored) { - return false; - } + private static int errCount(Value validateFn, Object value) { + return (int) validateFn.execute(value).getArraySize(); } }