diff --git a/.github/workflows/binding-publish-maven.yml b/.github/workflows/binding-publish-maven.yml new file mode 100644 index 0000000..0ed6730 --- /dev/null +++ b/.github/workflows/binding-publish-maven.yml @@ -0,0 +1,48 @@ +name: Publish Java bindings (placeholder, publishes nothing) + +on: + workflow_dispatch: + inputs: + targets: + description: 'Comma-separated package-key:java targets, or all' + type: string + default: '' + dry_run: + description: 'Must stay true: the workflow is a placeholder and verifies and publishes nothing' + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: binding-publish-maven + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Reject real publication + if: ${{ !inputs.dry_run }} + run: | + echo "::error::Maven publication is not implemented. Run with dry_run enabled; nothing was published." + exit 1 + - name: Record that nothing was verified or published + env: + TARGETS: ${{ inputs.targets }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + { + echo "## Placeholder: nothing was verified or published" + echo + echo "Maven publication is not implemented. This run did not verify, build, or publish any package." + echo + echo "- targets: \`${TARGETS:-}\`" + echo "- dry_run: \`${DRY_RUN}\`" + } >> "$GITHUB_STEP_SUMMARY" + echo "::notice::Placeholder only. No package was verified or published." diff --git a/.github/workflows/extension-binding-normalizer.yml b/.github/workflows/extension-binding-normalizer.yml index ec2228d..219658e 100644 --- a/.github/workflows/extension-binding-normalizer.yml +++ b/.github/workflows/extension-binding-normalizer.yml @@ -8,6 +8,7 @@ on: - "tooling/extension-bindings/IMPLEMENTATION.md" - "tooling/extension-bindings/README.md" - "tooling/extension-bindings/emitters/go/**" + - "tooling/extension-bindings/emitters/java/**" - "tooling/extension-bindings/emitters/typescript/**" - "tooling/extension-bindings/model/**" - "tooling/extension-bindings/normalizer/**" @@ -19,6 +20,7 @@ on: - "tooling/extension-bindings/IMPLEMENTATION.md" - "tooling/extension-bindings/README.md" - "tooling/extension-bindings/emitters/go/**" + - "tooling/extension-bindings/emitters/java/**" - "tooling/extension-bindings/emitters/typescript/**" - "tooling/extension-bindings/model/**" - "tooling/extension-bindings/normalizer/**" @@ -111,3 +113,32 @@ jobs: - name: Test the TypeScript emitter and generated conformance packages run: npm test + + verify-java-emitter: + name: Verify Java emitter + runs-on: ubuntu-latest + timeout-minutes: 20 + + defaults: + run: + working-directory: tooling/extension-bindings/emitters/java + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Set up Java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: 21.0.12 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + gradle-version: 8.14.2 + + - name: Test the Java emitter and generated conformance packages + run: gradle test --console=plain diff --git a/tooling/extension-bindings/README.md b/tooling/extension-bindings/README.md index e977c4c..d29e4f0 100644 --- a/tooling/extension-bindings/README.md +++ b/tooling/extension-bindings/README.md @@ -2,8 +2,8 @@ `IMPLEMENTATION.md` is historical design guidance; the repository owner's standing overrides govern generation and publication. -The shared Go resolver and normalizer feed native Go and Python emitters. A native TypeScript -emitter lives under `emitters/typescript/`. +The shared Go resolver and normalizer feed native Go and Python emitters. Native TypeScript +and Java emitters live under `emitters/typescript/` and `emitters/java/`. The Go orchestrator exposes eight commands through Go's standard `flag` package. GitHub Actions generates and commits Go and Python bindings to `main`, then a separate manual workflow publishes verified Go module tags and release assets. @@ -144,10 +144,58 @@ For local checks: python3 -m unittest discover -s tooling/common -p 'test_*.py' python3 -m unittest discover -s tooling/extension-bindings/ci -p 'test_*.py' (cd tooling/extension-bindings/emitters/typescript && npm ci --ignore-scripts && npm test) +(cd tooling/extension-bindings/emitters/java && gradle test) python3 tooling/common/build_extensions_site.py --output /tmp/extension-site python3 tooling/extension-bindings/ci/publish.py configure --targets env-configuration:go ``` +## Java emitter quickstart + +The Java emitter is a standalone program under `emitters/java/`. It needs JDK 21 and +Gradle 8.14.2, the version pinned in `toolchain.lock.yaml` and used by CI; install +both before you start (the build selects its JDK through a Gradle toolchain). The +orchestrator does not run the Java emitter yet, so the three inputs the orchestrator +supplies to the other emitters are supplied by hand: + +- the normalized binding model, which the shared normalizer writes. The committed + conformance models under `model/conformance/expected/` are ready-made examples; +- a package target, which selects the Maven coordinates and the Java package. Start + from `emitters/java/testdata/package-target.yaml`; +- `emitterSha256`, the SHA-256 of the emitter build, which is recorded in the + generated binding manifest. For a local run any digest of your emitter build will + do; the orchestrator will supply the pinned one. + +```sh +cd tooling/extension-bindings/emitters/java +gradle installDist +EMITTER=build/install/runtimeconditions-binding-emitter-java/bin/runtimeconditions-binding-emitter-java +SHA=$(sha256sum build/install/runtimeconditions-binding-emitter-java/lib/runtimeconditions-binding-emitter-java-*.jar | cut -d' ' -f1) +MODEL=../../model/conformance/expected/01-owned-kind-interface/runtimeconditions.binding-model.yaml +{ cat testdata/package-target.yaml; echo "emitterSha256: $SHA"; } > /tmp/target.yaml + +$EMITTER emit --model $MODEL --package-config /tmp/target.yaml --output /tmp/java-package +$EMITTER verify --model $MODEL --package-config /tmp/target.yaml --input /tmp/java-package +``` + +`emit` writes a Maven project (`pom.xml`, `src/main/java/...`, and the binding +manifest under `src/main/resources/runtimeconditions/`) into a new empty directory. +`verify` checks its API, `pom.xml`, and manifest against the same model and target +and prints the API surface as JSON. `api --input DIR` prints just that surface. A +dependency that provides another extension's declarations must be listed in the +target's `dependencies`, together with the owner of any imported declaration, even +when that owner is only a transitive dependency. + +A consuming project adds the generated package as a Maven dependency and calls the +generated function with the generated field types: + +```java +import io.runtimeconditions.bindings.conformance.ownedkindinterface.Bindings; +import io.runtimeconditions.bindings.conformance.ownedkindinterface.Http; +import io.runtimeconditions.bindings.conformance.ownedkindinterface.Region; + +var declaration = Bindings.service(new Http("https://api.example.com"), new Region("eu-west-1")); +``` + `publish.py build` takes `--rc`, `--core-schema`, and a new `--output` directory. `publish.py promote --dry-run` validates those packaged artifacts against a clean checkout and the live discovery pages. It takes `GH_TOKEN` or `GITHUB_TOKEN` for diff --git a/tooling/extension-bindings/emitters/java/.gitignore b/tooling/extension-bindings/emitters/java/.gitignore new file mode 100644 index 0000000..67bcc2f --- /dev/null +++ b/tooling/extension-bindings/emitters/java/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/tooling/extension-bindings/emitters/java/build.gradle.kts b/tooling/extension-bindings/emitters/java/build.gradle.kts new file mode 100644 index 0000000..c7a4f8b --- /dev/null +++ b/tooling/extension-bindings/emitters/java/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + application +} + +group = "io.runtimeconditions.bindings" +version = "0.1.0" + +repositories { + mavenCentral() +} + +dependencyLocking { + lockAllConfigurations() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + implementation("org.snakeyaml:snakeyaml-engine:3.2") + implementation("com.networknt:json-schema-validator:1.5.9") + implementation("com.fasterxml.jackson.core:jackson-databind:2.22.4") + runtimeOnly("org.slf4j:slf4j-nop:2.0.17") + + testImplementation(platform("org.junit:junit-bom:6.1.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +application { + applicationName = "runtimeconditions-binding-emitter-java" + mainClass = "io.runtimeconditions.bindings.emitter.Main" +} + +tasks.processResources { + filesMatching("emitter.properties") { + expand("name" to application.applicationName, "version" to project.version) + } + from(layout.projectDirectory.dir("templates")) { + into("templates") + } + from(layout.projectDirectory.file("package-target.schema.yaml")) { + into("target") + } + from(layout.projectDirectory.file("../../model/runtimeconditions.binding-model.schema.yaml")) { + into("model") + } +} + +tasks.test { + useJUnitPlatform() + systemProperty("rc.emitter.root", projectDir.absolutePath) + systemProperty("rc.gradle.version", gradle.gradleVersion) + maxHeapSize = "1g" +} diff --git a/tooling/extension-bindings/emitters/java/gradle.lockfile b/tooling/extension-bindings/emitters/java/gradle.lockfile new file mode 100644 index 0000000..b45d7b4 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/gradle.lockfile @@ -0,0 +1,26 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.22=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.22.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.22.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.22.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.22.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.networknt:json-schema-validator:1.5.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.jspecify:jspecify:1.0.0=testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.1.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.1.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.1.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.1.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.1.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.1.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.1.3=testRuntimeClasspath +org.junit:junit-bom:6.1.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-nop:2.0.17=runtimeClasspath,testRuntimeClasspath +org.snakeyaml:snakeyaml-engine:3.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +empty=annotationProcessor,testAnnotationProcessor diff --git a/tooling/extension-bindings/emitters/java/package-target.schema.yaml b/tooling/extension-bindings/emitters/java/package-target.schema.yaml new file mode 100644 index 0000000..cf16a25 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/package-target.schema.yaml @@ -0,0 +1,102 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://runtimeconditions.io/schemas/java-package-target/v1alpha1 +title: Runtime Conditions Java package target +type: object +additionalProperties: false +required: + - apiVersion + - kind + - packageKey + - rootExtension + - groupId + - artifactId + - javaPackage + - version + - sourceDirectory + - minimumJavaVersion + - publicationMode + - repositoryUrl +properties: + apiVersion: + const: runtimeconditions.io/java-package-target/v1alpha1 + kind: + const: RuntimeConditionsJavaPackageTarget + packageKey: + type: string + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + rootExtension: + type: string + minLength: 1 + groupId: + description: The Maven groupId. + type: string + maxLength: 128 + pattern: '^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$' + artifactId: + description: The Maven artifactId. + type: string + maxLength: 128 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + javaPackage: + description: The Java package every generated type is declared in. It is never inferred from extension vocabulary. + type: string + pattern: '^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$' + version: + type: string + pattern: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.(0|[1-9][0-9]*))?$' + sourceDirectory: + type: string + pattern: '^bindings/[a-z0-9]+(?:-[a-z0-9]+)*/java$' + minimumJavaVersion: + type: string + pattern: '^(?:1[7-9]|[2-9][0-9]|[1-9][0-9]{2,})$' + publicationMode: + enum: [github-tag, registry] + registryId: + type: string + minLength: 1 + repositoryUrl: + type: string + pattern: '^https://[^\s]+$' + emitterSha256: + description: Required when emitting package resources; supplied by the tooling caller. + type: string + pattern: '^[0-9a-f]{64}$' + dependencies: + type: array + items: + type: object + additionalProperties: false + required: [extension, groupId, artifactId, javaPackage, version] + properties: + extension: + type: string + minLength: 1 + groupId: + type: string + maxLength: 128 + pattern: '^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$' + artifactId: + type: string + maxLength: 128 + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + javaPackage: + type: string + pattern: '^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$' + version: + type: string + pattern: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.(0|[1-9][0-9]*))?$' + versionRange: + description: A Maven version range for this dependency. When absent, the range from the declared version up to the next breaking version is used. + type: string + minLength: 1 +allOf: + - if: + properties: + publicationMode: + const: registry + then: + required: [registryId] + else: + not: + required: [registryId] diff --git a/tooling/extension-bindings/emitters/java/settings.gradle.kts b/tooling/extension-bindings/emitters/java/settings.gradle.kts new file mode 100644 index 0000000..1d6f946 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "runtimeconditions-binding-emitter-java" diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Cli.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Cli.java new file mode 100644 index 0000000..40215b7 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Cli.java @@ -0,0 +1,77 @@ +package io.runtimeconditions.bindings.emitter; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import java.io.PrintStream; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class Cli { + private static final String USAGE = + "usage: runtimeconditions-binding-emitter-java emit|verify|api --model FILE --package-config FILE (--output DIR | --input DIR)"; + private static final Set OPTIONS = Set.of("--model", "--package-config", "--output", "--input"); + + private Cli() {} + + public static int run(List argv, PrintStream out, PrintStream err) { + if (argv.isEmpty()) { + err.println(USAGE); + return 1; + } + String command = argv.get(0); + if (command.equals("--version")) { + out.println(Metadata.EMITTER_VERSION); + return 0; + } + try { + if (!command.equals("emit") && !command.equals("verify") && !command.equals("api")) { + throw new IllegalArgumentException(USAGE); + } + Map values = new LinkedHashMap<>(); + for (int index = 1; index < argv.size(); index += 2) { + String name = argv.get(index); + if (!OPTIONS.contains(name) || index + 1 >= argv.size() || values.containsKey(name)) { + throw new IllegalArgumentException(USAGE); + } + values.put(name, argv.get(index + 1)); + } + ObjectNode result = Json.object(); + if (command.equals("api")) { + if (!values.containsKey("--input")) { + throw new IllegalArgumentException(USAGE); + } + result.set("api", Json.MAPPER.valueToTree(Verification.apiSurface(Path.of(values.get("--input"))))); + } else { + if (!values.containsKey("--model") || !values.containsKey("--package-config")) { + throw new IllegalArgumentException(USAGE); + } + ObjectNode model = Model.load(Path.of(values.get("--model"))); + PackageTarget target = Target.load(Path.of(values.get("--package-config"))); + EmissionPlan plan = Plan.build(model, target); + if (command.equals("emit")) { + if (!values.containsKey("--output")) { + throw new IllegalArgumentException(USAGE); + } + Source.emitPackage(plan, model, Path.of(values.get("--output"))); + result.put("output", values.get("--output")); + } else { + if (!values.containsKey("--input")) { + throw new IllegalArgumentException(USAGE); + } + Verification.Verified verified = Verification.verifyPackage(plan, Path.of(values.get("--input")), model); + result.set("api", Json.MAPPER.valueToTree(verified.api())); + result.put("modelMapping", verified.modelMapping()); + } + } + out.println(Json.MAPPER.writeValueAsString(result)); + return 0; + } catch (Exception error) { + err.println(error.getMessage()); + return 1; + } + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/DiagnosticError.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/DiagnosticError.java new file mode 100644 index 0000000..619baab --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/DiagnosticError.java @@ -0,0 +1,39 @@ +package io.runtimeconditions.bindings.emitter; + +import java.io.Serializable; + +public final class DiagnosticError extends RuntimeException { + private static final long serialVersionUID = 1L; + + public record Diagnostic( + String category, String code, String coordinate, String message, String jsonPointer) + implements Serializable {} + + private final Diagnostic diagnostic; + + public DiagnosticError(Diagnostic diagnostic) { + super( + diagnostic.code() + + " " + + (diagnostic.jsonPointer().isEmpty() + ? diagnostic.coordinate() + : diagnostic.coordinate() + " " + diagnostic.jsonPointer()) + + ": " + + diagnostic.message()); + this.diagnostic = diagnostic; + } + + public Diagnostic diagnostic() { + return diagnostic; + } + + public static DiagnosticError fail( + String category, String code, String coordinate, String message) { + return fail(category, code, coordinate, message, ""); + } + + public static DiagnosticError fail( + String category, String code, String coordinate, String message, String jsonPointer) { + return new DiagnosticError(new Diagnostic(category, code, coordinate, message, jsonPointer)); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Json.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Json.java new file mode 100644 index 0000000..eff1a5c --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Json.java @@ -0,0 +1,41 @@ +package io.runtimeconditions.bindings.emitter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.List; + +final class Json { + static final ObjectMapper MAPPER = new ObjectMapper(); + + private Json() {} + + static List list(JsonNode node) { + List result = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode item : (ArrayNode) node) { + result.add(item); + } + } + return result; + } + + static String text(JsonNode node, String name) { + JsonNode value = node.path(name); + return value.isTextual() ? value.asText() : ""; + } + + static boolean has(JsonNode node, String name) { + return node.has(name) && !node.get(name).isNull(); + } + + static ObjectNode object() { + return MAPPER.createObjectNode(); + } + + static int compare(String left, String right) { + return Integer.signum(left.compareTo(right)); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Main.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Main.java new file mode 100644 index 0000000..0158752 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Main.java @@ -0,0 +1,11 @@ +package io.runtimeconditions.bindings.emitter; + +import java.util.List; + +public final class Main { + private Main() {} + + public static void main(String[] args) { + System.exit(Cli.run(List.of(args), System.out, System.err)); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Metadata.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Metadata.java new file mode 100644 index 0000000..460a89b --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Metadata.java @@ -0,0 +1,469 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.DeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Plan.FieldPlan; +import io.runtimeconditions.bindings.emitter.Plan.ImportedDeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.Kind; +import io.runtimeconditions.bindings.emitter.Plan.TypeExpr; +import io.runtimeconditions.bindings.emitter.Plan.TypePlan; +import io.runtimeconditions.bindings.emitter.Target.Dependency; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import org.snakeyaml.engine.v2.api.Dump; +import org.snakeyaml.engine.v2.api.DumpSettings; +import org.snakeyaml.engine.v2.common.FlowStyle; + +public final class Metadata { + public static final String EMITTER_NAME = property("name"); + public static final String EMITTER_VERSION = property("version"); + public static final String MANIFEST_PATH = "src/main/resources/runtimeconditions/runtimeconditions.bindings.yaml"; + + private Metadata() {} + + /** Reads the emitter's identity from {@code emitter.properties}, which Gradle fills from the build. */ + private static String property(String key) { + try (InputStream stream = Metadata.class.getResourceAsStream("/emitter.properties")) { + Properties properties = new Properties(); + properties.load(Objects.requireNonNull(stream)); + return properties.getProperty(key); + } catch (IOException | NullPointerException error) { + throw new ExceptionInInitializerError("emitter.properties is unavailable"); + } + } + + public static String header(JsonNode model) { + return "Code generated by " + + EMITTER_NAME + + " " + + EMITTER_VERSION + + " from model " + + model.path("metadata").path("semanticSha256").asText() + + "; DO NOT EDIT."; + } + + static String dependencyVersion(Dependency dependency) { + return dependency.versionRange() != null + ? dependency.versionRange() + : dependencyInterval(dependency.version(), dependency.extension()); + } + + /** The target schema has already checked {@code value} as a semantic version. */ + static String dependencyInterval(String value, String coordinate) { + String[] parts = value.split("[.-]"); + int major = Integer.parseInt(parts[0]); + int minor = Integer.parseInt(parts[1]); + String next = major == 0 ? "0." + (minor + 1) + ".0" : (major + 1) + ".0.0"; + return "[" + value + "," + next + ")"; + } + + private static String xml(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static String pom(EmissionPlan plan, JsonNode model) { + PackageTarget target = plan.target(); + Set coordinates = new HashSet<>(); + for (Dependency dependency : target.dependencies()) { + if (!coordinates.add(dependency.coordinate())) { + throw fail( + "package-config", + "RCJ1006", + target.packageKey(), + "multiple direct extensions use the same Maven coordinate", + "/dependencies"); + } + } + List dependencies = new ArrayList<>(target.dependencies()); + dependencies.sort(Comparator.comparing(Dependency::coordinate)); + String repository = target.repositoryUrl().endsWith(".git") ? target.repositoryUrl() : target.repositoryUrl() + ".git"; + StringBuilder items = new StringBuilder(); + for (Dependency dependency : dependencies) { + items.append( + Templates.fill( + "dependency.xml.tmpl", + Templates.read("dependency.xml.tmpl"), + Map.of( + "groupId", xml(dependency.groupId()), + "artifactId", xml(dependency.artifactId()), + "version", xml(dependencyVersion(dependency))))); + } + String block = + dependencies.isEmpty() + ? "" + : Templates.fill("dependencies.xml.tmpl", Templates.read("dependencies.xml.tmpl"), Map.of("items", items.toString())); + return Templates.fill( + "pom.xml.tmpl", + Templates.read("pom.xml.tmpl"), + Map.of( + "header", header(model), + "groupId", xml(target.groupId()), + "artifactId", xml(target.artifactId()), + "version", xml(target.version()), + "repositoryUrl", xml(target.repositoryUrl()), + "scmConnection", xml(repository), + "minimumJavaVersion", target.minimumJavaVersion(), + "dependencies", block)); + } + + private static String modelLocation(JsonNode item) { + return Plan.location(item); + } + + private static ObjectNode modelRef(JsonNode provenance) { + ObjectNode result = Json.object(); + result.put("coordinate", provenance.path("coordinate").asText()); + if (provenance.has("jsonPointer") && !provenance.path("jsonPointer").asText().isEmpty()) { + result.put("jsonPointer", provenance.path("jsonPointer").asText()); + } + return result; + } + + private static final class Locations { + final Map provenances = new LinkedHashMap<>(); + final Map shapes = new LinkedHashMap<>(); + } + + private static void add(Locations locations, JsonNode item) { + JsonNode provenance = item.path("provenance"); + String place = modelLocation(item); + JsonNode previous = locations.provenances.get(place); + if (previous != null && !previous.equals(provenance)) { + throw fail( + "model", + "RCJ1012", + place, + "ambiguous legacy plan coordinate; separate model coordinate and JSON Pointer are required"); + } + locations.provenances.put(place, provenance); + } + + private static void walk(Locations locations, JsonNode shape) { + add(locations, shape); + locations.shapes.putIfAbsent(modelLocation(shape), shape); + for (JsonNode property : Json.list(shape.path("properties"))) { + add(locations, property); + walk(locations, property.path("shape")); + } + for (String name : List.of("items", "mapValues")) { + if (shape.has(name)) { + walk(locations, shape.get(name)); + } + } + for (JsonNode child : Json.list(shape.path("variants"))) { + walk(locations, child); + } + } + + private static Locations modelLocations(JsonNode model) { + Locations locations = new Locations(); + JsonNode vocabulary = model.path("vocabulary"); + for (String group : + List.of("ownedDeclarations", "importedDeclarations", "interfaces", "conditionFields", "valueDomains")) { + for (JsonNode item : Json.list(vocabulary.path(group))) { + add(locations, item); + } + } + for (JsonNode scope : Json.list(model.path("scopes"))) { + if (scope.has("projection")) { + walk(locations, scope.get("projection")); + } + } + for (JsonNode schema : Json.list(model.path("schemas"))) { + add(locations, schema); + walk(locations, schema.path("projection")); + for (JsonNode definition : Json.list(schema.path("definitions"))) { + add(locations, definition); + walk(locations, definition.path("shape")); + } + } + return locations; + } + + private static ObjectNode reference(TypeExpr expression, EmissionPlan plan) { + ObjectNode result = Json.object(); + switch (expression.kind()) { + case "object", "enum", "ref", "alias" -> result.put("type", plan.names().get(expression.symbolKey())); + case "string" -> result.put("builtin", "String"); + case "boolean" -> result.put("builtin", "Boolean"); + case "integer" -> result.put("builtin", "Long"); + case "number" -> result.put("builtin", "Double"); + case "null" -> result.put("builtin", "Void"); + case "any" -> result.put("builtin", "Object"); + default -> throw new IllegalStateException("unplanned type kind " + expression.kind()); + } + return result; + } + + private static ArrayNode structuralTypes(EmissionPlan plan, Locations locations) { + Map contracts = new LinkedHashMap<>(); + for (DeclarationPlan item : plan.declarations()) { + contracts.put(item.method(), item.coordinate()); + } + for (ImportedDeclarationPlan item : plan.importedDeclarations()) { + contracts.put(item.method(), item.coordinate()); + } + List result = new ArrayList<>(); + for (TypePlan item : plan.types()) { + JsonNode shape = locations.shapes.get(item.coordinate()); + String aliasKind = item.expression() == null ? "" : item.expression().kind(); + String construct; + if (item.kind() == Kind.OBJECT) { + construct = shape.path("kind").asText().equals("object") ? "object" : "wrapper"; + } else if (item.kind() == Kind.ENUM) { + construct = "scalar"; + } else { + construct = switch (aliasKind) { + case "array" -> "collection"; + case "map" -> "map"; + default -> "union"; + }; + } + ObjectNode entry = Json.object(); + entry.set("modelRef", modelRef(locations.provenances.get(item.coordinate()))); + entry.put("sourceName", item.sourceName()); + entry.put("nativeName", item.name()); + entry.put("construct", construct); + entry.put("file", item.name() + ".java"); + if (item.kind() == Kind.ENUM) { + entry.put("underlying", "string"); + ArrayNode members = entry.putArray("members"); + for (Plan.Member member : item.members()) { + ObjectNode node = members.addObject(); + node.set("modelRef", modelRef(locations.provenances.get(item.coordinate()))); + node.put("nativeName", member.name()); + node.put("value", member.value()); + } + } + if (item.kind() == Kind.OBJECT) { + ArrayNode fields = entry.putArray("fields"); + for (FieldPlan field : item.fields()) { + ObjectNode node = fields.addObject(); + node.set("modelRef", modelRef(locations.provenances.get(field.coordinate()))); + node.put("sourceName", construct.equals("wrapper") ? item.sourceName() : field.sourceName()); + node.put("nativeName", field.name()); + node.put("required", field.required()); + node.set("value", reference(field.typeExpr(), plan)); + if (construct.equals("wrapper")) { + node.put("synthetic", true); + } + } + if (item.marker() != null) { + ObjectNode implemented = entry.putArray("implements").addObject(); + implemented.put("declarationCoordinate", contracts.get(item.marker().method())); + implemented.put("markerMethod", item.marker().method()); + } + } + if (item.kind() == Kind.ALIAS) { + if (construct.equals("collection") || construct.equals("map")) { + JsonNode child = shape.path(construct.equals("collection") ? "items" : "mapValues"); + ObjectNode element = entry.putObject("element"); + element.set("modelRef", modelRef(child.path("provenance"))); + element.set("value", reference(item.expression().arguments().get(0), plan)); + } else { + ArrayNode variants = entry.putArray("variants"); + List children = Json.list(shape.path("variants")); + for (int index = 0; index < children.size(); index += 1) { + ObjectNode node = variants.addObject(); + node.set("modelRef", modelRef(children.get(index).path("provenance"))); + node.set("value", reference(item.expression().arguments().get(index), plan)); + } + } + } + result.add(entry); + } + result.sort(Comparator.comparing((ObjectNode node) -> node.path("nativeName").asText())); + ArrayNode array = Json.MAPPER.createArrayNode(); + result.forEach(array::add); + return array; + } + + private static int text(String left, String right) { + return Integer.signum(left.compareTo(right)); + } + + private static String manifest(EmissionPlan plan, JsonNode model) { + Locations locations = modelLocations(model); + String rootId = model.path("rootExtension").path("id").asText(); + Map declarations = new LinkedHashMap<>(); + for (String group : List.of("ownedDeclarations", "importedDeclarations")) { + for (JsonNode item : Json.list(model.path("vocabulary").path(group))) { + declarations.put(item.path("kind").asText(), item); + } + } + Map, JsonNode> fields = new LinkedHashMap<>(); + for (JsonNode item : Json.list(model.path("vocabulary").path("conditionFields"))) { + if (item.path("owner").asText().equals(rootId)) { + fields.put( + List.of(item.path("kind").asText(), item.path("interfaceType").asText(""), item.path("path").asText()), + item); + } + } + List rootBindings = new ArrayList<>(); + for (JsonNode schema : Json.list(model.path("schemas"))) { + if (!schema.path("owner").asText().equals(rootId)) { + continue; + } + String kind = schema.has("kind") ? schema.path("kind").asText() : schema.path("id").asText(); + if (!declarations.containsKey(kind)) { + continue; + } + String interfaceType = schema.path("interfaceType").asText(""); + for (JsonNode property : Json.list(schema.path("projection").path("properties"))) { + String name = property.path("name").asText(); + boolean isInterface = name.equals("interface") && !interfaceType.isEmpty(); + String sourceName = isInterface ? interfaceType : name; + String nativeName = plan.names().get("type:field:" + schema.path("coordinate").asText() + ":" + name); + if (nativeName == null) { + throw fail( + "model", + "RCJ1012", + schema.path("coordinate").asText(), + "root property '" + name + "' has no declaration field type"); + } + JsonNode vocabularyField = fields.get(List.of(kind, interfaceType, name)); + String role = isInterface ? "interface" : vocabularyField != null ? "condition-field" : "schema-field"; + ObjectNode binding = Json.object(); + binding.put("role", role); + binding.set("modelRef", modelRef(property.path("provenance"))); + binding.put("declarationCoordinate", declarations.get(kind).path("coordinate").asText()); + ObjectNode scope = binding.putObject("scope"); + scope.put("kind", kind); + if (!interfaceType.isEmpty()) { + scope.put("interfaceType", interfaceType); + } + binding.put("sourceName", sourceName); + binding.putArray("path").addObject().put("name", name); + binding.putObject("value").put("type", nativeName); + binding.put("schemaCoordinate", schema.path("coordinate").asText()); + if (role.equals("interface")) { + binding.put("fixedInterfaceType", interfaceType); + } + if (vocabularyField != null) { + binding.put("vocabularyCoordinate", vocabularyField.path("coordinate").asText()); + } + rootBindings.add(binding); + } + } + rootBindings.sort( + (a, b) -> { + int order = text(a.path("declarationCoordinate").asText(), b.path("declarationCoordinate").asText()); + if (order != 0) { + return order; + } + order = text(a.path("modelRef").path("coordinate").asText(), b.path("modelRef").path("coordinate").asText()); + if (order != 0) { + return order; + } + return text(a.path("modelRef").path("jsonPointer").asText(""), b.path("modelRef").path("jsonPointer").asText("")); + }); + + ObjectNode document = Json.object(); + document.put("apiVersion", "runtimeconditions.io/bindings/v1alpha2"); + document.put("kind", "RuntimeConditionsBindingManifest"); + ObjectNode generated = document.putObject("generated"); + generated.put("nonEditable", true); + generated.put("emitter", EMITTER_NAME + "@sha256:" + plan.target().emitterSha256()); + generated.put("version", EMITTER_VERSION); + ObjectNode modelNode = document.putObject("model"); + modelNode.put("apiVersion", model.path("apiVersion").asText()); + modelNode.put("semanticSha256", plan.modelDigest()); + ObjectNode extension = document.putObject("extension"); + extension.put("id", rootId); + extension.put("semanticSha256", model.path("rootExtension").path("semanticSha256").asText()); + ObjectNode pack = document.putObject("package"); + pack.put("language", "java"); + pack.put("coordinate", plan.target().coordinate()); + pack.put("name", plan.target().javaPackage()); + pack.put("version", plan.target().version()); + pack.put("minimumJavaVersion", plan.target().minimumJavaVersion()); + ArrayNode declared = document.putArray("declarations"); + for (DeclarationPlan item : plan.declarations()) { + ObjectNode node = declared.addObject(); + node.set("modelRef", modelRef(locations.provenances.get(item.coordinate()))); + node.put("owner", rootId); + node.put("sourceName", item.kind()); + node.put("function", item.function()); + node.put("functionClass", Plan.FUNCTIONS_CLASS); + node.put("markerInterface", item.protocol()); + node.put("markerMethod", item.method()); + node.put("file", Plan.FUNCTIONS_CLASS + ".java"); + } + ArrayNode contracts = document.putArray("importedMarkerContracts"); + for (ImportedDeclarationPlan item : plan.importedDeclarations()) { + ObjectNode node = contracts.addObject(); + node.set("modelRef", modelRef(locations.provenances.get(item.coordinate()))); + node.put("owner", item.owner()); + node.put("sourceName", item.kind()); + node.put("markerInterface", item.protocol()); + node.put("markerMethod", item.method()); + node.put("providerPackage", item.providerPackage().coordinate()); + } + ArrayNode roots = document.putArray("rootBindings"); + rootBindings.forEach(roots::add); + document.set("types", structuralTypes(plan, locations)); + + Map map = Json.MAPPER.convertValue(document, new TypeReference>() {}); + DumpSettings settings = + DumpSettings.builder() + .setDefaultFlowStyle(FlowStyle.BLOCK) + .setIndent(2) + .setIndicatorIndent(2) + .setIndentWithIndicator(true) + .setWidth(Integer.MAX_VALUE) + .setSplitLines(false) + .build(); + return "# " + header(model) + "\n" + new Dump(settings).dumpToString(map); + } + + public static Map renderResources(EmissionPlan plan, JsonNode model) { + if (plan.target().emitterSha256().isEmpty()) { + throw fail( + "package-config", + "RCJ1006", + plan.target().packageKey(), + "emitterSha256 is required when emitting package resources", + "/emitterSha256"); + } + if (!model.path("metadata").path("semanticSha256").asText().equals(plan.modelDigest()) + || !model.path("rootExtension").path("id").asText().equals(plan.target().rootExtension())) { + throw fail("model", "RCJ1003", "model", "emission plan does not match supplied model"); + } + JsonNode root = model.path("rootExtension"); + List roots = new ArrayList<>(); + for (JsonNode item : Json.list(model.path("extensions"))) { + if (item.path("id").asText().equals(root.path("id").asText())) { + roots.add(item); + } + } + if (roots.size() != 1 + || !roots.get(0).path("semanticSha256").asText().equals(root.path("semanticSha256").asText())) { + throw fail( + "model", + "RCJ1003", + root.path("id").asText(), + "root extension digest does not match model extension closure"); + } + Map files = new TreeMap<>(); + files.put("pom.xml", pom(plan, model)); + files.put(MANIFEST_PATH, manifest(plan, model)); + return files; + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Model.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Model.java new file mode 100644 index 0000000..b24af6d --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Model.java @@ -0,0 +1,99 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.PathType; +import com.networknt.schema.SchemaValidatorsConfig; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +public final class Model { + public static final String API = "runtimeconditions.io/binding-model/v1alpha1"; + public static final String KIND = "RuntimeConditionsBindingModel"; + private static final String SCHEMA_RESOURCE = "/model/runtimeconditions.binding-model.schema.yaml"; + + private static JsonSchema validator; + + private Model() {} + + static String schemaText() { + String override = System.getenv("RC_BINDING_MODEL_SCHEMA"); + try { + if (override != null && !override.isEmpty()) { + return Files.readString(Path.of(override), StandardCharsets.UTF_8); + } + try (InputStream stream = Model.class.getResourceAsStream(SCHEMA_RESOURCE)) { + if (stream == null) { + throw fail("model", "RCJ1003", "model", "model schema is unavailable"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException error) { + throw fail("model", "RCJ1003", "model", "model schema is unavailable"); + } + } + + public static JsonSchema createValidator(String schemaYaml) { + Object loaded = new Load(LoadSettings.builder().build()).loadFromString(schemaYaml); + JsonNode schema = Json.MAPPER.valueToTree(loaded); + SchemaValidatorsConfig config = + SchemaValidatorsConfig.builder() + .pathType(PathType.JSON_POINTER) + .formatAssertionsEnabled(true) + .build(); + return JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schema, config); + } + + public static synchronized JsonSchema validator() { + if (validator == null) { + validator = createValidator(schemaText()); + } + return validator; + } + + public static List validate(JsonSchema schema, JsonNode model) { + Set messages = schema.validate(model); + List ordered = new ArrayList<>(messages); + ordered.sort( + Comparator.comparing((ValidationMessage item) -> item.getInstanceLocation().toString()) + .thenComparing(ValidationMessage::getMessage)); + return ordered; + } + + public static ObjectNode load(Path path) { + ObjectNode model = YamlIo.load(path, true); + if (!API.equals(model.path("apiVersion").asText(null))) { + throw fail("model", "RCJ1004", "model", "unsupported binding-model apiVersion", "/apiVersion"); + } + if (!KIND.equals(model.path("kind").asText(null))) { + throw fail("model", "RCJ1005", "model", "unsupported binding-model kind", "/kind"); + } + List errors = validate(validator(), model); + if (!errors.isEmpty()) { + ValidationMessage error = errors.get(0); + throw fail( + "model", + "RCJ1003", + "model", + error.getMessage(), + error.getInstanceLocation().toString()); + } + return model; + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Naming.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Naming.java new file mode 100644 index 0000000..64ec564 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Naming.java @@ -0,0 +1,334 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +public final class Naming { + public enum Style { + PASCAL, + CAMEL, + CONSTANT, + EXACT + } + + public record Symbol( + String key, + String coordinate, + String source, + Style style, + String namespace, + List parents, + boolean fixed) { + public Symbol(String key, String coordinate, String source, Style style) { + this(key, coordinate, source, style, null, List.of(), false); + } + + public static Symbol fixed(String key, String coordinate, String source, Style style) { + return new Symbol(key, coordinate, source, style, null, List.of(), true); + } + + public static Symbol fixed( + String key, String coordinate, String source, Style style, String namespace) { + return new Symbol(key, coordinate, source, style, namespace, List.of(), true); + } + + public static Symbol scoped( + String key, String coordinate, String source, Style style, List parents) { + return new Symbol(key, coordinate, source, style, null, List.copyOf(parents), false); + } + + public static Symbol inNamespace( + String key, String coordinate, String source, Style style, String namespace) { + return new Symbol(key, coordinate, source, style, namespace, List.of(), false); + } + + String space() { + return namespace == null ? "module" : namespace; + } + } + + private static final Set KEYWORDS = + Set.of( + "_", "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", + "class", "const", "continue", "default", "do", "double", "else", "enum", + "extends", "false", "final", "finally", "float", "for", "goto", "if", + "implements", "import", "instanceof", "int", "interface", "long", "native", + "new", "null", "package", "permits", "private", "protected", "public", + "record", "return", "sealed", "short", "static", "strictfp", "super", + "switch", "synchronized", "this", "throw", "throws", "transient", "true", + "try", "var", "void", "volatile", "while", "yield", "clone", "finalize", + "getClass", "hashCode", "notify", "notifyAll", "toString", "wait"); + + private static final List HELPERS = + List.of( + "Object", "String", "Long", "Double", "Boolean", "Void", "Objects", "List", "Map", + "Override", "Record", "Enum", "Number", "Integer", "Class", "Exception", "Error", + "Throwable", "Comparable", "Iterable", "Character", "Byte", "Short", "Float", + "System", "Math", "Runnable", "Thread", "Deprecated", "SuppressWarnings", + "FunctionalInterface", "SafeVarargs", "CharSequence", "StringBuilder", "Cloneable", + "AutoCloseable", "Iterator", "Collection", "Set", "Optional", "Arrays", + "Collections"); + + private static final Map RESERVED = new LinkedHashMap<>(); + + static { + for (String name : HELPERS) { + RESERVED.put(name, "java-global:" + name); + } + } + + private Naming() {} + + static boolean isReservedWord(String name) { + return KEYWORDS.contains(name); + } + + private static boolean alphanumeric(int point) { + if (Character.isLetter(point)) { + return true; + } + int type = Character.getType(point); + return type == Character.DECIMAL_DIGIT_NUMBER + || type == Character.LETTER_NUMBER + || type == Character.OTHER_NUMBER; + } + + private static boolean upper(int point) { + return Character.getType(point) == Character.UPPERCASE_LETTER; + } + + private static boolean lower(int point) { + return Character.getType(point) == Character.LOWERCASE_LETTER; + } + + private static boolean digit(int point) { + return Character.getType(point) == Character.DECIMAL_DIGIT_NUMBER; + } + + static List tokens(String input) { + int[] points = Normalizer.normalize(input, Normalizer.Form.NFKC).codePoints().toArray(); + List chunks = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + for (int index = 0; index < points.length; index += 1) { + int point = points[index]; + if (!alphanumeric(point)) { + if (current.length() > 0) { + chunks.add(current.toString()); + current.setLength(0); + } + continue; + } + Integer previous = index > 0 ? points[index - 1] : null; + Integer following = index + 1 < points.length ? points[index + 1] : null; + boolean boundary = + current.length() > 0 + && ((upper(point) && previous != null && (lower(previous) || digit(previous))) + || (upper(point) + && previous != null + && upper(previous) + && following != null + && lower(following))); + if (boundary) { + chunks.add(current.toString()); + current.setLength(0); + } + current.appendCodePoint(point); + } + if (current.length() > 0) { + chunks.add(current.toString()); + } + return chunks; + } + + private static String capitalize(String part) { + int first = part.codePointAt(0); + String head = new String(Character.toChars(first)); + String rest = part.substring(head.length()); + return head.toUpperCase(Locale.ROOT) + rest.toLowerCase(Locale.ROOT); + } + + static boolean identifier(String name) { + if (name.isEmpty()) { + return false; + } + int first = name.codePointAt(0); + if (!Character.isJavaIdentifierStart(first) || Character.isIdentifierIgnorable(first)) { + return false; + } + for (int index = Character.charCount(first); index < name.length(); ) { + int point = name.codePointAt(index); + if (!Character.isJavaIdentifierPart(point) || Character.isIdentifierIgnorable(point)) { + return false; + } + index += Character.charCount(point); + } + return !name.equals("_"); + } + + private static String quote(String value) { + try { + return Json.MAPPER.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new IllegalStateException(error); + } + } + + public static String javaName(String source, Style style, String coordinate) { + if (style == Style.EXACT) { + if (!identifier(source)) { + throw fail( + "java-naming", + "RCJ2000", + coordinate, + "invalid Java identifier " + quote(source)); + } + return source; + } + List parts = tokens(source); + if (parts.isEmpty()) { + throw fail( + "java-naming", + "RCJ2000", + coordinate, + "cannot derive Java identifier from '" + source + "'"); + } + String name; + if (style == Style.PASCAL) { + name = String.join("", parts.stream().map(Naming::capitalize).toList()); + } else if (style == Style.CONSTANT) { + name = String.join("_", parts.stream().map(part -> part.toUpperCase(Locale.ROOT)).toList()); + } else { + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < parts.size(); index += 1) { + builder.append(index == 0 ? parts.get(0).toLowerCase(Locale.ROOT) : capitalize(parts.get(index))); + } + name = builder.toString(); + } + if (Character.getType(name.codePointAt(0)) == Character.DECIMAL_DIGIT_NUMBER) { + name = (style == Style.CAMEL ? "x" : "X") + name; + } + if (KEYWORDS.contains(name)) { + name += "_"; + } + if (!identifier(name)) { + throw fail( + "java-naming", + "RCJ2000", + coordinate, + "invalid Java identifier " + quote(name)); + } + return name; + } + + private static int compare(String left, String right) { + return Integer.signum(left.compareTo(right)); + } + + public static Map allocateSymbols(String packageCoordinate, List symbols) { + List ordered = new ArrayList<>(symbols); + ordered.sort( + Comparator.comparing(Symbol::space) + .thenComparing(Symbol::coordinate) + .thenComparing(Symbol::key)); + if (ordered.stream().map(Symbol::key).distinct().count() != ordered.size()) { + throw fail("java-naming", "RCJ2001", packageCoordinate, "duplicate symbol request key"); + } + Map levels = new LinkedHashMap<>(); + for (Symbol item : ordered) { + levels.put(item.key(), 0); + } + int maxDepth = 0; + for (Symbol item : ordered) { + maxDepth = Math.max(maxDepth, item.parents().size()); + } + int rounds = ordered.size() * (maxDepth + 1) + 1; + for (int round = 0; round < rounds; round += 1) { + Map, List> groups = + new TreeMap<>( + (a, b) -> { + int space = compare(a.get(0), b.get(0)); + return space != 0 ? space : compare(a.get(1), b.get(1)); + }); + for (Symbol item : ordered) { + groups.computeIfAbsent(List.of(item.space(), candidate(item, levels)), unused -> new ArrayList<>()) + .add(item); + } + List>> collisions = new ArrayList<>(); + for (Map.Entry, List> entry : groups.entrySet()) { + String space = entry.getKey().get(0); + String name = entry.getKey().get(1); + List group = entry.getValue(); + String reserved = space.equals("module") ? RESERVED.get(name) : null; + if (group.size() > 1 || reserved != null) { + List pair = new ArrayList<>(group); + pair.sort(Comparator.comparing(Symbol::coordinate)); + if (reserved != null) { + throw fail( + "java-naming", + "RCJ2001", + packageCoordinate, + "symbol '" + name + "' conflicts: " + group.get(0).coordinate() + " and " + reserved); + } + if (group.stream().anyMatch(Symbol::fixed)) { + throw fail( + "java-naming", + "RCJ2001", + packageCoordinate, + "fixed symbol '" + + name + + "' conflicts: " + + pair.get(0).coordinate() + + " and " + + pair.get(1).coordinate()); + } + collisions.add(Map.entry(name, group)); + } + } + if (collisions.isEmpty()) { + Map result = new LinkedHashMap<>(); + for (Symbol item : ordered) { + result.put(item.key(), candidate(item, levels)); + } + return result; + } + for (Map.Entry> collision : collisions) { + for (Symbol item : collision.getValue()) { + if (levels.get(item.key()) == item.parents().size()) { + List pair = new ArrayList<>(collision.getValue()); + pair.sort(Comparator.comparing(Symbol::coordinate)); + throw fail( + "java-naming", + "RCJ2001", + packageCoordinate, + "symbol '" + + collision.getKey() + + "' conflicts after full path: " + + pair.get(0).coordinate() + + " and " + + pair.get(1).coordinate()); + } + levels.put(item.key(), levels.get(item.key()) + 1); + } + } + } + throw new IllegalStateException("symbol allocation did not converge"); + } + + private static String candidate(Symbol item, Map levels) { + List parents = new ArrayList<>(item.parents().subList(0, levels.get(item.key()))); + Collections.reverse(parents); + parents.add(item.source()); + return javaName(String.join("_", parents), item.style(), item.coordinate()); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java new file mode 100644 index 0000000..fc948ca --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java @@ -0,0 +1,725 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import io.runtimeconditions.bindings.emitter.Naming.Style; +import io.runtimeconditions.bindings.emitter.Naming.Symbol; +import io.runtimeconditions.bindings.emitter.Target.Dependency; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class Plan { + public record TypeExpr(String kind, List arguments, String symbolKey) {} + + public record FieldPlan( + String coordinate, + String sourceName, + String name, + TypeExpr typeExpr, + String annotation, + boolean required) {} + + public record ProviderPackage(String coordinate, String javaPackage) {} + + public record MarkerReference( + String owner, String protocol, String method, ProviderPackage providerPackage) {} + + public enum Kind { + OBJECT, + ALIAS, + ENUM + } + + public record Member(String name, String value) {} + + public record TypePlan( + String coordinate, + String name, + Kind kind, + List fields, + TypeExpr expression, + List members, + MarkerReference marker, + boolean isDeclarationField, + String schemaCoordinate, + String sourceName) {} + + public record DeclarationPlan( + String coordinate, String kind, String function, String protocol, String method) {} + + public record ImportedDeclarationPlan( + String coordinate, + String owner, + String kind, + String function, + String protocol, + String method, + ProviderPackage providerPackage) {} + + public record EmissionPlan( + PackageTarget target, + Map names, + List declarations, + List importedDeclarations, + List types, + boolean usesAny, + String modelDigest) {} + + public static final String DECLARATION_CLASS = "Declaration"; + public static final String FUNCTIONS_CLASS = "Bindings"; + + private Plan() {} + + private static TypeExpr expr(String kind) { + return new TypeExpr(kind, List.of(), ""); + } + + private static TypeExpr expr(String kind, List arguments) { + return new TypeExpr(kind, arguments, ""); + } + + private static TypeExpr named(String kind, String key) { + return new TypeExpr(kind, List.of(), key); + } + + /** + * Replaces a reference to a definition that has no type of its own (a scalar, {@code any}, or another + * reference) with that definition's expression, following chains. Objects, enums, and collections keep + * their named reference. + */ + static TypeExpr resolve(TypeExpr expression, Map definitions, List active) { + if (expression.kind().equals("ref")) { + String key = expression.symbolKey(); + TypeExpr definition = definitions.get(key); + if (definition == null) { + throw fail("model", "RCJ1012", key, "reference to a definition that was not planned"); + } + if (definition.symbolKey().equals(key)) { + return expression; + } + if (active.contains(key)) { + throw fail("model", "RCJ1012", key, "definition refers to itself without a structure"); + } + List next = new ArrayList<>(active); + next.add(key); + return resolve(definition, definitions, next); + } + if (expression.arguments().isEmpty()) { + return expression; + } + List arguments = new ArrayList<>(); + for (TypeExpr argument : expression.arguments()) { + arguments.add(resolve(argument, definitions, active)); + } + return new TypeExpr(expression.kind(), List.copyOf(arguments), expression.symbolKey()); + } + + public static String annotate(TypeExpr expression, Map names) { + return switch (expression.kind()) { + case "string" -> "String"; + case "boolean" -> "Boolean"; + case "integer" -> "Long"; + case "number" -> "Double"; + case "null" -> "Void"; + case "any" -> "Object"; + case "object", "enum", "ref", "alias" -> names.get(expression.symbolKey()); + case "array" -> "List<" + annotate(expression.arguments().get(0), names) + ">"; + case "map" -> "Map"; + default -> throw new IllegalStateException("unplanned type kind " + expression.kind()); + }; + } + + public static String markerMethod(String owner, String coordinate) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(owner.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(coordinate.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte value : digest.digest()) { + hex.append(String.format("%02x", value)); + } + return "rcMarker_" + hex.substring(0, 16); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + private static Set directDependencies(JsonNode model) { + String root = model.path("rootExtension").path("id").asText(); + for (JsonNode extension : Json.list(model.path("extensions"))) { + if (extension.path("id").asText().equals(root)) { + Set result = new LinkedHashSet<>(); + for (JsonNode item : Json.list(extension.path("dependencies"))) { + result.add(item.asText()); + } + return result; + } + } + throw fail("model", "RCJ1011", root, "root extension is absent from resolved extension closure"); + } + + private static ProviderPackage providerPackage(PackageTarget target, String owner) { + for (Dependency dependency : target.dependencies()) { + if (dependency.extension().equals(owner)) { + return new ProviderPackage(dependency.coordinate(), dependency.javaPackage()); + } + } + throw fail( + "package-config", + "RCJ1011", + owner, + "imported declaration owner has no entry in the target dependencies", + "/dependencies"); + } + + static String location(JsonNode item) { + JsonNode provenance = item.path("provenance"); + return provenance.path("coordinate").asText() + (provenance.has("jsonPointer") ? provenance.path("jsonPointer").asText() : ""); + } + + private record RawField( + String key, String coordinate, String sourceName, TypeExpr typeExpr, boolean required) {} + + private record RawType( + Kind kind, + JsonNode shape, + TypeExpr expression, + List fields, + List members, + MarkerReference marker) {} + + private record ImportedRaw( + String coordinate, + String owner, + String kind, + String functionKey, + String protocolKey, + String method, + ProviderPackage providerPackage) {} + + private static final class Builder { + final JsonNode model; + final PackageTarget target; + final String root; + final List symbols = new ArrayList<>(); + final Map rawTypes = new TreeMap<>(); + final Map typeSources = new LinkedHashMap<>(); + final Map declarationFieldSchemas = new LinkedHashMap<>(); + boolean anyUsed; + final Map, String> definitionKeys = new LinkedHashMap<>(); + final Set referencedDefinitions = new LinkedHashSet<>(); + final Map definitionExpressions = new LinkedHashMap<>(); + + Builder(JsonNode model, PackageTarget target) { + this.model = model; + this.target = target; + this.root = model.path("rootExtension").path("id").asText(); + } + + TypeExpr registerShape( + JsonNode shape, + String key, + String source, + List parents, + MarkerReference marker, + String exactSource) { + String coordinate = location(shape); + String kind = shape.path("kind").asText(); + String sourceName = exactSource != null ? exactSource : source; + if (kind.equals("any")) { + anyUsed = true; + return expr("any"); + } + if (kind.equals("scalar")) { + List values = Json.list(shape.path("values")); + if (!values.isEmpty() && shape.path("scalar").asText().equals("string")) { + for (JsonNode entry : values) { + if (!entry.path("value").isTextual()) { + throw fail("model", "RCJ1012", coordinate, "string domain contains non-string value"); + } + } + symbols.add(Symbol.scoped(key, coordinate, source, Style.PASCAL, parents)); + List members = new ArrayList<>(); + for (int index = 0; index < values.size(); index += 1) { + String value = values.get(index).path("value").asText(); + String memberKey = "member:" + key + ":" + index; + symbols.add( + Symbol.inNamespace( + memberKey, + coordinate + "#value:" + value, + value, + Style.CONSTANT, + "enum:" + key)); + members.add(new String[] {memberKey, value}); + } + rawTypes.put(key, new RawType(Kind.ENUM, shape, null, List.of(), members, marker)); + typeSources.put(key, sourceName); + return named("enum", key); + } + return expr(shape.path("scalar").asText()); + } + if (kind.equals("ref")) { + String reference = shape.path("ref").asText().replaceFirst("^#", ""); + String referenceKey = + definitionKeys.get(List.of(shape.path("provenance").path("coordinate").asText(), reference)); + if (referenceKey == null) { + throw fail("model", "RCJ1012", coordinate, "unresolved reference '" + shape.path("ref").asText() + "'"); + } + referencedDefinitions.add(referenceKey); + return named("ref", referenceKey); + } + if (kind.equals("object")) { + symbols.add(Symbol.scoped(key, coordinate, source, Style.PASCAL, parents)); + if (marker != null) { + symbols.add( + Symbol.fixed( + "method:" + key, + marker.owner(), + marker.method(), + Style.EXACT, + "fields:" + key)); + } + List rawFields = new ArrayList<>(); + Set required = new TreeSet<>(); + for (JsonNode name : Json.list(shape.path("required"))) { + required.add(name.asText()); + } + List properties = Json.list(shape.path("properties")); + Set propertyNames = new TreeSet<>(); + for (JsonNode property : properties) { + propertyNames.add(property.path("name").asText()); + } + List missing = new ArrayList<>(); + for (String name : required) { + if (!propertyNames.contains(name)) { + missing.add(name); + } + } + if (!missing.isEmpty()) { + throw fail( + "model", + "RCJ1012", + coordinate, + "required properties are missing: " + jsonList(missing)); + } + if (propertyNames.size() != properties.size()) { + throw fail("model", "RCJ1012", coordinate, "duplicate object property"); + } + for (JsonNode property : properties) { + String name = property.path("name").asText(); + String propertyCoordinate = location(property); + String fieldKey = "field:" + key + ":" + name; + symbols.add(Symbol.inNamespace(fieldKey, propertyCoordinate, name, Style.CAMEL, "fields:" + key)); + List childParents = new ArrayList<>(); + childParents.add(source); + childParents.addAll(parents); + TypeExpr expression = + registerShape(property.path("shape"), "type:" + key + ":" + name, name, childParents, null, null); + rawFields.add( + new RawField( + fieldKey, + propertyCoordinate, + name, + expression, + required.contains(name) || property.path("required").asBoolean(false))); + } + rawTypes.put(key, new RawType(Kind.OBJECT, shape, null, rawFields, List.of(), marker)); + typeSources.put(key, sourceName); + return named("object", key); + } + if (kind.equals("array") || kind.equals("map") || kind.equals("union")) { + List children; + if (kind.equals("union")) { + children = Json.list(shape.path("variants")); + if (children.isEmpty()) { + throw fail("model", "RCJ1012", coordinate, "union has no variants"); + } + } else { + children = List.of(shape.path(kind.equals("array") ? "items" : "mapValues")); + } + List expressions = new ArrayList<>(); + for (int index = 0; index < children.size(); index += 1) { + String suffix = + kind.equals("array") ? "Item" : kind.equals("map") ? "Value" : "Variant" + (index + 1); + String base = kind.equals("array") ? source.replaceFirst(" Value$", "") : source; + expressions.add( + registerShape( + children.get(index), + "type:" + key + ":" + suffix, + base + " " + suffix, + parents, + null, + sourceName)); + } + symbols.add(Symbol.scoped(key, coordinate, source, Style.PASCAL, parents)); + rawTypes.put(key, new RawType(Kind.ALIAS, shape, expr(kind, expressions), List.of(), List.of(), marker)); + typeSources.put(key, sourceName); + return named("alias", key); + } + throw fail("model", "RCJ1010", coordinate, "unknown structural node '" + kind + "'"); + } + } + + private static String jsonList(List values) { + try { + return Json.MAPPER.writeValueAsString(values); + } catch (com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException(error); + } + } + + private static List list(String... values) { + return List.of(values); + } + + private static List pendingReferenced(Set referenced, Map pending) { + List result = new ArrayList<>(); + for (String item : referenced) { + if (pending.containsKey(item)) { + result.add(item); + } + } + result.sort(Comparator.naturalOrder()); + return result; + } + + public static EmissionPlan build(JsonNode model, PackageTarget target) { + String root = model.path("rootExtension").path("id").asText(); + String pkg = target.packageKey(); + if (!root.equals(target.rootExtension())) { + throw fail( + "package-config", + "RCJ1009", + pkg, + "target root '" + target.rootExtension() + "' differs from model root '" + root + "'", + "/rootExtension"); + } + Set direct = directDependencies(model); + Set configured = new TreeSet<>(); + for (Dependency dependency : target.dependencies()) { + configured.add(dependency.extension()); + } + Set owners = new TreeSet<>(); + for (JsonNode item : Json.list(model.path("vocabulary").path("importedDeclarations"))) { + owners.add(item.path("owner").asText()); + } + Set allowed = new TreeSet<>(direct); + allowed.addAll(owners); + if (!configured.containsAll(direct) || !allowed.containsAll(configured)) { + throw fail( + "package-config", + "RCJ1009", + pkg, + "dependencies must be the direct dependencies plus the owners of imported declarations: model=" + + jsonList(new ArrayList<>(allowed)) + + ", target=" + + jsonList(new ArrayList<>(configured)), + "/dependencies"); + } + Set extensionIds = new HashSet<>(); + for (JsonNode item : Json.list(model.path("extensions"))) { + extensionIds.add(item.path("id").asText()); + } + if (!extensionIds.containsAll(configured)) { + throw fail("model", "RCJ1011", root, "target dependency is absent from resolved closure"); + } + + Builder builder = new Builder(model, target); + JsonNode vocabulary = model.path("vocabulary"); + List declarations = new ArrayList<>(); + List imported = new ArrayList<>(); + Map importedKinds = new LinkedHashMap<>(); + Map ownedKinds = new LinkedHashMap<>(); + + for (JsonNode item : Json.list(vocabulary.path("ownedDeclarations"))) { + String coordinate = item.path("coordinate").asText(); + String kind = item.path("kind").asText(); + if (!item.path("owner").asText().equals(root)) { + throw fail("model", "RCJ1011", coordinate, "owned declaration has a foreign owner"); + } + if (ownedKinds.containsKey(kind)) { + throw fail("model", "RCJ1011", coordinate, "duplicate owned kind '" + kind + "'"); + } + ownedKinds.put(kind, coordinate); + builder.symbols.add(Symbol.fixed("function:" + coordinate, coordinate, kind, Style.CAMEL)); + builder.symbols.add(Symbol.fixed("protocol:" + coordinate, coordinate, kind + " Field", Style.PASCAL)); + declarations.add(new String[] {coordinate, "function:" + coordinate, "protocol:" + coordinate}); + } + if (!declarations.isEmpty()) { + builder.symbols.add(Symbol.fixed("builtin:Declaration", "java:Declaration", DECLARATION_CLASS, Style.PASCAL)); + builder.symbols.add(Symbol.fixed("builtin:Bindings", "java:Bindings", FUNCTIONS_CLASS, Style.PASCAL)); + } + for (JsonNode item : Json.list(vocabulary.path("importedDeclarations"))) { + String owner = item.path("owner").asText(); + String coordinate = item.path("coordinate").asText(); + if (owner.equals(root) || !extensionIds.contains(owner)) { + throw fail("model", "RCJ1011", coordinate, "invalid imported declaration owner"); + } + String kind = item.path("kind").asText(); + String functionKey = "import:function:" + coordinate; + String protocolKey = "import:protocol:" + coordinate; + builder.symbols.add(Symbol.fixed(functionKey, coordinate, kind, Style.CAMEL)); + builder.symbols.add(Symbol.fixed(protocolKey, coordinate, kind + " Field", Style.PASCAL)); + ImportedRaw plan = + new ImportedRaw( + coordinate, + owner, + kind, + functionKey, + protocolKey, + markerMethod(owner, coordinate), + providerPackage(target, owner)); + imported.add(plan); + if (importedKinds.containsKey(kind)) { + throw fail("model", "RCJ1011", coordinate, "duplicate imported kind"); + } + importedKinds.put(kind, plan); + } + + Map pendingDefinitions = new TreeMap<>(); + Set> ownedFieldRoots = new HashSet<>(); + for (JsonNode item : Json.list(vocabulary.path("conditionFields"))) { + if (item.path("owner").asText().equals(root)) { + ownedFieldRoots.add( + list( + item.path("kind").asText(), + item.path("interfaceType").asText(""), + item.path("segments").path(0).path("name").asText())); + } + } + Map, JsonNode> scopeProjections = new LinkedHashMap<>(); + for (JsonNode item : Json.list(model.path("scopes"))) { + scopeProjections.put( + list(item.path("kind").asText(), item.path("interfaceType").asText("")), + item.path("projection")); + } + for (JsonNode schema : Json.list(model.path("schemas"))) { + for (JsonNode definition : Json.list(schema.path("definitions"))) { + builder.definitionKeys.put( + list(schema.path("coordinate").asText(), definition.path("jsonPointer").asText()), + "type:" + schema.path("coordinate").asText() + ":" + definition.path("jsonPointer").asText()); + } + } + + for (JsonNode schema : Json.list(model.path("schemas"))) { + if (!schema.path("owner").asText().equals(root)) { + continue; + } + String schemaCoordinate = schema.path("coordinate").asText(); + String kind = schema.has("kind") ? schema.path("kind").asText() : schema.path("id").asText(); + String interfaceType = schema.has("interfaceType") ? schema.path("interfaceType").asText() : null; + List scope = new ArrayList<>(); + if (interfaceType != null && !interfaceType.isEmpty()) { + scope.add(interfaceType); + } + if (!kind.isEmpty()) { + scope.add(kind); + } + MarkerReference marker = null; + if (ownedKinds.containsKey(kind)) { + String declarationCoordinate = ownedKinds.get(kind); + marker = + new MarkerReference( + root, + "protocol:" + declarationCoordinate, + markerMethod(root, declarationCoordinate), + null); + } else if (importedKinds.containsKey(kind)) { + ImportedRaw item = importedKinds.get(kind); + marker = new MarkerReference(item.owner(), item.protocolKey(), item.method(), item.providerPackage()); + } + JsonNode projection = schema.path("projection"); + if (!projection.path("kind").asText().equals("object")) { + throw fail("model", "RCJ1012", schemaCoordinate, "root condition projection must be an object"); + } + List projectionProperties = Json.list(projection.path("properties")); + for (JsonNode property : projectionProperties) { + String propertyName = property.path("name").asText(); + boolean isInterface = propertyName.equals("interface") && interfaceType != null && !interfaceType.isEmpty(); + String fieldSource = isInterface ? interfaceType : propertyName; + List fieldParents = new ArrayList<>(); + if (isInterface) { + fieldParents.add(propertyName); + } + fieldParents.addAll(scope); + String key = "type:field:" + schemaCoordinate + ":" + propertyName; + builder.declarationFieldSchemas.put(key, schemaCoordinate); + JsonNode shape = property.path("shape"); + if (ownedFieldRoots.contains(list(kind, interfaceType == null ? "" : interfaceType, propertyName))) { + JsonNode scopeProjection = + scopeProjections.getOrDefault( + list(kind, interfaceType == null ? "" : interfaceType), + Json.MAPPER.createObjectNode()); + JsonNode projected = null; + for (JsonNode candidate : Json.list(scopeProjection.path("properties"))) { + if (candidate.path("name").asText().equals(propertyName)) { + projected = candidate; + break; + } + } + if (projected == null) { + throw fail( + "model", + "RCJ1012", + schemaCoordinate, + "condition field '" + propertyName + "' is absent from its scope projection"); + } + shape = projected.path("shape"); + } + if (shape.path("kind").asText().equals("object")) { + builder.registerShape(shape, key, fieldSource, fieldParents, marker, null); + continue; + } + if (marker == null) { + throw fail( + "model", + "RCJ1011", + schemaCoordinate, + "field '" + propertyName + "' has no declaration contract"); + } + String coordinate = location(property); + builder.symbols.add(Symbol.scoped(key, coordinate, fieldSource, Style.PASCAL, fieldParents)); + builder.symbols.add( + Symbol.fixed("method:" + key, marker.owner(), marker.method(), Style.EXACT, "fields:" + key)); + String valueKey = "field:" + key + ":value"; + builder.symbols.add(Symbol.fixed(valueKey, coordinate, "value", Style.CAMEL, "fields:" + key)); + TypeExpr fieldExpression = + builder.registerShape(shape, "type:" + key + ":value", fieldSource + " Value", fieldParents, null, null); + builder.rawTypes.put( + key, + new RawType( + Kind.OBJECT, + shape, + null, + List.of(new RawField(valueKey, coordinate, "value", fieldExpression, true)), + List.of(), + marker)); + builder.typeSources.put(key, fieldSource); + } + for (JsonNode definition : Json.list(schema.path("definitions"))) { + String definitionKey = + builder.definitionKeys.get(list(schemaCoordinate, definition.path("jsonPointer").asText())); + pendingDefinitions.put(definitionKey, new Object[] {definition, scope, schemaCoordinate}); + if (projectionProperties.isEmpty()) { + builder.referencedDefinitions.add(definitionKey); + } + } + } + + // Planning a definition can reference more definitions. Each pass removes the definitions it + // plans from pendingDefinitions, so the set only shrinks and the loop ends when none is left. + List required = pendingReferenced(builder.referencedDefinitions, pendingDefinitions); + while (!required.isEmpty()) { + for (String definitionKey : required) { + Object[] pending = pendingDefinitions.remove(definitionKey); + JsonNode definition = (JsonNode) pending[0]; + @SuppressWarnings("unchecked") + List scope = (List) pending[1]; + builder.definitionExpressions.put( + definitionKey, + builder.registerShape( + definition.path("shape"), definitionKey, definition.path("name").asText(), scope, null, null)); + } + required = pendingReferenced(builder.referencedDefinitions, pendingDefinitions); + } + + Map names = Naming.allocateSymbols(pkg, builder.symbols); + List types = new ArrayList<>(); + for (Map.Entry entry : builder.rawTypes.entrySet()) { + String key = entry.getKey(); + RawType raw = entry.getValue(); + List fields = new ArrayList<>(); + for (RawField field : raw.fields()) { + TypeExpr resolved = resolve(field.typeExpr(), builder.definitionExpressions, new ArrayList<>()); + fields.add( + new FieldPlan( + field.coordinate(), + field.sourceName(), + names.get(field.key()), + resolved, + annotate(resolved, names), + field.required())); + } + fields.sort( + Comparator.comparing((FieldPlan field) -> !field.required()) + .thenComparing(FieldPlan::name) + .thenComparing(FieldPlan::coordinate)); + List members = new ArrayList<>(); + for (String[] member : raw.members()) { + members.add(new Member(names.get(member[0]), member[1])); + } + MarkerReference marker = + raw.marker() == null + ? null + : new MarkerReference( + raw.marker().owner(), + names.get(raw.marker().protocol()), + raw.marker().method(), + raw.marker().providerPackage()); + types.add( + new TypePlan( + location(raw.shape()), + names.get(key), + raw.kind(), + List.copyOf(fields), + raw.expression() == null + ? null + : resolve(raw.expression(), builder.definitionExpressions, new ArrayList<>()), + List.copyOf(members), + marker, + raw.marker() != null && raw.kind() == Kind.OBJECT, + builder.declarationFieldSchemas.getOrDefault(key, ""), + builder.typeSources.get(key))); + } + declarations.sort(Comparator.comparing((String[] item) -> item[0])); + List resultDeclarations = new ArrayList<>(); + for (String[] item : declarations) { + String declarationKind = ""; + for (JsonNode owned : Json.list(vocabulary.path("ownedDeclarations"))) { + if (owned.path("coordinate").asText().equals(item[0])) { + declarationKind = owned.path("kind").asText(); + break; + } + } + resultDeclarations.add( + new DeclarationPlan( + item[0], declarationKind, names.get(item[1]), names.get(item[2]), markerMethod(root, item[0]))); + } + List sortedImported = new ArrayList<>(imported); + sortedImported.sort(Comparator.comparing(ImportedRaw::coordinate)); + List resultImported = new ArrayList<>(); + for (ImportedRaw item : sortedImported) { + resultImported.add( + new ImportedDeclarationPlan( + item.coordinate(), + item.owner(), + item.kind(), + names.get(item.functionKey()), + names.get(item.protocolKey()), + item.method(), + item.providerPackage())); + } + return new EmissionPlan( + target, + names, + List.copyOf(resultDeclarations), + List.copyOf(resultImported), + List.copyOf(types), + builder.anyUsed, + model.path("metadata").path("semanticSha256").asText()); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Source.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Source.java new file mode 100644 index 0000000..2546709 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Source.java @@ -0,0 +1,330 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import io.runtimeconditions.bindings.emitter.Plan.DeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Plan.FieldPlan; +import io.runtimeconditions.bindings.emitter.Plan.Kind; +import io.runtimeconditions.bindings.emitter.Plan.TypePlan; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; + +public final class Source { + private Source() {} + + public static String sourceRoot(EmissionPlan plan) { + return "src/main/java/" + plan.target().javaPackage().replace('.', '/') + "/"; + } + + public static String sourcePath(EmissionPlan plan, String name) { + return sourceRoot(plan) + name + ".java"; + } + + public static List exportedNames(EmissionPlan plan) { + TreeSet names = new TreeSet<>(); + if (!plan.declarations().isEmpty()) { + names.add(Plan.DECLARATION_CLASS); + names.add(Plan.FUNCTIONS_CLASS); + } + for (DeclarationPlan item : plan.declarations()) { + names.add(item.protocol()); + } + for (TypePlan item : plan.types()) { + names.add(item.name()); + } + return new ArrayList<>(names); + } + + static String literal(String value) { + StringBuilder builder = new StringBuilder("\""); + for (int index = 0; index < value.length(); index += 1) { + char character = value.charAt(index); + switch (character) { + case '"' -> builder.append("\\\""); + case '\\' -> builder.append("\\\\"); + case '\n' -> builder.append("\\n"); + case '\r' -> builder.append("\\r"); + case '\t' -> builder.append("\\t"); + default -> { + if (character < 0x20 || character > 0x7e) { + builder.append(String.format("\\u%04x", (int) character)); + } else { + builder.append(character); + } + } + } + } + return builder.append('"').toString(); + } + + private static boolean nullable(String annotation) { + return annotation.equals("Void") || annotation.equals("Object"); + } + + private static String components(List fields) { + List parts = new ArrayList<>(); + for (FieldPlan field : fields) { + parts.add(field.annotation() + " " + field.name()); + } + return String.join(", ", parts); + } + + private static void requireChecks(List lines, List fields, String indent, String name) { + List checked = new ArrayList<>(); + for (FieldPlan field : fields) { + if (field.required() && !nullable(field.annotation())) { + checked.add(field); + } + } + if (checked.isEmpty()) { + return; + } + lines.add(""); + lines.add(indent + " public " + name + " {"); + for (FieldPlan field : checked) { + lines.add(indent + " Objects.requireNonNull(" + field.name() + ", " + literal(field.name()) + ");"); + } + lines.add(indent + " }"); + } + + private static List recordBody( + String name, List fields, String implement, String markerMethod, String indent) { + List lines = new ArrayList<>(); + String modifier = indent.isEmpty() ? "public " : ""; + String declaration = indent + modifier + "record " + name + "(" + components(fields) + ")" + implement; + boolean checks = fields.stream().anyMatch(field -> field.required() && !nullable(field.annotation())); + if (!checks && markerMethod == null) { + lines.add(declaration + " {}"); + return lines; + } + lines.add(declaration + " {"); + List body = new ArrayList<>(); + requireChecks(body, fields, indent, name); + if (markerMethod != null) { + body.add(""); + body.add(indent + " @Override"); + body.add(indent + " public void " + markerMethod + "() {}"); + } + if (!body.isEmpty() && body.get(0).isEmpty()) { + body.remove(0); + } + lines.addAll(body); + lines.add(indent + "}"); + return lines; + } + + private static String file(EmissionPlan plan, JsonNode model, String name, List body, List extraImports) { + TreeSet imports = new TreeSet<>(extraImports); + String text = String.join("\n", body); + if (text.contains("List<")) { + imports.add("java.util.List"); + } + if (text.contains("Map<")) { + imports.add("java.util.Map"); + } + if (text.contains("Objects.")) { + imports.add("java.util.Objects"); + } + List lines = new ArrayList<>(); + lines.add("// " + Metadata.header(model)); + lines.add(""); + lines.add("package " + plan.target().javaPackage() + ";"); + lines.add(""); + if (!imports.isEmpty()) { + for (String item : imports) { + lines.add("import " + item + ";"); + } + lines.add(""); + } + lines.addAll(body); + lines.add(""); + return String.join("\n", lines); + } + + private static String implementsClause(TypePlan item, List imports) { + if (item.marker() == null) { + return ""; + } + if (item.marker().providerPackage() != null) { + imports.add(item.marker().providerPackage().javaPackage() + "." + item.marker().protocol()); + } + return " implements " + item.marker().protocol(); + } + + private static String renderType(EmissionPlan plan, JsonNode model, TypePlan item) { + List imports = new ArrayList<>(); + List body = new ArrayList<>(); + if (item.kind() == Kind.ENUM) { + body.add("public enum " + item.name() + " {"); + for (int index = 0; index < item.members().size(); index += 1) { + Plan.Member member = item.members().get(index); + body.add( + " " + + member.name() + + "(" + + literal(member.value()) + + ")" + + (index + 1 < item.members().size() ? "," : ";")); + } + body.add(""); + body.add(" private final String value;"); + body.add(""); + body.add(" " + item.name() + "(String value) {"); + body.add(" this.value = value;"); + body.add(" }"); + body.add(""); + body.add(" public String value() {"); + body.add(" return value;"); + body.add(" }"); + body.add("}"); + } else if (item.kind() == Kind.ALIAS) { + String kind = item.expression().kind(); + if (kind.equals("union")) { + body.add("public sealed interface " + item.name() + " {"); + for (int index = 0; index < item.expression().arguments().size(); index += 1) { + String annotation = Plan.annotate(item.expression().arguments().get(index), plan.names()); + List single = + List.of(new FieldPlan("", "value", "value", item.expression().arguments().get(index), annotation, true)); + if (index > 0) { + body.add(""); + } + String variant = "Variant" + (index + 1); + body.addAll(recordBody(variant, single, " implements " + item.name(), null, " ")); + } + body.add("}"); + } else { + String annotation = Plan.annotate(item.expression(), plan.names()); + List single = List.of(new FieldPlan("", "value", "value", item.expression(), annotation, true)); + body.addAll(recordBody(item.name(), single, "", null, "")); + } + } else { + String implement = implementsClause(item, imports); + body.addAll( + recordBody( + item.name(), + item.fields(), + implement, + item.marker() == null ? null : item.marker().method(), + "")); + } + return file(plan, model, item.name(), body, imports); + } + + private static String renderProtocol(EmissionPlan plan, JsonNode model, DeclarationPlan declaration) { + List body = + List.of( + "public interface " + declaration.protocol() + " {", + " void " + declaration.method() + "();", + "}"); + return file(plan, model, declaration.protocol(), body, List.of()); + } + + private static String renderDeclaration(EmissionPlan plan, JsonNode model) { + List body = + List.of("public final class " + Plan.DECLARATION_CLASS + " {", " Declaration() {}", "}"); + return file(plan, model, Plan.DECLARATION_CLASS, body, List.of()); + } + + private static String renderFunctions(EmissionPlan plan, JsonNode model) { + List body = new ArrayList<>(); + body.add("public final class " + Plan.FUNCTIONS_CLASS + " {"); + body.add(" private " + Plan.FUNCTIONS_CLASS + "() {}"); + List ordered = new ArrayList<>(plan.declarations()); + ordered.sort(Comparator.comparing(DeclarationPlan::function)); + for (DeclarationPlan declaration : ordered) { + body.add(""); + body.add( + " public static " + + Plan.DECLARATION_CLASS + + " " + + declaration.function() + + "(" + + declaration.protocol() + + "... fields) {"); + body.add(" Objects.requireNonNull(fields, \"fields\");"); + body.add(" return new " + Plan.DECLARATION_CLASS + "();"); + body.add(" }"); + } + body.add("}"); + return file(plan, model, Plan.FUNCTIONS_CLASS, body, List.of()); + } + + public static Map renderSources(EmissionPlan plan, JsonNode model) { + if (!model.path("metadata").path("semanticSha256").asText().equals(plan.modelDigest()) + || !model.path("rootExtension").path("id").asText().equals(plan.target().rootExtension())) { + throw fail("model", "RCJ1003", "model", "emission plan does not match supplied model"); + } + Map sources = new TreeMap<>(); + if (!plan.declarations().isEmpty()) { + sources.put(sourcePath(plan, Plan.DECLARATION_CLASS), renderDeclaration(plan, model)); + sources.put(sourcePath(plan, Plan.FUNCTIONS_CLASS), renderFunctions(plan, model)); + } + for (DeclarationPlan declaration : plan.declarations()) { + sources.put(sourcePath(plan, declaration.protocol()), renderProtocol(plan, model, declaration)); + } + for (TypePlan item : plan.types()) { + sources.put(sourcePath(plan, item.name()), renderType(plan, model, item)); + } + return sources; + } + + public static Map renderPackage(EmissionPlan plan, JsonNode model) { + Map files = new TreeMap<>(renderSources(plan, model)); + files.putAll(Metadata.renderResources(plan, model)); + return files; + } + + public static List writeFiles(Map files, EmissionPlan plan, Path output) { + String key = plan.target().packageKey(); + boolean occupied; + try { + occupied = + Files.exists(output, LinkOption.NOFOLLOW_LINKS) + && (Files.isSymbolicLink(output) + || !Files.isDirectory(output, LinkOption.NOFOLLOW_LINKS) + || hasEntries(output)); + } catch (IOException error) { + throw fail("output", "RCJ3002", key, "cannot inspect output directory"); + } + if (occupied) { + throw fail("output", "RCJ3001", key, "output directory must be new or empty and not a symbolic link"); + } + try { + Files.createDirectories(output); + for (Map.Entry entry : new TreeMap<>(files).entrySet()) { + Path destination = output.resolve(entry.getKey()); + Files.createDirectories(destination.getParent()); + Files.writeString(destination, entry.getValue(), StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + } + } catch (FileAlreadyExistsException error) { + throw fail("output", "RCJ3001", key, "output directory must be new or empty and not a symbolic link"); + } catch (IOException error) { + throw fail("output", "RCJ3002", key, "cannot write generated package files"); + } + return new ArrayList<>(new TreeMap<>(files).keySet()); + } + + private static boolean hasEntries(Path directory) throws IOException { + try (Stream entries = Files.list(directory)) { + return entries.findAny().isPresent(); + } + } + + public static List emitPackage(EmissionPlan plan, JsonNode model, Path output) { + return writeFiles(renderPackage(plan, model), plan, output); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Target.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Target.java new file mode 100644 index 0000000..d50ca08 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Target.java @@ -0,0 +1,238 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.ValidationMessage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +public final class Target { + public static final String API = "runtimeconditions.io/java-package-target/v1alpha1"; + public static final String KIND = "RuntimeConditionsJavaPackageTarget"; + private static final String SCHEMA_RESOURCE = "/target/package-target.schema.yaml"; + private static final Pattern DEPENDENCY = Pattern.compile("^/dependencies/\\d+(?:/\\w+)?$"); + private static final Pattern JAVA_PACKAGE = + Pattern.compile("^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)*$"); + + public record Dependency( + String extension, + String groupId, + String artifactId, + String javaPackage, + String version, + String versionRange) { + public Dependency(String extension, String groupId, String artifactId, String javaPackage, String version) { + this(extension, groupId, artifactId, javaPackage, version, null); + } + + public String coordinate() { + return groupId + ":" + artifactId; + } + } + + public record PackageTarget( + String packageKey, + String rootExtension, + String groupId, + String artifactId, + String javaPackage, + String version, + String sourceDirectory, + String minimumJavaVersion, + String publicationMode, + String repositoryUrl, + String registryId, + List dependencies, + String emitterSha256) { + public String coordinate() { + return groupId + ":" + artifactId; + } + + public PackageTarget withEmitterSha256(String digest) { + return new PackageTarget( + packageKey, + rootExtension, + groupId, + artifactId, + javaPackage, + version, + sourceDirectory, + minimumJavaVersion, + publicationMode, + repositoryUrl, + registryId, + dependencies, + digest); + } + } + + private static JsonSchema validator; + + private Target() {} + + private record Described(String code, String message, String pointer) {} + + private static String schemaText() { + String override = System.getenv("RC_PACKAGE_TARGET_SCHEMA"); + try { + if (override != null && !override.isEmpty()) { + return Files.readString(Path.of(override), StandardCharsets.UTF_8); + } + try (InputStream stream = Target.class.getResourceAsStream(SCHEMA_RESOURCE)) { + if (stream == null) { + throw fail("package-config", "RCJ1006", "package-target", "package target schema is unavailable"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException error) { + throw fail("package-config", "RCJ1006", "package-target", "package target schema is unavailable"); + } + } + + private static synchronized JsonSchema validator() { + if (validator == null) { + validator = Model.createValidator(schemaText()); + } + return validator; + } + + private static Described configError(String message, String pointer) { + return new Described("RCJ1006", message, pointer); + } + + private static Described describe(ValidationMessage error) { + String path = error.getInstanceLocation().toString(); + String type = error.getType(); + Described registry = configError("registryId is required exactly for registry publication", "/registryId"); + if (type.equals("required")) { + String missing = error.getProperty(); + if (path.isEmpty()) { + return missing.equals("registryId") ? registry : configError("missing required field '" + missing + "'", "/" + missing); + } + return configError("missing dependency field '" + missing + "'", path + "/" + missing); + } + if (type.equals("additionalProperties")) { + String extra = error.getProperty(); + return path.isEmpty() + ? configError("unknown field '" + extra + "'", "/" + extra) + : configError("unknown dependency field '" + extra + "'", path + "/" + extra); + } + if (type.equals("if") || type.equals("not")) { + return registry; + } + return switch (path) { + case "/apiVersion", "/kind" -> configError("unsupported target apiVersion or kind", ""); + case "/sourceDirectory" -> new Described("RCJ1007", "source directory does not match package key", path); + case "/minimumJavaVersion" -> new Described("RCJ1008", "unsupported minimum Java version", path); + case "/emitterSha256" -> configError("emitterSha256 must be a 64-character lowercase SHA-256 of the emitter tool", path); + case "/version" -> configError("unsupported package version", path); + case "/publicationMode" -> configError("unsupported publication mode", path); + case "/repositoryUrl" -> configError("repositoryUrl must use https", path); + case "/dependencies" -> configError("dependencies must be a sequence", path); + default -> { + if (DEPENDENCY.matcher(path).matches()) { + yield configError( + path.chars().filter(character -> character == '/').count() == 2 + ? "dependency must be a mapping" + : "invalid dependency package metadata", + path); + } + yield configError("invalid " + path.substring(path.lastIndexOf('/') + 1), path); + } + }; + } + + private static int rank(ValidationMessage error) { + if (!error.getInstanceLocation().toString().isEmpty()) { + return 3; + } + return switch (error.getType()) { + case "required" -> error.getProperty().equals("registryId") ? 2 : 0; + case "additionalProperties" -> 1; + case "if", "not" -> 2; + default -> 3; + }; + } + + private static String sortKey(ValidationMessage error) { + return rank(error) + (error.getProperty() == null ? "" : error.getProperty()) + error.getInstanceLocation() + error.getType(); + } + + private static boolean validJavaPackage(String value) { + if (!JAVA_PACKAGE.matcher(value).matches()) { + return false; + } + for (String part : value.split("\\.")) { + if (Naming.isReservedWord(part)) { + return false; + } + } + return true; + } + + public static PackageTarget load(Path path) { + ObjectNode value = YamlIo.load(path, false); + List errors = new ArrayList<>(Model.validate(validator(), value)); + String coordinate = value.path("packageKey").isTextual() ? value.path("packageKey").asText() : "package-target"; + if (!errors.isEmpty()) { + errors.sort(Comparator.comparing(Target::sortKey)); + Described described = describe(errors.get(0)); + throw fail("package-config", described.code(), coordinate, described.message(), described.pointer()); + } + String packageKey = value.get("packageKey").asText(); + if (!validJavaPackage(value.get("javaPackage").asText())) { + throw fail("package-config", "RCJ1006", packageKey, "invalid javaPackage", "/javaPackage"); + } + if (!value.get("sourceDirectory").asText().equals("bindings/" + packageKey + "/java")) { + throw fail("package-config", "RCJ1007", packageKey, "source directory does not match package key", "/sourceDirectory"); + } + List dependencies = new ArrayList<>(); + Set seen = new HashSet<>(); + int index = 0; + for (JsonNode entry : value.has("dependencies") ? value.get("dependencies") : Json.MAPPER.createArrayNode()) { + String pointer = "/dependencies/" + index; + index += 1; + String extension = entry.get("extension").asText(); + if (!seen.add(extension)) { + throw fail("package-config", "RCJ1006", packageKey, "duplicate dependency '" + extension + "'", pointer); + } + if (!validJavaPackage(entry.get("javaPackage").asText())) { + throw fail("package-config", "RCJ1006", packageKey, "invalid dependency package metadata", pointer); + } + dependencies.add( + new Dependency( + extension, + entry.get("groupId").asText(), + entry.get("artifactId").asText(), + entry.get("javaPackage").asText(), + entry.get("version").asText(), + entry.has("versionRange") ? entry.get("versionRange").asText() : null)); + } + return new PackageTarget( + packageKey, + value.get("rootExtension").asText(), + value.get("groupId").asText(), + value.get("artifactId").asText(), + value.get("javaPackage").asText(), + value.get("version").asText(), + value.get("sourceDirectory").asText(), + value.get("minimumJavaVersion").asText(), + value.get("publicationMode").asText(), + value.get("repositoryUrl").asText(), + value.has("registryId") ? value.get("registryId").asText() : null, + List.copyOf(dependencies), + value.has("emitterSha256") ? value.get("emitterSha256").asText() : ""); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Templates.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Templates.java new file mode 100644 index 0000000..59a6646 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Templates.java @@ -0,0 +1,46 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Reads the files under {@code templates/} and fills their {@code {{name}}} placeholders. */ +public final class Templates { + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{(\\w+)}}"); + private static final Pattern LINE = Pattern.compile("(?m)^[ \\t]*\\{\\{(\\w+)}}\\n"); + + private Templates() {} + + public static String read(String name) { + try (InputStream stream = Templates.class.getResourceAsStream("/templates/" + name)) { + if (stream == null) { + throw fail("toolchain", "RCJ4001", name, "template '" + name + "' cannot be read"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException error) { + throw fail("toolchain", "RCJ4001", name, "template '" + name + "' cannot be read"); + } + } + + private static String lookup(Map variables, String key, String template) { + String value = variables.get(key); + if (value == null) { + throw fail("toolchain", "RCJ4001", template, "template placeholder '{{" + key + "}}' has no value"); + } + return value; + } + + /** + * Replaces {@code {{name}}} in {@code text}. A placeholder alone on its line is replaced together with + * that line, so its value supplies whole lines and an empty value leaves none behind. + */ + public static String fill(String template, String text, Map variables) { + String lines = LINE.matcher(text).replaceAll(match -> Matcher.quoteReplacement(lookup(variables, match.group(1), template))); + return PLACEHOLDER.matcher(lines).replaceAll(match -> Matcher.quoteReplacement(lookup(variables, match.group(1), template))); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Verification.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Verification.java new file mode 100644 index 0000000..a7de642 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Verification.java @@ -0,0 +1,370 @@ +package io.runtimeconditions.bindings.emitter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.source.tree.ClassTree; +import com.sun.source.tree.CompilationUnitTree; +import com.sun.source.tree.ExpressionStatementTree; +import com.sun.source.tree.ImportTree; +import com.sun.source.tree.LiteralTree; +import com.sun.source.tree.MethodInvocationTree; +import com.sun.source.tree.MethodTree; +import com.sun.source.tree.NewClassTree; +import com.sun.source.tree.StatementTree; +import com.sun.source.tree.Tree; +import com.sun.source.tree.VariableTree; +import com.sun.source.util.JavacTask; +import io.runtimeconditions.bindings.emitter.Plan.DeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Plan.FieldPlan; +import io.runtimeconditions.bindings.emitter.Plan.ImportedDeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.Kind; +import io.runtimeconditions.bindings.emitter.Plan.TypePlan; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; +import javax.lang.model.element.Modifier; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +public final class Verification { + private static final String MISMATCH = "native API or conformance mismatch"; + + private Verification() {} + + private static void check(boolean condition, String description) { + if (!condition) { + throw new IllegalStateException(MISMATCH + ": " + description); + } + } + + private static String squash(Object tree) { + return String.valueOf(tree).replaceAll("\\s+", " ").trim(); + } + + record ParsedType(String path, String packageName, List imports, ClassTree type) {} + + static Map parse(Path directory) { + Path root = directory.resolve("src/main/java"); + List files = List.of(); + // A package that declares and imports nothing of its own has no Java source directory. + if (Files.isDirectory(root)) { + try (Stream walk = Files.walk(root)) { + files = walk.filter(path -> path.toString().endsWith(".java")).sorted().toList(); + } catch (IOException error) { + throw new IllegalStateException(MISMATCH + ": cannot read " + root); + } + } + Map result = new LinkedHashMap<>(); + if (files.isEmpty()) { + return result; + } + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager manager = + compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + JavacTask task = + (JavacTask) + compiler.getTask( + null, + manager, + diagnostics, + List.of("-proc:none"), + null, + manager.getJavaFileObjectsFromPaths(files)); + for (CompilationUnitTree unit : task.parse()) { + String path = directory.relativize(Path.of(unit.getSourceFile().toUri())).toString(); + List declarations = unit.getTypeDecls(); + check(declarations.size() == 1, path + " declares exactly one top-level type"); + ClassTree type = (ClassTree) declarations.get(0); + List imports = new ArrayList<>(); + for (ImportTree item : unit.getImports()) { + imports.add(squash(item.getQualifiedIdentifier())); + } + String name = type.getSimpleName().toString(); + check(path.endsWith("/" + name + ".java"), path + " is named after its type"); + result.put(name, new ParsedType(path, squash(unit.getPackageName()), imports, type)); + } + } catch (IOException error) { + throw new IllegalStateException(MISMATCH + ": cannot parse generated sources"); + } + for (Diagnostic item : diagnostics.getDiagnostics()) { + check(item.getKind() != Diagnostic.Kind.ERROR, "generated source parses: " + item.getMessage(null)); + } + return result; + } + + private static boolean nullable(String annotation) { + return annotation.equals("Void") || annotation.equals("Object"); + } + + private record Component(String name, String type) {} + + private static List components(ClassTree type) { + List result = new ArrayList<>(); + for (Tree member : type.getMembers()) { + if (member instanceof VariableTree variable + && variable.getModifiers().getFlags().contains(Modifier.PRIVATE) + && variable.getModifiers().getFlags().contains(Modifier.FINAL) + && !variable.getModifiers().getFlags().contains(Modifier.STATIC)) { + result.add(new Component(variable.getName().toString(), squash(variable.getType()))); + } + } + return result; + } + + private static Set requiredChecks(ClassTree type) { + Set result = new TreeSet<>(); + for (Tree member : type.getMembers()) { + if (member instanceof MethodTree method && method.getName().contentEquals("") && method.getBody() != null) { + for (StatementTree statement : method.getBody().getStatements()) { + if (statement instanceof ExpressionStatementTree expression + && expression.getExpression() instanceof MethodInvocationTree call + && squash(call.getMethodSelect()).endsWith("Objects.requireNonNull") + && !call.getArguments().isEmpty()) { + result.add(squash(call.getArguments().get(0))); + } + } + } + } + return result; + } + + private static List methods(ClassTree type) { + List result = new ArrayList<>(); + for (Tree member : type.getMembers()) { + if (member instanceof MethodTree method && !method.getName().contentEquals("")) { + result.add(method); + } + } + return result; + } + + private static List implemented(ClassTree type) { + List result = new ArrayList<>(); + for (Tree item : type.getImplementsClause()) { + result.add(squash(item)); + } + return result; + } + + private static List expectedFiles(EmissionPlan plan) { + List result = new ArrayList<>(); + for (String name : Source.exportedNames(plan)) { + result.add(Source.sourcePath(plan, name)); + } + Collections.sort(result); + return result; + } + + private static void checkRecord(ClassTree type, List fields, String owner, String marker, List markerProtocol) { + List actual = components(type); + List expected = new ArrayList<>(); + for (FieldPlan field : fields) { + expected.add(new Component(field.name(), field.annotation())); + } + check(actual.equals(expected), owner + " components match the plan"); + Set checked = requiredChecks(type); + for (FieldPlan field : fields) { + if (!nullable(field.annotation())) { + check(checked.contains(field.name()) == field.required(), owner + "." + field.name() + " requiredness"); + } + } + List names = new ArrayList<>(); + for (MethodTree method : methods(type)) { + names.add(method.getName().toString()); + } + check(names.equals(marker == null ? List.of() : List.of(marker)), owner + " markers match the plan"); + check(implemented(type).equals(markerProtocol), owner + " implements its marker"); + } + + private static void checkPublicApi(EmissionPlan plan, Path directory) { + Map parsed = parse(directory); + List actualFiles = new ArrayList<>(); + for (ParsedType item : parsed.values()) { + actualFiles.add(item.path().replace('\\', '/')); + } + Collections.sort(actualFiles); + check(actualFiles.equals(expectedFiles(plan)), "source files == planned types, protocols, and declaration classes"); + for (ParsedType item : parsed.values()) { + check(item.packageName().equals(plan.target().javaPackage()), item.path() + " package"); + check(item.type().getModifiers().getFlags().contains(Modifier.PUBLIC), item.path() + " is public"); + } + if (!plan.declarations().isEmpty()) { + ClassTree declaration = parsed.get(Plan.DECLARATION_CLASS).type(); + check(declaration.getKind() == Tree.Kind.CLASS, "Declaration is a class"); + ClassTree functions = parsed.get(Plan.FUNCTIONS_CLASS).type(); + check(functions.getKind() == Tree.Kind.CLASS, "Bindings is a class"); + check(functions.getModifiers().getFlags().contains(Modifier.FINAL), "Bindings is final"); + Map byName = new LinkedHashMap<>(); + for (MethodTree method : methods(functions)) { + byName.put(method.getName().toString(), method); + } + Set expectedFunctions = new TreeSet<>(); + for (DeclarationPlan item : plan.declarations()) { + expectedFunctions.add(item.function()); + } + check(new TreeSet<>(byName.keySet()).equals(expectedFunctions), "functions == declaration functions"); + for (DeclarationPlan item : plan.declarations()) { + MethodTree method = byName.get(item.function()); + check(method.getModifiers().getFlags().contains(Modifier.STATIC), "declaration function is static"); + check(method.getParameters().size() == 1, "declaration takes one varargs parameter"); + check( + squash(method.getParameters().get(0)).equals(item.protocol() + "... fields"), + "declaration takes rest fields named fields"); + check(squash(method.getReturnType()).equals(Plan.DECLARATION_CLASS), "declaration returns Declaration"); + ClassTree protocol = parsed.get(item.protocol()).type(); + check(protocol.getKind() == Tree.Kind.INTERFACE, item.protocol() + " is an interface"); + List protocolMethods = methods(protocol); + check( + protocolMethods.size() == 1 + && protocolMethods.get(0).getName().contentEquals(item.method()) + && protocolMethods.get(0).getParameters().isEmpty() + && squash(protocolMethods.get(0).getReturnType()).equals("void"), + "protocol has only the marker method"); + } + } + for (ImportedDeclarationPlan item : plan.importedDeclarations()) { + check(!parsed.containsKey(item.protocol()), "imported declaration is not redeclared"); + } + for (TypePlan item : plan.types()) { + ClassTree type = parsed.get(item.name()).type(); + if (item.kind() == Kind.ENUM) { + check(type.getKind() == Tree.Kind.ENUM, item.name() + " is an enum"); + List> members = new ArrayList<>(); + for (Tree member : type.getMembers()) { + if (member instanceof VariableTree variable && variable.getInitializer() instanceof NewClassTree creation) { + members.add(List.of(variable.getName().toString(), String.valueOf(((LiteralTree) creation.getArguments().get(0)).getValue()))); + } + } + List> expected = new ArrayList<>(); + for (Plan.Member member : item.members()) { + expected.add(List.of(member.name(), member.value())); + } + check(members.equals(expected), "enum " + item.name() + " members match the plan"); + } else if (item.kind() == Kind.ALIAS) { + if (item.expression().kind().equals("union")) { + check(type.getKind() == Tree.Kind.INTERFACE, item.name() + " is a union interface"); + check(type.getModifiers().getFlags().contains(Modifier.SEALED), item.name() + " is sealed"); + List variants = new ArrayList<>(); + for (Tree member : type.getMembers()) { + if (member instanceof ClassTree nested) { + variants.add(nested); + } + } + check(variants.size() == item.expression().arguments().size(), item.name() + " variant count"); + for (int index = 0; index < variants.size(); index += 1) { + ClassTree variant = variants.get(index); + check(variant.getKind() == Tree.Kind.RECORD, item.name() + " variants are records"); + check(variant.getSimpleName().contentEquals("Variant" + (index + 1)), item.name() + " variant names"); + String annotation = Plan.annotate(item.expression().arguments().get(index), plan.names()); + checkRecord( + variant, + List.of(new FieldPlan("", "value", "value", item.expression().arguments().get(index), annotation, true)), + item.name() + ".Variant" + (index + 1), + null, + List.of(item.name())); + } + } else { + check(type.getKind() == Tree.Kind.RECORD, item.name() + " is a wrapper record"); + String annotation = Plan.annotate(item.expression(), plan.names()); + checkRecord( + type, + List.of(new FieldPlan("", "value", "value", item.expression(), annotation, true)), + item.name(), + null, + List.of()); + } + } else { + check(type.getKind() == Tree.Kind.RECORD, item.name() + " is a record"); + checkRecord( + type, + item.fields(), + item.name(), + item.marker() == null ? null : item.marker().method(), + item.marker() == null ? List.of() : List.of(item.marker().protocol())); + } + } + } + + public static List apiSurface(Path directory) { + Map parsed = parse(directory); + List result = new ArrayList<>(); + for (Map.Entry entry : parsed.entrySet()) { + String name = entry.getKey(); + ClassTree type = entry.getValue().type(); + for (String item : entry.getValue().imports()) { + if (!item.startsWith("java.")) { + int dot = item.lastIndexOf('.'); + result.add("import|" + item.substring(0, dot) + "|" + item.substring(dot + 1)); + } + } + describe(result, name, type); + } + Collections.sort(result); + return result; + } + + private static void describe(List result, String name, ClassTree type) { + String heritage = String.join(",", implemented(type)); + switch (type.getKind()) { + case INTERFACE -> result.add("interface|" + name + (type.getModifiers().getFlags().contains(Modifier.SEALED) ? "|sealed" : "")); + case ENUM -> result.add("enum|" + name); + case RECORD -> result.add("record|" + name + "|" + heritage); + default -> result.add("class|" + name + "|" + heritage); + } + if (type.getKind() == Tree.Kind.RECORD) { + Set checked = requiredChecks(type); + for (Component component : components(type)) { + String requiredness = nullable(component.type()) ? "nullable" : checked.contains(component.name()) ? "required" : "optional"; + result.add("component|" + name + "|" + component.name() + "|" + component.type() + "|" + requiredness); + } + } + for (Tree member : type.getMembers()) { + if (member instanceof VariableTree variable && variable.getInitializer() instanceof NewClassTree creation) { + result.add("member|" + name + "|" + variable.getName() + "(" + squash(creation.getArguments().get(0)) + ")"); + } else if (member instanceof MethodTree method && !method.getName().contentEquals("")) { + String parameters = String.join(",", method.getParameters().stream().map(Verification::squash).toList()); + String modifiers = method.getModifiers().getFlags().contains(Modifier.STATIC) ? "static" : ""; + result.add( + "method|" + name + "|" + method.getName() + "|" + parameters + "|" + squash(method.getReturnType()) + "|" + modifiers); + } else if (member instanceof ClassTree nested) { + describe(result, name + "." + nested.getSimpleName(), nested); + } + } + } + + public record Verified(List api, boolean modelMapping) {} + + public static Verified verifyPackage(EmissionPlan plan, Path directory, JsonNode model) { + checkPublicApi(plan, directory); + Map expected = Metadata.renderResources(plan, model); + for (String path : List.of("pom.xml", Metadata.MANIFEST_PATH)) { + String actual; + try { + actual = Files.readString(directory.resolve(path), StandardCharsets.UTF_8); + } catch (IOException error) { + throw new IllegalStateException(path + " is missing from the package"); + } + if (!actual.equals(expected.get(path))) { + throw new IllegalStateException( + path.equals("pom.xml") + ? "pom.xml differs from the package target and normalized model" + : "binding manifest mappings differ from normalized model"); + } + } + return new Verified(apiSurface(directory), true); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/YamlIo.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/YamlIo.java new file mode 100644 index 0000000..cd85e7e --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/YamlIo.java @@ -0,0 +1,236 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.DiagnosticError.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; +import org.snakeyaml.engine.v2.comments.CommentType; +import org.snakeyaml.engine.v2.events.CommentEvent; +import org.snakeyaml.engine.v2.events.Event; +import org.snakeyaml.engine.v2.events.NodeEvent; +import org.snakeyaml.engine.v2.events.ScalarEvent; +import org.snakeyaml.engine.v2.events.CollectionStartEvent; +import org.snakeyaml.engine.v2.exceptions.YamlEngineException; +import org.snakeyaml.engine.v2.parser.ParserImpl; +import org.snakeyaml.engine.v2.scanner.StreamReader; + +final class YamlIo { + static final int MAX_BYTES = 64 * 1024 * 1024; + static final int MAX_NODES = 1_000_000; + static final int MAX_DEPTH = 256; + static final int MAX_ALIASES = 100; + + private static final String BAD_MODEL_BYTES = + "binding model must use UTF-8 without BOM and LF with one trailing newline"; + private static final String BAD_MODEL_NODES = + "binding model may not contain YAML anchors, aliases, or tags"; + + private YamlIo() {} + + private static String strictDecode(byte[] data, String coordinate) { + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(data)) + .toString(); + } catch (CharacterCodingException error) { + throw fail("input", "RCJ1002", coordinate, "input is not valid UTF-8"); + } + } + + private static void checkModelBytes(byte[] data, String coordinate) { + boolean bom = + data.length >= 3 + && (data[0] & 0xff) == 0xef + && (data[1] & 0xff) == 0xbb + && (data[2] & 0xff) == 0xbf; + boolean carriageReturn = false; + for (byte value : data) { + if (value == 0x0d) { + carriageReturn = true; + break; + } + } + if (bom + || carriageReturn + || data.length == 0 + || data[data.length - 1] != 0x0a + || (data.length > 1 && data[data.length - 2] == 0x0a)) { + throw fail("input", "RCJ1002", coordinate, BAD_MODEL_BYTES); + } + } + + private static String firstLine(String message) { + int end = message.indexOf('\n'); + return end < 0 ? message : message.substring(0, end); + } + + private static void scanEvents(String source, boolean model, String coordinate) { + LoadSettings settings = + LoadSettings.builder() + .setParseComments(model) + .setAllowDuplicateKeys(false) + .setMaxAliasesForCollections(MAX_ALIASES) + .build(); + int documents = 0; + int nodes = 0; + int aliases = 0; + int depth = 0; + try { + ParserImpl parser = new ParserImpl(settings, new StreamReader(settings, source)); + while (parser.hasNext()) { + Event event = parser.next(); + switch (event.getEventId()) { + case Comment -> { + if (model && ((CommentEvent) event).getCommentType() != CommentType.BLANK_LINE) { + throw fail( + "input", + "RCJ1002", + coordinate, + "binding model may not contain YAML comments"); + } + } + case DocumentStart -> documents += 1; + case Alias -> { + nodes += 1; + aliases += 1; + if (model) { + throw fail( + "input", + "RCJ1002", + coordinate, + "binding model may not contain YAML aliases"); + } + if (aliases > MAX_ALIASES) { + throw fail("input", "RCJ1002", coordinate, "YAML alias limit exceeded"); + } + } + case Scalar, SequenceStart, MappingStart -> { + nodes += 1; + if (event.getEventId() != Event.ID.Scalar) { + depth += 1; + } + if (nodes > MAX_NODES) { + throw fail("input", "RCJ1002", coordinate, "YAML node limit exceeded"); + } + if (depth > MAX_DEPTH) { + throw fail( + "input", "RCJ1002", coordinate, "YAML nesting limit exceeded"); + } + if (model && event instanceof NodeEvent node && node.getAnchor().isPresent()) { + throw fail("input", "RCJ1002", coordinate, BAD_MODEL_NODES); + } + if (model && hasExplicitTag(event)) { + throw fail("input", "RCJ1002", coordinate, BAD_MODEL_NODES); + } + } + case SequenceEnd, MappingEnd -> depth -= 1; + default -> {} + } + } + } catch (YamlEngineException error) { + throw fail("input", "RCJ1002", coordinate, firstLine(String.valueOf(error.getMessage()))); + } + if (documents == 0) { + throw fail("input", "RCJ1002", coordinate, "YAML document is empty"); + } + if (documents > 1) { + throw fail("input", "RCJ1002", coordinate, "YAML input must contain exactly one document"); + } + } + + private static boolean hasExplicitTag(Event event) { + if (event instanceof ScalarEvent scalar) { + return scalar.getTag().isPresent(); + } + if (event instanceof CollectionStartEvent collection) { + return collection.getTag().isPresent(); + } + return false; + } + + private static final class Counter { + int count; + } + + private static void checkDataTree(Object value, int depth, Counter counter, String coordinate) { + counter.count += 1; + if (counter.count > MAX_NODES) { + throw fail("input", "RCJ1002", coordinate, "decoded YAML node limit exceeded"); + } + if (depth > MAX_DEPTH) { + throw fail("input", "RCJ1002", coordinate, "decoded YAML nesting limit exceeded"); + } + if (value instanceof Double number && (number.isNaN() || number.isInfinite())) { + throw fail("input", "RCJ1002", coordinate, "non-finite YAML number"); + } + if (value instanceof Float number && (number.isNaN() || number.isInfinite())) { + throw fail("input", "RCJ1002", coordinate, "non-finite YAML number"); + } + if (value instanceof List items) { + for (Object item : items) { + checkDataTree(item, depth + 1, counter, coordinate); + } + } else if (value instanceof Map entries) { + for (Map.Entry entry : entries.entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw fail("input", "RCJ1002", coordinate, "YAML mapping keys must be strings"); + } + checkDataTree(entry.getValue(), depth + 1, counter, coordinate); + } + } + } + + static ObjectNode load(Path path, boolean model) { + String coordinate = model ? "model" : "package-target"; + byte[] data; + try (InputStream stream = Files.newInputStream(path)) { + data = stream.readNBytes(MAX_BYTES + 1); + } catch (IOException error) { + throw fail("input", "RCJ1001", coordinate, "cannot read YAML input: unreadable"); + } + if (data.length > MAX_BYTES) { + throw fail("input", "RCJ1001", coordinate, "YAML input exceeds 64 MiB"); + } + if (model) { + checkModelBytes(data, coordinate); + } + String source = strictDecode(data, coordinate); + scanEvents(source, model, coordinate); + + Object value; + try { + LoadSettings settings = + LoadSettings.builder() + .setAllowDuplicateKeys(false) + .setMaxAliasesForCollections(MAX_ALIASES) + .build(); + value = new Load(settings).loadFromString(source); + } catch (YamlEngineException error) { + throw fail("input", "RCJ1002", coordinate, firstLine(String.valueOf(error.getMessage()))); + } + if (value == null) { + throw fail("input", "RCJ1002", coordinate, "YAML document is empty"); + } + checkDataTree(value, 0, new Counter(), coordinate); + if (!(value instanceof Map)) { + throw fail("input", "RCJ1002", coordinate, "YAML document must be a mapping"); + } + JsonNode tree = Json.MAPPER.valueToTree(value); + return (ObjectNode) tree; + } +} diff --git a/tooling/extension-bindings/emitters/java/src/main/resources/emitter.properties b/tooling/extension-bindings/emitters/java/src/main/resources/emitter.properties new file mode 100644 index 0000000..fa51a4a --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/resources/emitter.properties @@ -0,0 +1,2 @@ +name=${name} +version=${version} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/CliTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/CliTest.java new file mode 100644 index 0000000..3525c65 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/CliTest.java @@ -0,0 +1,179 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.TARGET; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.emitterDigest; +import static io.runtimeconditions.bindings.emitter.TestSupport.modelPath; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CliTest { + record Result(int status, String stdout, String stderr) {} + + private static Result run(String... args) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + int status = + Cli.run( + List.of(args), + new PrintStream(out, true, StandardCharsets.UTF_8), + new PrintStream(err, true, StandardCharsets.UTF_8)); + return new Result(status, out.toString(StandardCharsets.UTF_8), err.toString(StandardCharsets.UTF_8)); + } + + private static Path targetFile(Path directory) throws IOException { + Path path = directory.resolve("target.yaml"); + Files.writeString( + path, + Files.readString(TARGET, StandardCharsets.UTF_8).replace("repositoryUrl:", "emitterSha256: " + emitterDigest() + "\nrepositoryUrl:"), + StandardCharsets.UTF_8); + return path; + } + + @Test + void emitVerifyAndApiWorkEndToEndThroughTheCommandLine() throws Exception { + Path directory = temporaryDirectory(); + try { + Path target = targetFile(directory); + String model = modelPath("01-owned-kind-interface").toString(); + Path output = directory.resolve("package"); + Result emitted = run("emit", "--model", model, "--package-config", target.toString(), "--output", output.toString()); + assertEquals(0, emitted.status(), emitted.stderr()); + assertEquals(output.toString(), Json.MAPPER.readTree(emitted.stdout()).path("output").asText()); + Result verified = run("verify", "--model", model, "--package-config", target.toString(), "--input", output.toString()); + assertEquals(0, verified.status(), verified.stderr()); + JsonNode result = Json.MAPPER.readTree(verified.stdout()); + assertTrue(result.path("modelMapping").asBoolean()); + List api = new ArrayList<>(); + result.path("api").forEach(item -> api.add(item.asText())); + assertTrue(api.contains("method|Bindings|service|ServiceField... fields|Declaration|static"), api.toString()); + Result printed = run("api", "--input", output.toString()); + assertEquals(0, printed.status(), printed.stderr()); + assertEquals(result.path("api"), Json.MAPPER.readTree(printed.stdout()).path("api")); + } finally { + delete(directory); + } + } + + @Test + void emitRefusesAnOccupiedOutputDirectoryAndWritesNothing() throws Exception { + Path directory = temporaryDirectory(); + try { + Path target = targetFile(directory); + Path output = directory.resolve("occupied"); + Files.createDirectory(output); + Files.writeString(output.resolve("keep.txt"), "keep"); + Result result = + run("emit", "--model", modelPath("01-owned-kind-interface").toString(), "--package-config", target.toString(), "--output", output.toString()); + assertEquals(1, result.status()); + assertTrue(result.stderr().contains("RCJ3001"), result.stderr()); + assertEquals("keep", Files.readString(output.resolve("keep.txt"))); + } finally { + delete(directory); + } + } + + @Test + void emitRequiresTheEmitterDigestForPackageResources() { + Path directory = temporaryDirectory(); + try { + Result result = + run("emit", "--model", modelPath("01-owned-kind-interface").toString(), "--package-config", TARGET.toString(), "--output", directory.resolve("out").toString()); + assertEquals(1, result.status()); + assertTrue(result.stderr().contains("emitterSha256"), result.stderr()); + } finally { + delete(directory); + } + } + + @Test + void badUsageAndMismatchedTargetsFailWithANonzeroStatus() throws Exception { + assertEquals(1, run().status()); + assertEquals(1, run("generate").status()); + assertEquals(1, run("emit", "--model", "x").status()); + assertEquals(1, run("emit", "--bogus").status()); + assertEquals(1, run("api").status()); + assertEquals("0.1.0", run("--version").stdout().trim()); + Path directory = temporaryDirectory(); + try { + Path target = targetFile(directory); + Result result = + run("emit", "--model", modelPath("02-additive-field").toString(), "--package-config", target.toString(), "--output", directory.resolve("out").toString()); + assertEquals(1, result.status()); + assertTrue(result.stderr().contains("RCJ1009"), result.stderr()); + } finally { + delete(directory); + } + } + + @Test + void verifyRejectsATamperedApiOrManifest() throws Exception { + Path directory = temporaryDirectory(); + try { + Path target = targetFile(directory); + String model = modelPath("01-owned-kind-interface").toString(); + Path output = directory.resolve("package"); + assertEquals(0, run("emit", "--model", model, "--package-config", target.toString(), "--output", output.toString()).status()); + Path bindings = output.resolve("src/main/java/io/runtimeconditions/bindings/conformance/ownedkindinterface/Http.java"); + String original = Files.readString(bindings); + Files.writeString(bindings, original.replace("String endpoint", "Long endpoint")); + Result tamperedApi = run("verify", "--model", model, "--package-config", target.toString(), "--input", output.toString()); + assertEquals(1, tamperedApi.status()); + assertTrue(tamperedApi.stderr().contains("native API or conformance mismatch"), tamperedApi.stderr()); + Files.writeString(bindings, original); + Path manifest = output.resolve("src/main/resources/runtimeconditions/runtimeconditions.bindings.yaml"); + Files.writeString(manifest, Files.readString(manifest).replace("#schema", "#wrong")); + Result tamperedManifest = run("verify", "--model", model, "--package-config", target.toString(), "--input", output.toString()); + assertEquals(1, tamperedManifest.status()); + assertTrue(tamperedManifest.stderr().contains("manifest mappings"), tamperedManifest.stderr()); + } finally { + delete(directory); + } + } + + @Test + void anOversizedInputIsRejectedWithoutReadingItAllIntoMemory() throws Exception { + Path directory = temporaryDirectory(); + try { + Path target = targetFile(directory); + Path oversized = directory.resolve("model.yaml"); + try (java.io.RandomAccessFile file = new java.io.RandomAccessFile(oversized.toFile(), "rw")) { + file.setLength(1L << 30); + } + Process process = + new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-Xmx256m", + "-cp", + System.getProperty("java.class.path"), + "io.runtimeconditions.bindings.emitter.Main", + "emit", + "--model", + oversized.toString(), + "--package-config", + target.toString(), + "--output", + directory.resolve("package").toString()) + .redirectErrorStream(true) + .start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(1, process.waitFor(), output); + assertTrue(output.contains("RCJ1001") && output.contains("exceeds 64 MiB"), output); + assertTrue(!output.contains("OutOfMemoryError"), output); + } finally { + delete(directory); + } + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/Conformance.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/Conformance.java new file mode 100644 index 0000000..7d52b39 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/Conformance.java @@ -0,0 +1,265 @@ +package io.runtimeconditions.bindings.emitter; + +import com.fasterxml.jackson.databind.JsonNode; +import io.runtimeconditions.bindings.emitter.Plan.DeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Plan.FieldPlan; +import io.runtimeconditions.bindings.emitter.Plan.ImportedDeclarationPlan; +import io.runtimeconditions.bindings.emitter.Plan.Kind; +import io.runtimeconditions.bindings.emitter.Plan.TypeExpr; +import io.runtimeconditions.bindings.emitter.Plan.TypePlan; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +final class Conformance { + private Conformance() {} + + private static String attempt(Supplier build) { + try { + return build.get(); + } catch (DiagnosticError error) { + return null; + } + } + + private static String sample( + TypeExpr expression, EmissionPlan plan, Map types, List active, String selectedOptional) { + switch (expression.kind()) { + case "string", "any": + return "\"sample\""; + case "boolean": + return "true"; + case "integer": + return "1L"; + case "number": + return "1.0"; + case "null": + return "null"; + default: + } + String name = plan.names().get(expression.symbolKey()); + TypePlan type = types.get(name); + if (type.kind() == Kind.ENUM) { + return type.name() + "." + type.members().get(0).name(); + } + if (type.kind() == Kind.ALIAS) { + if (active.contains(name)) { + throw DiagnosticError.fail("model", "RCJ1012", type.coordinate(), "recursive alias has no finite conformance sample"); + } + List next = append(active, name); + TypeExpr inner = type.expression(); + return switch (inner.kind()) { + case "array" -> { + String item = attempt(() -> sample(inner.arguments().get(0), plan, types, next, null)); + yield "new " + name + "(" + (item == null ? "List.of()" : "List.of(" + item + ")") + ")"; + } + case "map" -> { + String item = attempt(() -> sample(inner.arguments().get(0), plan, types, next, null)); + yield "new " + name + "(" + (item == null ? "Map.of()" : "Map.of(\"sample\", " + item + ")") + ")"; + } + default -> { + DiagnosticError last = null; + for (int index = 0; index < inner.arguments().size(); index += 1) { + try { + yield "new " + name + ".Variant" + (index + 1) + "(" + sample(inner.arguments().get(index), plan, types, next, null) + ")"; + } catch (DiagnosticError error) { + last = error; + } + } + throw last; + } + }; + } + boolean repeated = active.contains(name); + List optional = new ArrayList<>(); + for (FieldPlan field : type.fields()) { + if (!field.required()) { + optional.add(field.name()); + } + } + String selected = selectedOptional != null ? selectedOptional : optional.isEmpty() ? null : optional.get(0); + List arguments = new ArrayList<>(); + List next = append(active, name); + for (FieldPlan field : type.fields()) { + if (!field.required()) { + if (!repeated && field.name().equals(selected)) { + arguments.add(sample(field.typeExpr(), plan, types, next, null)); + } else { + arguments.add("null"); + } + continue; + } + if (repeated && List.of("object", "ref", "alias").contains(field.typeExpr().kind())) { + throw DiagnosticError.fail("model", "RCJ1012", field.coordinate(), "required recursive field has no finite conformance sample"); + } + arguments.add(sample(field.typeExpr(), plan, types, next, null)); + } + return "new " + type.name() + "(" + String.join(", ", arguments) + ")"; + } + + private static List append(List list, String item) { + List result = new ArrayList<>(list); + result.add(item); + return result; + } + + private static List> fieldGroups(List fields) { + Map> groups = new java.util.TreeMap<>(); + for (TypePlan field : fields) { + groups.computeIfAbsent(field.schemaCoordinate(), key -> new ArrayList<>()).add(field); + } + List> result = new ArrayList<>(); + for (List group : groups.values()) { + List sorted = new ArrayList<>(group); + sorted.sort(Comparator.comparing(TypePlan::name)); + result.add(sorted); + } + if (result.isEmpty()) { + result.add(List.of()); + } + return result; + } + + private static String variableName(Map variables, String name) { + return variables.get(name); + } + + static String source(EmissionPlan plan, JsonNode model) { + List lines = new ArrayList<>(); + lines.add("// " + Metadata.header(model)); + lines.add(""); + lines.add("package " + plan.target().javaPackage() + ";"); + lines.add(""); + lines.add("import java.util.List;"); + lines.add("import java.util.Map;"); + lines.add(""); + lines.add("@SuppressWarnings({\"unused\", \"rawtypes\"})"); + lines.add("final class Exercise {"); + lines.add(" private Exercise() {}"); + lines.add(""); + lines.add(" static void exercise() {"); + Map types = new LinkedHashMap<>(); + for (TypePlan item : plan.types()) { + types.put(item.name(), item); + } + Map objectVariables = new LinkedHashMap<>(); + Map> alternates = new LinkedHashMap<>(); + List ordered = new ArrayList<>(plan.types()); + ordered.sort(Comparator.comparing(TypePlan::name)); + for (int index = 0; index < ordered.size(); index += 1) { + TypePlan type = ordered.get(index); + if (type.kind() == Kind.ENUM) { + for (int member = 0; member < type.members().size(); member += 1) { + lines.add(" " + type.name() + " _enum" + index + "_" + member + " = " + type.name() + "." + type.members().get(member).name() + ";"); + } + } else if (type.kind() == Kind.ALIAS) { + String key = keyOf(plan, type.name()); + TypeExpr reference = new TypeExpr("alias", List.of(), key); + if (type.expression().kind().equals("union")) { + for (int variant = 0; variant < type.expression().arguments().size(); variant += 1) { + int chosen = variant; + String value = attempt(() -> sample(type.expression().arguments().get(chosen), plan, types, List.of(type.name()), null)); + if (value != null) { + lines.add(" " + type.name() + " _variant" + index + "_" + variant + " = new " + type.name() + ".Variant" + (variant + 1) + "(" + value + ");"); + } + } + } else { + lines.add(" " + type.name() + " _alias" + index + " = " + sample(reference, plan, types, List.of(), null) + ";"); + } + } else { + String key = keyOf(plan, type.name()); + TypeExpr objectExpression = new TypeExpr("object", List.of(), key); + String variable = "_object" + index; + objectVariables.put(type.name(), variable); + lines.add(" " + type.name() + " " + variable + " = " + sample(objectExpression, plan, types, List.of(), null) + ";"); + List optional = new ArrayList<>(); + for (FieldPlan field : type.fields()) { + if (!field.required()) { + optional.add(field.name()); + } + } + for (int offset = 1; offset < optional.size(); offset += 1) { + String alternate = "_objectOptional" + index + "_" + offset; + alternates.computeIfAbsent(type.name(), unused -> new ArrayList<>()).add(alternate); + lines.add(" " + type.name() + " " + alternate + " = " + sample(objectExpression, plan, types, List.of(), optional.get(offset)) + ";"); + } + } + } + int declarationIndex = 0; + List declarations = new ArrayList<>(plan.declarations()); + declarations.sort(Comparator.comparing(DeclarationPlan::function)); + for (DeclarationPlan declaration : declarations) { + List fields = new ArrayList<>(); + for (TypePlan item : plan.types()) { + if (item.isDeclarationField() + && item.marker() != null + && item.marker().owner().equals(plan.target().rootExtension()) + && item.marker().protocol().equals(declaration.protocol())) { + fields.add(item); + } + } + if (fields.isEmpty()) { + lines.add(" java.util.function.Function<" + declaration.protocol() + "[], Declaration> _deferred_" + declaration.function() + " = " + Plan.FUNCTIONS_CLASS + "::" + declaration.function() + ";"); + continue; + } + for (List group : fieldGroups(fields)) { + List arguments = new ArrayList<>(); + for (TypePlan item : group) { + arguments.add(variableName(objectVariables, item.name())); + } + lines.add(" Declaration _declaration" + declarationIndex + " = " + Plan.FUNCTIONS_CLASS + "." + declaration.function() + "(" + String.join(", ", arguments) + ");"); + declarationIndex += 1; + for (TypePlan item : group) { + for (String alternate : alternates.getOrDefault(item.name(), List.of())) { + List replaced = new ArrayList<>(); + for (TypePlan field : group) { + replaced.add(field.name().equals(item.name()) ? alternate : variableName(objectVariables, field.name())); + } + lines.add(" Declaration _declaration" + declarationIndex + " = " + Plan.FUNCTIONS_CLASS + "." + declaration.function() + "(" + String.join(", ", replaced) + ");"); + declarationIndex += 1; + } + } + } + } + int importedIndex = 0; + List imported = new ArrayList<>(plan.importedDeclarations()); + imported.sort(Comparator.comparing(ImportedDeclarationPlan::function)); + for (ImportedDeclarationPlan declaration : imported) { + List fields = new ArrayList<>(); + for (TypePlan item : plan.types()) { + if (item.isDeclarationField() && item.marker() != null && item.marker().protocol().equals(declaration.protocol())) { + fields.add(item); + } + } + String provider = declaration.providerPackage().javaPackage(); + if (fields.isEmpty()) { + continue; + } + for (List group : fieldGroups(fields)) { + List arguments = new ArrayList<>(); + for (TypePlan item : group) { + arguments.add(variableName(objectVariables, item.name())); + } + lines.add(" " + provider + ".Declaration _imported" + importedIndex + " = " + provider + "." + Plan.FUNCTIONS_CLASS + "." + declaration.function() + "(" + String.join(", ", arguments) + ");"); + importedIndex += 1; + } + } + lines.add(" }"); + lines.add("}"); + lines.add(""); + return String.join("\n", lines); + } + + private static String keyOf(EmissionPlan plan, String name) { + for (Map.Entry entry : plan.names().entrySet()) { + if (entry.getValue().equals(name) && entry.getKey().startsWith("type:")) { + return entry.getKey(); + } + } + throw new IllegalStateException(name); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ConformanceTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ConformanceTest.java new file mode 100644 index 0000000..9192a17 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ConformanceTest.java @@ -0,0 +1,177 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.buildCase; +import static io.runtimeconditions.bindings.emitter.TestSupport.compileBuilt; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.modelPath; +import static io.runtimeconditions.bindings.emitter.TestSupport.planFor; +import static io.runtimeconditions.bindings.emitter.TestSupport.snapshot; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static io.runtimeconditions.bindings.emitter.TestSupport.writeConformance; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.TestSupport.Built; +import io.runtimeconditions.bindings.emitter.TestSupport.Compiled; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class ConformanceTest { + static List cases() { + return TestSupport.cases(); + } + + @ParameterizedTest + @MethodSource("cases") + void emitsVerifiesAndCompilesUnderStrictJavac(String name) throws IOException { + Path workspace = temporaryDirectory(); + try { + Built built = buildCase(name, workspace, null); + Verification.verifyPackage(built.plan(), built.directory(), built.model()); + writeConformance(built); + Compiled compiled = compileBuilt(built, workspace.resolve("classes")); + assertTrue(compiled.success(), compiled.output()); + assertFalse(Verification.apiSurface(built.directory()).isEmpty()); + } finally { + delete(workspace); + } + } + + @ParameterizedTest + @MethodSource("cases") + void threeIndependentEmissionsAreByteIdentical(String name) { + ObjectNode model = Model.load(modelPath(name)); + List> runs = new ArrayList<>(); + Path workspace = temporaryDirectory(); + try { + for (int run = 0; run < 3; run += 1) { + EmissionPlan plan = planFor(model, name); + Path directory = workspace.resolve("run-" + run); + Source.emitPackage(plan, model, directory); + runs.add(snapshot(directory)); + } + assertEquals(runs.get(0), runs.get(1)); + assertEquals(runs.get(0), runs.get(2)); + for (Map.Entry entry : runs.get(0).entrySet()) { + if (entry.getKey().endsWith(".java")) { + assertTrue( + entry.getValue().startsWith("// Code generated by runtimeconditions-binding-emitter-java 0.1.0 from model "), + entry.getKey()); + assertTrue(entry.getValue().split("\n")[0].endsWith("DO NOT EDIT."), entry.getKey()); + assertFalse(entry.getValue().contains("\t"), entry.getKey()); + assertTrue(entry.getValue().endsWith("}\n"), entry.getKey()); + } + } + } finally { + delete(workspace); + } + } + + @Test + void theEmitterOutputContainsExactlyTheDeclaredPackageFiles() { + Path workspace = temporaryDirectory(); + try { + Built built = buildCase("01-owned-kind-interface", workspace, null); + String base = "src/main/java/io/runtimeconditions/conformance/c01ownedkindinterface/"; + assertEquals( + List.of( + "pom.xml", + base + "Bindings.java", + base + "Declaration.java", + base + "Http.java", + base + "Region.java", + base + "ServiceField.java", + "src/main/resources/runtimeconditions/runtimeconditions.bindings.yaml"), + new ArrayList<>(snapshot(built.directory()).keySet())); + } finally { + delete(workspace); + } + } + + private static String probe(Built built, Path classes, String line) throws IOException { + Path file = built.directory().resolve(Source.sourcePath(built.plan(), "Probe")); + Files.writeString( + file, + "package " + built.plan().target().javaPackage() + ";\n" + + "@SuppressWarnings(\"unused\")\n" + + "final class Probe {\n static void probe() {\n " + line + "\n }\n}\n"); + Compiled compiled = compileBuilt(built, classes); + Files.delete(file); + return compiled.success() ? "" : compiled.output(); + } + + @Test + void generatedTypesRejectValuesTheModelDoesNotAllow() throws IOException { + Path workspace = temporaryDirectory(); + try { + Built built = buildCase("10-scoped-domains-collisions", workspace, null); + String wrongValue = built.plan().types().stream().filter(item -> item.kind() == Plan.Kind.ENUM).findFirst().orElseThrow().name(); + String scalar = built.plan().types().stream().filter(item -> item.name().equals("BaseUrl")).findFirst().orElseThrow().name(); + String[][] probes = { + {"wrong enum value", wrongValue + " _x = \"streaming\";"}, + {"wrong scalar type", scalar + " _x = new " + scalar + "(1L);"}, + {"missing required field", scalar + " _x = new " + scalar + "();"}, + {"non-field argument to a declaration", "Declaration _x = Bindings.service(\"x\");"}, + }; + for (String[] item : probes) { + assertNotEquals("", probe(built, workspace.resolve("classes-" + item[0].replace(' ', '-')), item[1]), item[0] + " should not compile"); + } + assertEquals("", probe(built, workspace.resolve("classes-ok"), "Declaration _x = Bindings.service(new " + scalar + "(\"x\"));")); + } finally { + delete(workspace); + } + } + + @Test + void requiredValuesAreEnforcedAtRuntime() throws Exception { + Path workspace = temporaryDirectory(); + try { + Built built = buildCase("01-owned-kind-interface", workspace, null); + Path classes = workspace.resolve("classes"); + Compiled compiled = compileBuilt(built, classes); + assertTrue(compiled.success(), compiled.output()); + try (URLClassLoader loader = new URLClassLoader(new URL[] {classes.toUri().toURL()})) { + Class http = loader.loadClass(built.plan().target().javaPackage() + ".Http"); + Object value = http.getConstructor(String.class).newInstance("https://orders.internal"); + assertEquals("https://orders.internal", http.getMethod("endpoint").invoke(value)); + var error = org.junit.jupiter.api.Assertions.assertThrows( + java.lang.reflect.InvocationTargetException.class, () -> http.getConstructor(String.class).newInstance((Object) null)); + assertTrue(error.getCause() instanceof NullPointerException); + assertEquals("endpoint", error.getCause().getMessage()); + } + } finally { + delete(workspace); + } + } + + @Test + void anAdditiveFieldTypeIsCheckedAgainstTheProvidersMarker() { + Path workspace = temporaryDirectory(); + Path mismatchedWorkspace = temporaryDirectory(); + try { + Built built = buildCase("02-additive-field", workspace, null); + Compiled compiled = compileBuilt(built, workspace.resolve("classes")); + assertTrue(compiled.success(), compiled.output()); + Built mismatched = buildCase("02-additive-field", mismatchedWorkspace, "rcMarker_0000000000000000"); + Compiled failed = compileBuilt(mismatched, mismatchedWorkspace.resolve("classes")); + assertFalse(failed.success()); + assertTrue(failed.output().contains("rcMarker_"), failed.output()); + } finally { + delete(workspace); + delete(mismatchedWorkspace); + } + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ContractTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ContractTest.java new file mode 100644 index 0000000..9a71206 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ContractTest.java @@ -0,0 +1,170 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.EMITTER_ROOT; +import static io.runtimeconditions.bindings.emitter.TestSupport.TARGET; +import static io.runtimeconditions.bindings.emitter.TestSupport.TOOLING; +import static io.runtimeconditions.bindings.emitter.TestSupport.buildCase; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import io.runtimeconditions.bindings.emitter.TestSupport.Built; +import java.io.IOException; +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.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +class ContractTest { + private static String read(Path path) { + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException error) { + throw new IllegalStateException(error); + } + } + + private static JsonNode yaml(String text) { + return Json.MAPPER.valueToTree(new Load(LoadSettings.builder().build()).loadFromString(text)); + } + + private static JsonSchema schema(Path path) { + return Model.createValidator(read(path)); + } + + private static boolean valid(JsonSchema schema, JsonNode value) { + return schema.validate(value).isEmpty(); + } + + private static final JsonSchema MANIFEST = schema(TOOLING.resolve("model/runtimeconditions.binding-manifest.schema.yaml")); + + @Test + void thePackageTargetSchemaAcceptsTheTestTargetAndRejectsDrift() { + JsonSchema validate = schema(EMITTER_ROOT.resolve("package-target.schema.yaml")); + ObjectNode target = (ObjectNode) yaml(read(TARGET)); + assertTrue(validate.validate(target).isEmpty(), validate.validate(target).toString()); + List> changes = + List.of( + Map.of("sourceDirectory", "bindings/other/python"), + Map.of("minimumJavaVersion", "11"), + Map.of("groupId", "Bad Group"), + Map.of("javaPackage", "Bad.Package"), + Map.of("publicationMode", "registry"), + Map.of("extra", true)); + for (Map change : changes) { + ObjectNode copy = target.deepCopy(); + change.forEach((key, value) -> copy.set(key, Json.MAPPER.valueToTree(value))); + assertFalse(valid(validate, copy), change.toString()); + } + } + + private static ObjectNode manifestOf(Built built) { + return (ObjectNode) yaml(read(built.directory().resolve(Metadata.MANIFEST_PATH))); + } + + @Test + void everyEmittedManifestValidatesAgainstTheSharedBindingManifestSchema() { + Path workspace = temporaryDirectory(); + try { + for (String name : + List.of( + "01-owned-kind-interface", + "02-additive-field", + "06-recursive-reference", + "08-heterogeneous-union", + "09-collections-and-maps", + "10-scoped-domains-collisions")) { + Built built = buildCase(name, workspace, null); + var errors = MANIFEST.validate(manifestOf(built)); + assertTrue(errors.isEmpty(), name + ": " + errors); + } + } finally { + delete(workspace); + } + } + + @Test + void theManifestSchemaKeepsEachLanguagesIdentitySeparate() { + Path workspace = temporaryDirectory(); + try { + ObjectNode manifest = manifestOf(buildCase("01-owned-kind-interface", workspace, null)); + Map> mutations = + Map.of( + "a Java manifest needs minimumJavaVersion", value -> ((ObjectNode) value.path("package")).remove("minimumJavaVersion"), + "a Java manifest cannot carry a Python version", value -> ((ObjectNode) value.path("package")).put("minimumPythonVersion", "3.12"), + "a Java manifest cannot name a Python file", value -> ((ObjectNode) value.path("declarations").get(0)).put("file", "bindings.py"), + "a Java package name must be a Java package", value -> ((ObjectNode) value.path("package")).put("name", "Bad Name"), + "a Java declaration must name its function class", value -> ((ObjectNode) value.path("declarations").get(0)).remove("functionClass"), + "an unknown language is rejected", value -> ((ObjectNode) value.path("package")).put("language", "ruby")); + for (Map.Entry> mutation : mutations.entrySet()) { + ObjectNode copy = manifest.deepCopy(); + mutation.getValue().accept(copy); + assertFalse(valid(MANIFEST, copy), mutation.getKey()); + } + ObjectNode python = manifest.deepCopy(); + ObjectNode pack = Json.object(); + pack.put("language", "python"); + pack.put("coordinate", "x"); + pack.put("name", "x"); + pack.put("version", "1.0.0"); + pack.put("minimumJavaVersion", "17"); + python.set("package", pack); + assertFalse(valid(MANIFEST, python), "a Python manifest cannot carry a Java version"); + ObjectNode go = manifest.deepCopy(); + ((ObjectNode) go.path("package")).put("language", "go"); + assertFalse(valid(MANIFEST, go)); + assertTrue(manifest.path("importedMarkerContracts") instanceof ArrayNode); + } finally { + delete(workspace); + } + } + + @Test + void theEmittersToolPinsEqualTheSharedToolchainLock() { + JsonNode lock = yaml(read(TOOLING.resolve("toolchain.lock.yaml"))); + JsonNode java = lock.path("languages").path("java"); + JsonNode tools = java.path("tools"); + assertEquals("maven", java.path("packageManager").asText()); + assertEquals("gradle", java.path("buildBackend").asText()); + Matcher plugin = Pattern.compile("maven-compiler-plugin\\s*([^<]+)").matcher(Templates.read("pom.xml.tmpl")); + assertTrue(plugin.find()); + assertEquals(plugin.group(1), tools.path("maven-compiler-plugin").asText()); + String build = read(EMITTER_ROOT.resolve("build.gradle.kts")); + Matcher matcher = Pattern.compile("\"([A-Za-z0-9_.-]+):([A-Za-z0-9_.-]+):([0-9][A-Za-z0-9_.-]*)\"").matcher(build); + int matched = 0; + while (matcher.find()) { + String artifact = matcher.group(2); + assertEquals(tools.path(artifact).asText(), matcher.group(3), artifact + " matches the lock"); + matched += 1; + } + assertTrue(matched >= 4, "build.gradle.kts pins at least the runtime dependencies"); + assertEquals(tools.path("gradle").asText(), gradleVersion()); + } + + @Test + void identityComesFromTheBuildAndDependencyRangesCanBeOverridden() { + assertEquals("runtimeconditions-binding-emitter-java", Metadata.EMITTER_NAME); + assertTrue(Pattern.matches("\\d+\\.\\d+\\.\\d+", Metadata.EMITTER_VERSION), Metadata.EMITTER_VERSION); + Target.Dependency plain = new Target.Dependency("e", "g", "a", "p", "1.2.3"); + Target.Dependency pinned = new Target.Dependency("e", "g", "a", "p", "1.2.3", "[1.2,2)"); + assertEquals("[1.2.3,2.0.0)", Metadata.dependencyVersion(plain)); + assertEquals("[1.2,2)", Metadata.dependencyVersion(pinned)); + } + + private static String gradleVersion() { + return System.getProperty("rc.gradle.version", ""); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/DefinitionsTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/DefinitionsTest.java new file mode 100644 index 0000000..8778e0a --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/DefinitionsTest.java @@ -0,0 +1,136 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.compileBuilt; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.modelPath; +import static io.runtimeconditions.bindings.emitter.TestSupport.planFor; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static io.runtimeconditions.bindings.emitter.TestSupport.writeConformance; +import static io.runtimeconditions.bindings.emitter.TestSupport.writeProviderStubs; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.TestSupport.Built; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** A property that references a definition which is not an object keeps that definition's type. */ +final class DefinitionsTest { + private static final String CASE = "06-recursive-reference"; + + private static ObjectNode scalar(JsonNode provenance, String pointer, String kind, String scalar) { + ObjectNode shape = Json.object(); + shape.put("kind", kind); + if (scalar != null) { + shape.put("scalar", scalar); + } + shape.set("provenance", provenance(provenance, pointer)); + return shape; + } + + private static ObjectNode reference(JsonNode provenance, String pointer, String target) { + ObjectNode shape = Json.object(); + shape.put("kind", "ref"); + shape.put("ref", "#/$defs/" + target); + shape.set("provenance", provenance(provenance, pointer)); + return shape; + } + + private static ObjectNode provenance(JsonNode base, String pointer) { + ObjectNode copy = base.deepCopy(); + copy.put("jsonPointer", pointer); + return copy; + } + + private static void definition(ArrayNode definitions, JsonNode base, String name, ObjectNode shape) { + ObjectNode item = definitions.addObject(); + item.put("name", name); + item.put("jsonPointer", "/$defs/" + name); + item.set("shape", shape); + item.set("provenance", provenance(base, "/$defs/" + name)); + } + + private static void property(ArrayNode properties, JsonNode base, String name, ObjectNode shape) { + ObjectNode item = properties.addObject(); + item.put("name", name); + item.put("required", true); + item.set("shape", shape); + item.set("provenance", provenance(base, "/$defs/node/properties/" + name)); + } + + private static ObjectNode modelWithScalarDefinitions() { + ObjectNode model = Model.load(modelPath(CASE)); + ObjectNode schema = (ObjectNode) model.path("schemas").get(0); + ArrayNode definitions = (ArrayNode) schema.path("definitions"); + ObjectNode node = (ObjectNode) definitions.get(0); + JsonNode base = node.path("provenance"); + definition(definitions, base, "label", scalar(base, "/$defs/label", "scalar", "string")); + definition(definitions, base, "anything", scalar(base, "/$defs/anything", "any", null)); + definition(definitions, base, "labelAlias", reference(base, "/$defs/labelAlias", "label")); + definition(definitions, base, "labelAliasAlias", reference(base, "/$defs/labelAliasAlias", "labelAlias")); + ArrayNode properties = (ArrayNode) node.path("shape").path("properties"); + property(properties, base, "label", reference(base, "/$defs/node/properties/label", "label")); + property(properties, base, "anything", reference(base, "/$defs/node/properties/anything", "anything")); + property(properties, base, "chained", reference(base, "/$defs/node/properties/chained", "labelAliasAlias")); + ObjectNode list = Json.object(); + list.put("kind", "array"); + list.set("items", reference(base, "/$defs/node/properties/labels/items", "labelAlias")); + list.set("provenance", provenance(base, "/$defs/node/properties/labels")); + property(properties, base, "labels", list); + return model; + } + + @Test + void scalarAnyAndChainedDefinitionsKeepTheirResolvedTypes() throws Exception { + Path workspace = temporaryDirectory(); + try { + ObjectNode model = modelWithScalarDefinitions(); + EmissionPlan plan = planFor(model, CASE); + Path stubs = workspace.resolve("stubs"); + writeProviderStubs(plan, stubs, null); + Path directory = workspace.resolve("pkg").resolve(CASE); + Source.emitPackage(plan, model, directory); + Built built = new Built(directory, stubs, model, plan); + Verification.verifyPackage(plan, directory, model); + writeConformance(built); + + StringBuilder generated = new StringBuilder(); + try (java.util.stream.Stream walk = Files.walk(directory.resolve("src/main/java"))) { + for (Path file : walk.filter(item -> item.toString().endsWith(".java")).sorted().toList()) { + generated.append(Files.readString(file)); + } + } + String source = generated.toString(); + assertFalse(source.contains("null "), "null annotation"); + assertTrue(source.contains("String label"), "String label"); + assertTrue(source.contains("Object anything"), "Object anything"); + assertTrue(source.contains("String chained"), "String chained"); + assertTrue(source.contains("List"), "list of resolved strings"); + String manifest = Files.readString(directory.resolve(Metadata.MANIFEST_PATH)); + assertFalse(manifest.contains("type: null"), "null manifest type"); + assertTrue(compileBuilt(built, workspace.resolve("classes")).success()); + } finally { + delete(workspace); + } + } + + @Test + void aDefinitionThatOnlyRefersToItselfIsRejected() { + ObjectNode model = modelWithScalarDefinitions(); + ObjectNode schema = (ObjectNode) model.path("schemas").get(0); + ArrayNode definitions = (ArrayNode) schema.path("definitions"); + JsonNode base = definitions.get(0).path("provenance"); + definition(definitions, base, "loopA", reference(base, "/$defs/loopA", "loopB")); + definition(definitions, base, "loopB", reference(base, "/$defs/loopB", "loopA")); + ArrayNode properties = (ArrayNode) definitions.get(0).path("shape").path("properties"); + property(properties, base, "loop", reference(base, "/$defs/node/properties/loop", "loopA")); + DiagnosticError error = org.junit.jupiter.api.Assertions.assertThrows(DiagnosticError.class, () -> planFor(model, CASE)); + assertEquals("RCJ1012", error.diagnostic().code()); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/LoaderTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/LoaderTest.java new file mode 100644 index 0000000..1f44815 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/LoaderTest.java @@ -0,0 +1,157 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.TARGET; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.modelPath; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class LoaderTest { + private static final String GOOD = read(modelPath("01-owned-kind-interface")); + + private static String read(Path path) { + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException error) { + throw new IllegalStateException(error); + } + } + + private static void expect(Runnable action, String code, String message) { + DiagnosticError error = assertThrows(DiagnosticError.class, action::run); + assertEquals(code, error.diagnostic().code(), error.getMessage()); + if (message != null) { + assertTrue(error.diagnostic().message().contains(message) || error.diagnostic().message().matches("(?s).*" + message + ".*"), error.getMessage()); + } + } + + private static void withFile(String content, Consumer action) { + Path directory = temporaryDirectory(); + try { + Path path = directory.resolve("input.yaml"); + Files.writeString(path, content, StandardCharsets.UTF_8); + action.accept(path); + } catch (IOException error) { + throw new IllegalStateException(error); + } finally { + delete(directory); + } + } + + @Test + void theCommittedConformanceModelLoads() { + withFile(GOOD, path -> assertEquals("RuntimeConditionsBindingModel", Model.load(path).path("kind").asText())); + } + + @Test + void aBindingModelRejectsABomCrlfAndMissingOrDoubledTrailingNewlines() { + List cases = + List.of("" + GOOD, GOOD.replace("\n", "\r\n"), GOOD.substring(0, GOOD.length() - 1), GOOD + "\n", ""); + for (String content : cases) { + withFile(content, path -> expect(() -> Model.load(path), "RCJ1002", "without BOM and LF")); + } + } + + @Test + void aBindingModelRejectsCommentsAnchorsAliasesTagsAndDuplicateKeys() { + withFile( + GOOD.replace("kind: RuntimeConditionsBindingModel", "kind: RuntimeConditionsBindingModel # note"), + path -> expect(() -> Model.load(path), "RCJ1002", "comments")); + withFile("# leading\n" + GOOD, path -> expect(() -> Model.load(path), "RCJ1002", "comments")); + withFile( + GOOD.replace("kind: RuntimeConditionsBindingModel", "kind: &a RuntimeConditionsBindingModel"), + path -> expect(() -> Model.load(path), "RCJ1002", "anchors")); + withFile( + GOOD.replace("kind: RuntimeConditionsBindingModel", "kind: !!str RuntimeConditionsBindingModel"), + path -> expect(() -> Model.load(path), "RCJ1002", "tags")); + withFile(GOOD + "kind: RuntimeConditionsBindingModel\n", path -> expect(() -> Model.load(path), "RCJ1002", null)); + withFile("x: &a 1\ny: *a\n", path -> expect(() -> Model.load(path), "RCJ1002", "aliases")); + } + + @Test + void aBindingModelRejectsEmptyMultiDocumentNonMappingAndNonStringKeyInput() { + withFile("\n", path -> expect(() -> Model.load(path), "RCJ1002", "empty")); + withFile("a: 1\n---\nb: 2\n", path -> expect(() -> Model.load(path), "RCJ1002", "exactly one document")); + withFile("- 1\n- 2\n", path -> expect(() -> Model.load(path), "RCJ1002", "mapping")); + withFile("1: x\n", path -> expect(() -> Model.load(path), "RCJ1002", "keys must be strings")); + withFile("a: .nan\n", path -> expect(() -> Model.load(path), "RCJ1002", "non-finite")); + } + + @Test + void aMissingFileIsReportedAsUnreadableInput() { + expect(() -> Model.load(Path.of("/nonexistent/model.yaml")), "RCJ1001", "cannot read"); + } + + private static String mutated(java.util.function.Consumer change) { + ObjectNode value = Model.load(modelPath("01-owned-kind-interface")).deepCopy(); + change.accept(value); + return TestSupport.yaml(value); + } + + @Test + void unsupportedModelVersionsAndShapesAreRejectedWithDistinctCodes() { + withFile( + mutated(value -> value.put("apiVersion", "runtimeconditions.io/binding-model/v9")), + path -> expect(() -> Model.load(path), "RCJ1004", null)); + withFile(mutated(value -> value.put("kind", "Other")), path -> expect(() -> Model.load(path), "RCJ1005", null)); + withFile( + mutated(value -> ((ObjectNode) value.path("schemas").get(0).path("projection")).put("kind", "tuple")), + path -> expect(() -> Model.load(path), "RCJ1003", null)); + withFile(mutated(value -> value.remove("vocabulary")), path -> expect(() -> Model.load(path), "RCJ1003", null)); + withFile(mutated(value -> value.put("surprise", true)), path -> expect(() -> Model.load(path), "RCJ1003", null)); + } + + private static void badTarget(String base, String from, String to, String code, String message) { + assertTrue(base.contains(from), from); + withFile(base.replace(from, to), path -> expect(() -> Target.load(path), code, message)); + } + + @Test + void packageTargetsAreValidatedFieldByField() { + String base = read(TARGET); + assertEquals("conformance-owned-kind-interface", Target.load(TARGET).artifactId()); + String[][] bad = { + {"packageKey: conformance-owned-kind-interface", "packageKey: Bad_Key", "RCJ1006", null}, + {"groupId: io.runtimeconditions.bindings", "groupId: Bad_Group", "RCJ1006", null}, + {"artifactId: conformance-owned-kind-interface", "artifactId: Bad Artifact", "RCJ1006", null}, + {"javaPackage: io.runtimeconditions.bindings.conformance.ownedkindinterface", "javaPackage: io.class.bindings", "RCJ1006", null}, + {"javaPackage: io.runtimeconditions.bindings.conformance.ownedkindinterface", "javaPackage: Io.Bad", "RCJ1006", null}, + {"version: 1.0.0", "version: 1.0", "RCJ1006", null}, + {"sourceDirectory: bindings/conformance-owned-kind-interface/java", "sourceDirectory: bindings/other/java", "RCJ1007", null}, + {"minimumJavaVersion: '17'", "minimumJavaVersion: '11'", "RCJ1008", null}, + {"minimumJavaVersion: '17'", "minimumJavaVersion: '17.0'", "RCJ1008", null}, + {"publicationMode: github-tag", "publicationMode: registry", "RCJ1006", "registryId"}, + {"publicationMode: github-tag", "publicationMode: other", "RCJ1006", null}, + {"repositoryUrl: https://github.com/runtimeconditions/extensions", "repositoryUrl: http://example.com/x", "RCJ1006", null}, + {"kind: RuntimeConditionsJavaPackageTarget", "kind: Other", "RCJ1006", null}, + }; + for (String[] item : bad) { + badTarget(base, item[0], item[1], item[2], item[3]); + } + withFile(base + "extra: 1\n", path -> expect(() -> Target.load(path), "RCJ1006", "unknown field 'extra'")); + withFile(base.replaceFirst("(?m)^repositoryUrl:.*\n", ""), path -> expect(() -> Target.load(path), "RCJ1006", "missing required field")); + withFile(base + "emitterSha256: nothex\n", path -> expect(() -> Target.load(path), "RCJ1006", "emitterSha256")); + withFile(base + "registryId: central\n", path -> expect(() -> Target.load(path), "RCJ1006", "registryId")); + String entry = "dependencies:\n"; + withFile( + base + entry + " - extension: a\n groupId: g\n artifactId: dep\n javaPackage: dep.one\n version: 1.0.0\n" + + " - extension: a\n groupId: g\n artifactId: dep2\n javaPackage: dep.two\n version: 1.0.0\n", + path -> expect(() -> Target.load(path), "RCJ1006", "duplicate dependency")); + withFile(base + entry + " - extension: a\n groupId: g\n", path -> expect(() -> Target.load(path), "RCJ1006", "missing dependency field")); + withFile( + base + entry + " - extension: a\n groupId: g\n artifactId: Bad\n javaPackage: dep.one\n version: 1.0.0\n", + path -> expect(() -> Target.load(path), "RCJ1006", "invalid dependency")); + withFile(base + entry + " - bad\n", path -> expect(() -> Target.load(path), "RCJ1006", "mapping")); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NamingTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NamingTest.java new file mode 100644 index 0000000..583445a --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NamingTest.java @@ -0,0 +1,126 @@ +package io.runtimeconditions.bindings.emitter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.runtimeconditions.bindings.emitter.Naming.Style; +import io.runtimeconditions.bindings.emitter.Naming.Symbol; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +class NamingTest { + @Test + void namesAreDerivedFromTokens() { + Object[][] cases = { + {"base_url", Style.PASCAL, "BaseUrl"}, + {"base_url", Style.CAMEL, "baseUrl"}, + {"HTTPServer", Style.PASCAL, "HttpServer"}, + {"requestBody Schema", Style.CAMEL, "requestBodySchema"}, + {"key-value", Style.PASCAL, "KeyValue"}, + {"GET", Style.PASCAL, "Get"}, + {"3d", Style.PASCAL, "X3d"}, + {"3d", Style.CAMEL, "x3d"}, + {"streaming mode", Style.CONSTANT, "STREAMING_MODE"}, + {"Ünï cøde", Style.PASCAL, "ÜnïCøde"}, + }; + for (Object[] item : cases) { + assertEquals(item[2], Naming.javaName((String) item[0], (Style) item[1], "coordinate")); + } + } + + @Test + void reservedWordsGainATrailingUnderscore() { + for (String word : List.of("class", "new", "record", "hashCode", "wait", "default")) { + assertEquals(word + "_", Naming.javaName(word, Style.CAMEL, "c")); + } + } + + @Test + void aSourceWithNoLettersOrDigitsHasNoIdentifier() { + assertEquals("RCJ2000", assertThrows(DiagnosticError.class, () -> Naming.javaName("💥", Style.PASCAL, "c")).diagnostic().code()); + assertThrows(DiagnosticError.class, () -> Naming.javaName("---", Style.CAMEL, "c")); + } + + @Test + void exactNamesMustAlreadyBeIdentifiers() { + assertEquals("rcMarker_0123", Naming.javaName("rcMarker_0123", Style.EXACT, "c")); + assertThrows(DiagnosticError.class, () -> Naming.javaName("not-valid", Style.EXACT, "c")); + } + + @Test + void collisionsAreResolvedWithParentPathComponentsNearestFirst() { + Map names = + Naming.allocateSymbols( + "pkg", + List.of( + Symbol.scoped("a", "c1", "mode", Style.PASCAL, List.of("http", "service")), + Symbol.scoped("b", "c2", "mode", Style.PASCAL, List.of("grpc", "service")))); + assertEquals("HttpMode", names.get("a")); + assertEquals("GrpcMode", names.get("b")); + } + + @Test + void aCollisionThatSurvivesTheFullPathStopsEmissionWithoutRenaming() { + DiagnosticError error = + assertThrows( + DiagnosticError.class, + () -> + Naming.allocateSymbols( + "pkg", + List.of( + Symbol.scoped("a", "c1", "mode", Style.PASCAL, List.of("x")), + Symbol.scoped("b", "c2", "Mode", Style.PASCAL, List.of("x"))))); + assertTrue(error.diagnostic().message().contains("after full path")); + } + + @Test + void fixedSymbolsAndJavaGlobalsAreNeverRenamed() { + assertTrue( + assertThrows( + DiagnosticError.class, + () -> + Naming.allocateSymbols( + "pkg", + List.of( + Symbol.fixed("a", "c1", "service", Style.CAMEL), + Symbol.fixed("b", "c2", "Service", Style.CAMEL)))) + .diagnostic() + .message() + .contains("fixed symbol")); + assertTrue( + assertThrows( + DiagnosticError.class, + () -> Naming.allocateSymbols("pkg", List.of(new Symbol("a", "c1", "String", Style.PASCAL)))) + .diagnostic() + .message() + .contains("java-global:String")); + assertTrue( + assertThrows( + DiagnosticError.class, + () -> + Naming.allocateSymbols( + "pkg", + List.of( + new Symbol("a", "c1", "x", Style.PASCAL), + new Symbol("a", "c2", "y", Style.PASCAL)))) + .diagnostic() + .message() + .contains("duplicate symbol request key")); + } + + @Test + void allocationIsIndependentOfInputOrder() { + List symbols = + List.of( + Symbol.scoped("a", "c1", "mode", Style.PASCAL, List.of("http")), + Symbol.scoped("b", "c2", "mode", Style.PASCAL, List.of("grpc")), + new Symbol("c", "c3", "other", Style.CAMEL)); + List reversed = new ArrayList<>(symbols); + java.util.Collections.reverse(reversed); + assertEquals(new TreeMap<>(Naming.allocateSymbols("pkg", symbols)), new TreeMap<>(Naming.allocateSymbols("pkg", reversed))); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NegativeTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NegativeTest.java new file mode 100644 index 0000000..236ac48 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NegativeTest.java @@ -0,0 +1,99 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.EMITTER_ROOT; +import static io.runtimeconditions.bindings.emitter.TestSupport.developmentTarget; +import static io.runtimeconditions.bindings.emitter.TestSupport.modelPath; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +class NegativeTest { + private static JsonNode property(ObjectNode model, int index) { + return model.path("schemas").get(0).path("projection").path("properties").get(index); + } + + private static JsonNode named(JsonNode node, String name) { + for (JsonNode item : Json.list(node.path("properties"))) { + if (item.path("name").asText().equals(name)) { + return item; + } + } + throw new IllegalStateException(name); + } + + private static void mutate(String name, ObjectNode model) { + switch (name) { + case "reserved-global" -> ((ObjectNode) property(model, 1)).put("name", "record"); + case "fixed-function-collision" -> { + ObjectNode second = model.path("vocabulary").path("ownedDeclarations").get(0).deepCopy(); + second.put("coordinate", "kind:Service"); + second.put("kind", "Service"); + ((ArrayNode) model.path("vocabulary").path("ownedDeclarations")).add(second); + } + case "enum-member-collision" -> { + ObjectNode shape = (ObjectNode) named(model.path("schemas").get(0).path("projection"), "mode").path("shape"); + String first = shape.path("values").get(0).path("value").asText(); + ((ArrayNode) shape.path("values")).addObject().put("value", first.toUpperCase()); + ObjectNode scoped = (ObjectNode) named(model.path("scopes").get(0).path("projection"), "mode").path("shape"); + scoped.removeAll(); + scoped.setAll(shape.deepCopy()); + } + case "unnameable-field" -> ((ObjectNode) property(model, 1)).put("name", "💥"); + default -> throw new IllegalStateException(name); + } + } + + @Test + @SuppressWarnings("unchecked") + void negativeDiagnosticsMatchTheFixtures() throws IOException { + String text = Files.readString(EMITTER_ROOT.resolve("testdata/negative-diagnostics.yaml"), StandardCharsets.UTF_8); + List> fixtures = (List>) new Load(LoadSettings.builder().build()).loadFromString(text); + assertTrue(fixtures.size() >= 4); + for (Map fixture : fixtures) { + ObjectNode model = Model.load(modelPath((String) fixture.get("positiveCase"))).deepCopy(); + mutate((String) fixture.get("case"), model); + assertTrue(Model.validate(Model.validator(), model).isEmpty(), "the mutated model must stay schema-valid"); + PackageTarget target = developmentTarget(); + PackageTarget retargeted = + new PackageTarget( + target.packageKey(), + model.path("rootExtension").path("id").asText(), + target.groupId(), + target.artifactId(), + target.javaPackage(), + target.version(), + target.sourceDirectory(), + target.minimumJavaVersion(), + target.publicationMode(), + target.repositoryUrl(), + target.registryId(), + target.dependencies(), + target.emitterSha256()); + DiagnosticError error = assertThrows(DiagnosticError.class, () -> Plan.build(model, retargeted), (String) fixture.get("case")); + Map expected = (Map) fixture.get("expected"); + DiagnosticError.Diagnostic actual = error.diagnostic(); + assertEquals( + expected, + Map.of( + "category", actual.category(), + "code", actual.code(), + "coordinate", actual.coordinate(), + "jsonPointer", actual.jsonPointer(), + "message", actual.message()), + (String) fixture.get("case")); + } + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/PomVerificationTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/PomVerificationTest.java new file mode 100644 index 0000000..14f50f6 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/PomVerificationTest.java @@ -0,0 +1,52 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.buildCase; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.runtimeconditions.bindings.emitter.TestSupport.Built; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +/** verify compares the generated pom.xml with the one the target and model produce. */ +final class PomVerificationTest { + private static void expectRejected(String name, UnaryOperator change, boolean delete) throws IOException { + Path workspace = temporaryDirectory(); + try { + Built built = buildCase("03-transitive-closure", workspace, null); + Verification.verifyPackage(built.plan(), built.directory(), built.model()); + Path pom = built.directory().resolve("pom.xml"); + if (delete) { + Files.delete(pom); + } else { + String text = Files.readString(pom); + String changed = change.apply(text); + if (changed.equals(text)) { + throw new IllegalStateException(name + " did not change the pom"); + } + Files.writeString(pom, changed); + } + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> Verification.verifyPackage(built.plan(), built.directory(), built.model()), + name); + assertEquals(delete ? "pom.xml is missing from the package" : "pom.xml differs from the package target and normalized model", error.getMessage()); + } finally { + delete(workspace); + } + } + + @Test + void aMissingOrChangedPomFailsVerification() throws IOException { + expectRejected("missing pom", text -> text, true); + expectRejected("changed coordinates", text -> text.replace("io.runtimeconditions.conformance", "other"), false); + expectRejected("wrong Java release", text -> text.replaceFirst("\\d+<", "99<"), false); + expectRejected("dropped dependency", text -> text.replaceFirst("(?s) .*?\n", ""), false); + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/RealModelsTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/RealModelsTest.java new file mode 100644 index 0000000..badac5c --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/RealModelsTest.java @@ -0,0 +1,79 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.TOOLING; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.developmentTarget; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Target.Dependency; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import io.runtimeconditions.bindings.emitter.TestSupport.Compiled; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RealModelsTest { + private static final Path COMMON = TOOLING.resolve("../../bindings/common-integrations/go/runtimeconditions.binding-model.yaml").normalize(); + private static final Path ENV = TOOLING.resolve("../../bindings/env-configuration/go/runtimeconditions.binding-model.yaml").normalize(); + private static final String COMMON_ID = + "https://runtimeconditions.io/extensions/common-integrations/v1alpha1/runtimeconditions.extension.yaml"; + + private static PackageTarget target(String key, String javaPackage, ObjectNode model, List dependencies) { + PackageTarget base = developmentTarget(); + return new PackageTarget( + key, + model.path("rootExtension").path("id").asText(), + "io.runtimeconditions", + key, + javaPackage, + base.version(), + "bindings/" + key + "/java", + base.minimumJavaVersion(), + base.publicationMode(), + base.repositoryUrl(), + base.registryId(), + dependencies, + base.emitterSha256()); + } + + @Test + void theCommittedFirstPartyModelsEmitPackagesThatCompileIncludingRecursiveMaps() throws Exception { + assumeTrue(Files.exists(COMMON) && Files.exists(ENV)); + Path workspace = temporaryDirectory(); + try { + ObjectNode common = Model.load(COMMON); + EmissionPlan commonPlan = Plan.build(common, target("common-integrations", "io.runtimeconditions.commonintegrations", common, List.of())); + Path commonDirectory = workspace.resolve("common"); + Source.emitPackage(commonPlan, common, commonDirectory); + Verification.verifyPackage(commonPlan, commonDirectory, common); + Files.writeString(commonDirectory.resolve(Source.sourcePath(commonPlan, "Exercise")), Conformance.source(commonPlan, common)); + Compiled compiled = TestSupport.compile(List.of(commonDirectory.resolve("src/main/java")), workspace.resolve("common-classes")); + assertTrue(compiled.success(), compiled.output()); + + ObjectNode env = Model.load(ENV); + EmissionPlan envPlan = + Plan.build( + env, + target( + "env-configuration", + "io.runtimeconditions.envconfiguration", + env, + List.of(new Dependency(COMMON_ID, "io.runtimeconditions", "common-integrations", "io.runtimeconditions.commonintegrations", "0.1.0")))); + Path envDirectory = workspace.resolve("env"); + Source.emitPackage(envPlan, env, envDirectory); + Verification.verifyPackage(envPlan, envDirectory, env); + Files.writeString(envDirectory.resolve(Source.sourcePath(envPlan, "Exercise")), Conformance.source(envPlan, env)); + Compiled envCompiled = + TestSupport.compile( + List.of(envDirectory.resolve("src/main/java"), commonDirectory.resolve("src/main/java")), workspace.resolve("env-classes")); + assertTrue(envCompiled.success(), envCompiled.output()); + } finally { + delete(workspace); + } + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java new file mode 100644 index 0000000..5925f26 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java @@ -0,0 +1,282 @@ +package io.runtimeconditions.bindings.emitter; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Plan.ImportedDeclarationPlan; +import io.runtimeconditions.bindings.emitter.Target.Dependency; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +final class TestSupport { + static final Path EMITTER_ROOT = Path.of(System.getProperty("rc.emitter.root")).toAbsolutePath().normalize(); + static final Path TOOLING = EMITTER_ROOT.resolve("../..").normalize(); + static final Path MODELS = TOOLING.resolve("model/conformance/expected"); + static final Path TARGET = EMITTER_ROOT.resolve("testdata/package-target.yaml"); + + private TestSupport() {} + + static List cases() { + try (Stream entries = Files.list(MODELS)) { + return entries + .filter(path -> Files.exists(path.resolve("runtimeconditions.binding-model.yaml"))) + .map(path -> path.getFileName().toString()) + .sorted() + .toList(); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static Path modelPath(String name) { + return MODELS.resolve(name).resolve("runtimeconditions.binding-model.yaml"); + } + + static Path temporaryDirectory() { + try { + return Files.createTempDirectory("rc-java-emitter-"); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static void delete(Path directory) { + if (!Files.exists(directory)) { + return; + } + try (Stream walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete()); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static String sha256(byte[] data) { + try { + StringBuilder hex = new StringBuilder(); + for (byte value : MessageDigest.getInstance("SHA-256").digest(data)) { + hex.append(String.format("%02x", value)); + } + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + static String emitterDigest() { + Map inventory = new TreeMap<>(); + try (Stream walk = Files.walk(EMITTER_ROOT.resolve("src/main/java"))) { + for (Path path : walk.filter(item -> item.toString().endsWith(".java")).sorted().toList()) { + inventory.put(EMITTER_ROOT.relativize(path).toString(), sha256(Files.readAllBytes(path))); + } + try (Stream templates = Files.walk(EMITTER_ROOT.resolve("templates"))) { + for (Path path : templates.filter(Files::isRegularFile).sorted().toList()) { + inventory.put(EMITTER_ROOT.relativize(path).toString(), sha256(Files.readAllBytes(path))); + } + } + inventory.put("package-target.schema.yaml", sha256(Files.readAllBytes(EMITTER_ROOT.resolve("package-target.schema.yaml")))); + inventory.put("build.gradle.kts", sha256(Files.readAllBytes(EMITTER_ROOT.resolve("build.gradle.kts")))); + return sha256(Json.MAPPER.writeValueAsString(inventory).getBytes(StandardCharsets.UTF_8)); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static PackageTarget developmentTarget() { + return Target.load(TARGET).withEmitterSha256(emitterDigest()); + } + + static String rootOf(ObjectNode model) { + return model.path("rootExtension").path("id").asText(); + } + + static List directDependencies(ObjectNode model) { + List result = new ArrayList<>(); + for (var extension : Json.list(model.path("extensions"))) { + if (extension.path("id").asText().equals(rootOf(model))) { + for (var dependency : Json.list(extension.path("dependencies"))) { + result.add(dependency.asText()); + } + } + } + return result; + } + + static String javaPackageFor(String name) { + return "io.runtimeconditions.conformance.c" + name.replaceAll("[^a-z0-9]", ""); + } + + static PackageTarget targetFor(String name, ObjectNode model, Map providers) { + PackageTarget base = developmentTarget(); + List dependencies = new ArrayList<>(); + for (Map.Entry provider : providers.entrySet()) { + int index = provider.getValue(); + dependencies.add( + new Dependency( + provider.getKey(), "io.runtimeconditions.providers", "provider-" + index, "provider.p" + index, "1.0.0")); + } + return new PackageTarget( + name, + rootOf(model), + "io.runtimeconditions.conformance", + name, + javaPackageFor(name), + base.version(), + "bindings/" + name + "/java", + base.minimumJavaVersion(), + base.publicationMode(), + base.repositoryUrl(), + base.registryId(), + dependencies, + base.emitterSha256()); + } + + /** The direct dependencies and the owners of imported declarations, each with its own provider package. */ + static Map providersOf(ObjectNode model) { + Map providers = new LinkedHashMap<>(); + for (String extension : directDependencies(model)) { + providers.put(extension, providers.size()); + } + for (var item : Json.list(model.path("vocabulary").path("importedDeclarations"))) { + providers.putIfAbsent(item.path("owner").asText(), providers.size()); + } + return providers; + } + + static EmissionPlan planFor(ObjectNode model, String name) { + return Plan.build(model, targetFor(name, model, providersOf(model))); + } + + record Built(Path directory, Path stubs, ObjectNode model, EmissionPlan plan) {} + + static void writeProviderStubs(EmissionPlan plan, Path stubs, String markerOverride) { + Map> byProvider = new TreeMap<>(); + for (ImportedDeclarationPlan item : plan.importedDeclarations()) { + byProvider.computeIfAbsent(item.providerPackage().javaPackage(), key -> new ArrayList<>()).add(item); + } + try { + for (Map.Entry> entry : byProvider.entrySet()) { + Path directory = stubs.resolve(entry.getKey().replace('.', '/')); + Files.createDirectories(directory); + String header = "package " + entry.getKey() + ";\n\n"; + Files.writeString(directory.resolve("Declaration.java"), header + "public final class Declaration {\n Declaration() {}\n}\n"); + StringBuilder functions = new StringBuilder(header + "public final class Bindings {\n private Bindings() {}\n"); + for (ImportedDeclarationPlan item : entry.getValue()) { + Files.writeString( + directory.resolve(item.protocol() + ".java"), + header + + "public interface " + + item.protocol() + + " {\n void " + + (markerOverride == null ? item.method() : markerOverride) + + "();\n}\n"); + functions + .append("\n public static Declaration ") + .append(item.function()) + .append("(") + .append(item.protocol()) + .append("... fields) {\n return new Declaration();\n }\n"); + } + functions.append("}\n"); + Files.writeString(directory.resolve("Bindings.java"), functions.toString()); + } + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static Built buildCase(String name, Path workspace, String markerOverride) { + ObjectNode model = Model.load(modelPath(name)); + EmissionPlan plan = Plan.build(model, targetFor(name, model, providersOf(model))); + Path stubs = workspace.resolve("stubs"); + writeProviderStubs(plan, stubs, markerOverride); + Path directory = workspace.resolve("pkg").resolve(name); + Source.emitPackage(plan, model, directory); + return new Built(directory, stubs, model, plan); + } + + static void writeConformance(Built built) { + try { + Files.writeString( + built.directory().resolve(Source.sourcePath(built.plan(), "Exercise")), + Conformance.source(built.plan(), built.model()), + StandardCharsets.UTF_8); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + record Compiled(boolean success, String output) {} + + static Compiled compile(List roots, Path classes) { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List files = new ArrayList<>(); + for (Path root : roots) { + if (!Files.exists(root)) { + continue; + } + try (Stream walk = Files.walk(root)) { + walk.filter(path -> path.toString().endsWith(".java")).sorted().forEach(files::add); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + try (StandardJavaFileManager manager = compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + Files.createDirectories(classes); + List options = List.of("--release", "17", "-Xlint:all", "-Werror", "-proc:none", "-d", classes.toString()); + boolean success = + compiler.getTask(null, manager, diagnostics, options, null, manager.getJavaFileObjectsFromPaths(files)).call(); + StringBuilder output = new StringBuilder(); + for (Diagnostic item : diagnostics.getDiagnostics()) { + output.append(item.getKind()).append(' ').append(item.getMessage(null)).append('\n'); + } + return new Compiled(success, output.toString()); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static Compiled compileBuilt(Built built, Path classes) { + return compile(List.of(built.directory().resolve("src/main/java"), built.stubs()), classes); + } + + static String yaml(com.fasterxml.jackson.databind.JsonNode node) { + org.snakeyaml.engine.v2.api.DumpSettings settings = + org.snakeyaml.engine.v2.api.DumpSettings.builder() + .setDefaultFlowStyle(org.snakeyaml.engine.v2.common.FlowStyle.BLOCK) + .setWidth(Integer.MAX_VALUE) + .setSplitLines(false) + .build(); + return new org.snakeyaml.engine.v2.api.Dump(settings).dumpToString(Json.MAPPER.convertValue(node, Object.class)); + } + + static Map snapshot(Path directory) { + Map result = new TreeMap<>(); + try (Stream walk = Files.walk(directory)) { + for (Path path : walk.filter(Files::isRegularFile).sorted().toList()) { + result.put(directory.relativize(path).toString(), Files.readString(path, StandardCharsets.UTF_8)); + } + } catch (IOException error) { + throw new UncheckedIOException(error); + } + return result; + } +} diff --git a/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TransitiveTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TransitiveTest.java new file mode 100644 index 0000000..e7f7195 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TransitiveTest.java @@ -0,0 +1,147 @@ +package io.runtimeconditions.bindings.emitter; + +import static io.runtimeconditions.bindings.emitter.TestSupport.compile; +import static io.runtimeconditions.bindings.emitter.TestSupport.delete; +import static io.runtimeconditions.bindings.emitter.TestSupport.developmentTarget; +import static io.runtimeconditions.bindings.emitter.TestSupport.modelPath; +import static io.runtimeconditions.bindings.emitter.TestSupport.temporaryDirectory; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.runtimeconditions.bindings.emitter.Plan.EmissionPlan; +import io.runtimeconditions.bindings.emitter.Target.Dependency; +import io.runtimeconditions.bindings.emitter.Target.PackageTarget; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** root -> middle -> leaf, where leaf owns the declaration the root implements. */ +final class TransitiveTest { + private static final String CASE = "03-transitive-closure"; + + private record Package(PackageTarget target, ObjectNode model, EmissionPlan plan) {} + + private static PackageTarget target(String key, String root, List dependencies) { + PackageTarget base = developmentTarget(); + return new PackageTarget( + key, + root, + "io.runtimeconditions.transitive", + key, + "io.runtimeconditions.transitive." + key, + base.version(), + "bindings/" + key + "/java", + base.minimumJavaVersion(), + base.publicationMode(), + base.repositoryUrl(), + base.registryId(), + dependencies, + base.emitterSha256()); + } + + private static Dependency dependency(String extension, PackageTarget target) { + return new Dependency(extension, target.groupId(), target.artifactId(), target.javaPackage(), target.version()); + } + + private static JsonNode extension(ObjectNode model, String id) { + for (JsonNode item : Json.list(model.path("extensions"))) { + if (item.path("id").asText().equals(id)) { + return item; + } + } + throw new IllegalStateException(id); + } + + private static String string(JsonNode node) { + return node.asText(); + } + + @Test + void threeGeneratedPackagesCompileTogether() throws Exception { + ObjectNode model = Model.load(modelPath(CASE)); + String rootId = model.path("rootExtension").path("id").asText(); + String leafId = string(model.path("vocabulary").path("importedDeclarations").get(0).path("owner")); + String middleId = null; + for (JsonNode item : Json.list(model.path("extensions"))) { + for (JsonNode dependency : Json.list(item.path("dependencies"))) { + if (dependency.asText().equals(leafId)) { + middleId = item.path("id").asText(); + } + } + } + assertTrue(middleId != null); + + ObjectNode leafModel = model.deepCopy(); + leafModel.set("rootExtension", extension(model, leafId).deepCopy()); + ((ArrayNode) leafModel.path("extensions")).removeAll(); + ((ArrayNode) leafModel.path("extensions")).add(extension(model, leafId).deepCopy()); + ((ObjectNode) leafModel.path("vocabulary")).set("ownedDeclarations", model.path("vocabulary").path("importedDeclarations").deepCopy()); + ((ObjectNode) leafModel.path("vocabulary")).set("importedDeclarations", Json.MAPPER.createArrayNode()); + PackageTarget leafTarget = target("leaf", leafId, List.of()); + + ObjectNode middleModel = model.deepCopy(); + middleModel.set("rootExtension", extension(model, middleId).deepCopy()); + ((ArrayNode) middleModel.path("extensions")).removeAll(); + ((ArrayNode) middleModel.path("extensions")).add(extension(model, leafId).deepCopy()); + ((ArrayNode) middleModel.path("extensions")).add(extension(model, middleId).deepCopy()); + ((ObjectNode) middleModel.path("vocabulary")).set("ownedDeclarations", Json.MAPPER.createArrayNode()); + PackageTarget middleTarget = target("middle", middleId, List.of(dependency(leafId, leafTarget))); + + // The root's target names the middle package it depends on and the leaf package that owns the marker. + PackageTarget rootTarget = + target("root", rootId, List.of(dependency(leafId, leafTarget), dependency(middleId, middleTarget))); + + Path workspace = temporaryDirectory(); + try { + List sources = new java.util.ArrayList<>(); + Package[] packages = { + new Package(leafTarget, leafModel, Plan.build(leafModel, leafTarget)), + new Package(middleTarget, middleModel, Plan.build(middleModel, middleTarget)), + new Package(rootTarget, model, Plan.build(model, rootTarget)) + }; + for (Package item : packages) { + Path directory = workspace.resolve(item.target().packageKey()); + Source.emitPackage(item.plan(), item.model(), directory); + Verification.verifyPackage(item.plan(), directory, item.model()); + sources.add(directory.resolve("src/main/java")); + } + StringBuilder root = new StringBuilder(); + try (java.util.stream.Stream walk = Files.walk(workspace.resolve("root/src/main/java"))) { + for (Path file : walk.filter(item -> item.toString().endsWith(".java")).sorted().toList()) { + root.append(Files.readString(file)); + } + } + assertTrue(root.toString().contains("import io.runtimeconditions.transitive.leaf.WorkerField;"), root.toString()); + String pom = Files.readString(workspace.resolve("root/pom.xml")); + assertTrue(pom.contains("leaf") && pom.contains("middle"), pom); + // The middle package declares nothing of its own, so it has no Java source directory. + assertTrue(!Files.exists(workspace.resolve("middle/src/main/java"))); + TestSupport.Compiled compiled = compile(sources, workspace.resolve("classes")); + assertTrue(compiled.success(), compiled.output()); + } finally { + delete(workspace); + } + } + + @Test + void anImportedDeclarationOwnerWithoutAnEntryIsRejected() { + ObjectNode model = Model.load(modelPath(CASE)); + String rootId = model.path("rootExtension").path("id").asText(); + String middleId = string(extension(model, rootId).path("dependencies").get(0)); + PackageTarget middleTarget = target("middle", middleId, List.of()); + PackageTarget rootTarget = target("root", rootId, List.of(dependency(middleId, middleTarget))); + DiagnosticError missing = assertThrows(DiagnosticError.class, () -> Plan.build(model, rootTarget)); + assertEquals("RCJ1011", missing.diagnostic().code()); + + PackageTarget stranger = target("stranger", "https://example.com/unrelated:1.0.0", List.of()); + PackageTarget withExtra = + target("root", rootId, List.of(dependency(middleId, middleTarget), dependency(stranger.rootExtension(), stranger))); + DiagnosticError extra = assertThrows(DiagnosticError.class, () -> Plan.build(model, withExtra)); + assertEquals("RCJ1009", extra.diagnostic().code()); + } +} diff --git a/tooling/extension-bindings/emitters/java/templates/dependencies.xml.tmpl b/tooling/extension-bindings/emitters/java/templates/dependencies.xml.tmpl new file mode 100644 index 0000000..f821841 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/templates/dependencies.xml.tmpl @@ -0,0 +1,3 @@ + +{{items}} + diff --git a/tooling/extension-bindings/emitters/java/templates/dependency.xml.tmpl b/tooling/extension-bindings/emitters/java/templates/dependency.xml.tmpl new file mode 100644 index 0000000..f403808 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/templates/dependency.xml.tmpl @@ -0,0 +1,5 @@ + + {{groupId}} + {{artifactId}} + {{version}} + diff --git a/tooling/extension-bindings/emitters/java/templates/pom.xml.tmpl b/tooling/extension-bindings/emitters/java/templates/pom.xml.tmpl new file mode 100644 index 0000000..188a13e --- /dev/null +++ b/tooling/extension-bindings/emitters/java/templates/pom.xml.tmpl @@ -0,0 +1,31 @@ + + + + 4.0.0 + {{groupId}} + {{artifactId}} + {{version}} + jar + {{artifactId}} + {{repositoryUrl}} + + {{repositoryUrl}} + scm:git:{{scmConnection}} + + + {{minimumJavaVersion}} + UTF-8 + +{{dependencies}} + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + + diff --git a/tooling/extension-bindings/emitters/java/testdata/negative-diagnostics.yaml b/tooling/extension-bindings/emitters/java/testdata/negative-diagnostics.yaml new file mode 100644 index 0000000..2d8d592 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/testdata/negative-diagnostics.yaml @@ -0,0 +1,32 @@ +- case: reserved-global + positiveCase: 01-owned-kind-interface + expected: + category: java-naming + code: RCJ2001 + coordinate: conformance-owned-kind-interface + jsonPointer: '' + message: "symbol 'Record' conflicts: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0#schema:service-http/properties/region and java-global:Record" +- case: fixed-function-collision + positiveCase: 01-owned-kind-interface + expected: + category: java-naming + code: RCJ2001 + coordinate: conformance-owned-kind-interface + jsonPointer: '' + message: "fixed symbol 'ServiceField' conflicts: kind:Service and kind:service" +- case: enum-member-collision + positiveCase: 10-scoped-domains-collisions + expected: + category: java-naming + code: RCJ2001 + coordinate: conformance-owned-kind-interface + jsonPointer: '' + message: "symbol 'STREAMING' conflicts after full path: https://runtimeconditions.io/conformance/scoped-domains-collisions:1.0.0#schema:service-grpc/properties/mode#value:STREAMING and https://runtimeconditions.io/conformance/scoped-domains-collisions:1.0.0#schema:service-grpc/properties/mode#value:streaming" +- case: unnameable-field + positiveCase: 01-owned-kind-interface + expected: + category: java-naming + code: RCJ2000 + coordinate: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0#schema:service-http/properties/region + jsonPointer: '' + message: "cannot derive Java identifier from '💥'" diff --git a/tooling/extension-bindings/emitters/java/testdata/package-target.yaml b/tooling/extension-bindings/emitters/java/testdata/package-target.yaml new file mode 100644 index 0000000..d66f74a --- /dev/null +++ b/tooling/extension-bindings/emitters/java/testdata/package-target.yaml @@ -0,0 +1,12 @@ +apiVersion: runtimeconditions.io/java-package-target/v1alpha1 +kind: RuntimeConditionsJavaPackageTarget +packageKey: conformance-owned-kind-interface +rootExtension: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0 +groupId: io.runtimeconditions.bindings +artifactId: conformance-owned-kind-interface +javaPackage: io.runtimeconditions.bindings.conformance.ownedkindinterface +version: 1.0.0 +sourceDirectory: bindings/conformance-owned-kind-interface/java +minimumJavaVersion: '17' +publicationMode: github-tag +repositoryUrl: https://github.com/runtimeconditions/extensions diff --git a/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml b/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml index e4ac3c0..ff3f4f2 100644 --- a/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml +++ b/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml @@ -58,20 +58,22 @@ properties: name, and version is the resolved module version. For Python, coordinate is the distribution name, name is the import package, and version is the installed distribution version. For TypeScript, coordinate and name are - the npm package name and version is the installed package version. - Consumers must compare all applicable fields to native package-manager - metadata. + the npm package name and version is the installed package version. For + Java, coordinate is the Maven groupId:artifactId, name is the Java + package, and version is the installed artifact version. Consumers must + compare all applicable fields to native package-manager metadata. type: object additionalProperties: false required: [language, coordinate, name, version] properties: - language: {enum: [go, python, typescript]} + language: {enum: [go, python, typescript, java]} coordinate: {type: string, minLength: 1} name: {type: string, minLength: 1} version: {type: string, minLength: 1} minimumGoVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} minimumPythonVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} minimumNodeVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} + minimumJavaVersion: {type: string, pattern: '^[0-9]+$'} allOf: - if: properties: @@ -84,6 +86,7 @@ properties: anyOf: - required: [minimumPythonVersion] - required: [minimumNodeVersion] + - required: [minimumJavaVersion] - if: properties: language: {const: python} @@ -95,6 +98,7 @@ properties: anyOf: - required: [minimumGoVersion] - required: [minimumNodeVersion] + - required: [minimumJavaVersion] - if: properties: language: {const: typescript} @@ -106,6 +110,19 @@ properties: anyOf: - required: [minimumGoVersion] - required: [minimumPythonVersion] + - required: [minimumJavaVersion] + - if: + properties: + language: {const: java} + then: + required: [minimumJavaVersion] + properties: + name: {pattern: '^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$'} + not: + anyOf: + - required: [minimumGoVersion] + - required: [minimumPythonVersion] + - required: [minimumNodeVersion] declarations: type: array items: {$ref: '#/$defs/declaration'} @@ -170,6 +187,25 @@ allOf: items: properties: file: {const: bindings.ts} + - if: + properties: + package: + properties: + language: {const: java} + then: + properties: + declarations: + items: + required: [functionClass] + properties: + file: {pattern: '^[A-Za-z_][A-Za-z0-9_]*\.java$'} + importedMarkerContracts: + items: + required: [providerPackage] + types: + items: + properties: + file: {pattern: '^[A-Za-z_][A-Za-z0-9_]*\.java$'} $defs: extensionIdentifier: type: string @@ -212,12 +248,14 @@ $defs: Exactly one local native type or language builtin. Pointer records Go pointer syntax; nullable records a Python annotation accepting None. TypeScript records optional properties through the field's required flag. + Java builtins are the boxed java.lang names; a nullable value is Void or + Object. A consumer must resolve every type to exactly one entry in types. type: object additionalProperties: false properties: type: {$ref: '#/$defs/nativeIdentifier'} - builtin: {enum: [string, bool, int64, float64, 'struct{}', any, str, int, float, 'None', JSONValue, boolean, number, 'null']} + builtin: {enum: [string, bool, int64, float64, 'struct{}', any, str, int, float, 'None', JSONValue, boolean, number, 'null', String, Boolean, Long, Double, Void, Object]} pointer: {const: true} nullable: {const: true} not: @@ -236,7 +274,8 @@ $defs: function: {$ref: '#/$defs/nativeIdentifier'} markerInterface: {$ref: '#/$defs/nativeIdentifier'} markerMethod: {$ref: '#/$defs/nativeIdentifier'} - file: {enum: [bindings.go, bindings.py, bindings.ts]} + functionClass: {$ref: '#/$defs/nativeIdentifier'} + file: {type: string, minLength: 1} importedMarkerContract: type: object additionalProperties: false @@ -351,7 +390,7 @@ $defs: sourceName: {type: string, minLength: 1} nativeName: {$ref: '#/$defs/nativeIdentifier'} construct: {enum: [scalar, object, collection, map, union, any, wrapper]} - file: {enum: [bindings.go, bindings.py, bindings.ts]} + file: {type: string, minLength: 1} implements: type: array items: {$ref: '#/$defs/implements'} diff --git a/tooling/extension-bindings/toolchain.lock.yaml b/tooling/extension-bindings/toolchain.lock.yaml index 4d5e93b..e8c7851 100644 --- a/tooling/extension-bindings/toolchain.lock.yaml +++ b/tooling/extension-bindings/toolchain.lock.yaml @@ -30,3 +30,15 @@ languages: yaml: '2.9.1' ajv: '8.20.0' ajv-formats: '3.0.1' + java: + languageVersion: '21.0.12' + packageManager: maven + buildBackend: gradle + tools: + gradle: '8.14.2' + maven-compiler-plugin: '3.14.0' + snakeyaml-engine: '3.2' + json-schema-validator: '1.5.9' + jackson-databind: '2.22.4' + slf4j-nop: '2.0.17' + junit-bom: '6.1.3'