From 3ea988576b487a8bff7caf37ea357d66abd2d0c5 Mon Sep 17 00:00:00 2001 From: itvi-1234 Date: Tue, 20 Oct 2026 18:36:26 +0530 Subject: [PATCH 1/4] Add a Java binding emitter A native emitter, written in Java, turns a normalized binding model into a typed Maven project: one source file per type under the package's source tree, pom.xml, and the structural binding manifest. It follows the Python emitter's command contract: emit, verify, and api. - Declarations are static methods of Bindings taking a per-kind marker interface. Object shapes are records implementing it, so a field of another kind, or one from a mismatched provider, does not compile. - Arrays and maps are wrapper records, unions are sealed interfaces with nested variant records, and string domains are enums. - Input is loaded under the Python emitter's restrictions and validated against the shared binding-model schema. Diagnostics use the RCJ prefix. - verify parses the generated source with the JDK syntax tree API and compares its API and manifest with the model plan. - The binding-manifest schema gains the java language, the toolchain lock gains languages.java, and CI gains a verify-java-emitter job and a placeholder workflow for Maven publication. Signed-off-by: itvi-1234 --- .github/workflows/binding-publish-maven.yml | 36 + .../extension-binding-normalizer.yml | 31 + tooling/extension-bindings/README.md | 5 +- .../emitters/java/.gitignore | 2 + .../emitters/java/CONTRACT.md | 169 +++++ .../emitters/java/build.gradle.kts | 49 ++ .../emitters/java/gradle.lockfile | 26 + .../emitters/java/package-target.schema.yaml | 98 +++ .../emitters/java/settings.gradle.kts | 1 + .../bindings/emitter/Cli.java | 77 ++ .../bindings/emitter/DiagnosticError.java | 39 + .../bindings/emitter/Json.java | 41 ++ .../bindings/emitter/Main.java | 11 + .../bindings/emitter/Metadata.java | 470 ++++++++++++ .../bindings/emitter/Model.java | 99 +++ .../bindings/emitter/Naming.java | 334 +++++++++ .../bindings/emitter/Plan.java | 695 ++++++++++++++++++ .../bindings/emitter/Source.java | 330 +++++++++ .../bindings/emitter/Target.java | 282 +++++++ .../bindings/emitter/Verification.java | 359 +++++++++ .../bindings/emitter/YamlIo.java | 235 ++++++ .../bindings/emitter/CliTest.java | 145 ++++ .../bindings/emitter/Conformance.java | 265 +++++++ .../bindings/emitter/ConformanceTest.java | 177 +++++ .../bindings/emitter/ContractTest.java | 158 ++++ .../bindings/emitter/LoaderTest.java | 157 ++++ .../bindings/emitter/NamingTest.java | 126 ++++ .../bindings/emitter/NegativeTest.java | 99 +++ .../bindings/emitter/RealModelsTest.java | 79 ++ .../bindings/emitter/TestSupport.java | 271 +++++++ .../java/testdata/negative-diagnostics.yaml | 32 + .../java/testdata/package-target.yaml | 12 + ...imeconditions.binding-manifest.schema.yaml | 53 +- .../extension-bindings/toolchain.lock.yaml | 12 + 34 files changed, 4966 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/binding-publish-maven.yml create mode 100644 tooling/extension-bindings/emitters/java/.gitignore create mode 100644 tooling/extension-bindings/emitters/java/CONTRACT.md create mode 100644 tooling/extension-bindings/emitters/java/build.gradle.kts create mode 100644 tooling/extension-bindings/emitters/java/gradle.lockfile create mode 100644 tooling/extension-bindings/emitters/java/package-target.schema.yaml create mode 100644 tooling/extension-bindings/emitters/java/settings.gradle.kts create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Cli.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/DiagnosticError.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Json.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Main.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Metadata.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Model.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Naming.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Source.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Target.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Verification.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/YamlIo.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/CliTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/Conformance.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ConformanceTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ContractTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/LoaderTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NamingTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/NegativeTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/RealModelsTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java create mode 100644 tooling/extension-bindings/emitters/java/testdata/negative-diagnostics.yaml create mode 100644 tooling/extension-bindings/emitters/java/testdata/package-target.yaml diff --git a/.github/workflows/binding-publish-maven.yml b/.github/workflows/binding-publish-maven.yml new file mode 100644 index 0000000..f841195 --- /dev/null +++ b/.github/workflows/binding-publish-maven.yml @@ -0,0 +1,36 @@ +name: Publish Java bindings + +on: + workflow_dispatch: + inputs: + targets: + description: 'Comma-separated package-key:java targets, or all' + type: string + default: '' + dry_run: + description: 'Verify publication without pushing to Maven' + 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: Publish to Maven (placeholder) + env: + TARGETS: ${{ inputs.targets }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + echo "Maven publication is not implemented; no package was published." + echo "targets=${TARGETS:-} dry_run=${DRY_RUN}" 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..a4e331f 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 Java emitter's contract is in `CONTRACT.md`. 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,6 +144,7 @@ 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 ``` 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/CONTRACT.md b/tooling/extension-bindings/emitters/java/CONTRACT.md new file mode 100644 index 0000000..ab930a3 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/CONTRACT.md @@ -0,0 +1,169 @@ +# Java emitter contract + +This records the Java target, layout, and naming decisions for the native Java +emitter. It is written in Java, as the implementation standard requires of every +emitter, and follows the Python emitter's structure. + +The emitter consumes one validated binding model and one selected, resolved Java +target through the shared command contract: + +```text +runtimeconditions-binding-emitter-java emit \ + --model runtimeconditions.binding-model.yaml \ + --package-config --output +``` + +`verify --model --package-config --input ` parses the generated source +with the JDK compiler's syntax tree API (never compiling or running it), checks +its public API against the model plan, and compares the structural manifest with +a fresh rendering. `api --input ` prints the structural signatures. + +## Target configuration + +The selected target uses `runtimeconditions.io/java-package-target/v1alpha1` and +`RuntimeConditionsJavaPackageTarget`. Its shape is defined by +`package-target.schema.yaml`; the emitter validates it field by field with the +same diagnostics as the schema. + +- `packageKey`, `rootExtension`, `version`, `sourceDirectory`, and + `publicationMode` correspond to the package set and language entry in the + catalog. `sourceDirectory` is exactly `bindings//java`. +- `groupId` and `artifactId` are the Maven coordinate. `javaPackage` is the Java + package every generated type is declared in. None of them is inferred from + extension vocabulary. +- `minimumJavaVersion` is a whole release number of at least `17`. It becomes + `maven.compiler.release`. +- `repositoryUrl` supplies the package repository metadata and must use HTTPS. + A registry target additionally requires `registryId`. +- `dependencies` contains only direct extension dependencies, with their Maven + coordinates, Java packages, and exact tested versions, and equals the model's + direct dependency set. +- `emitterSha256` is supplied by the caller and recorded in the manifest. + +Package versions are SemVer without a leading `v` or build metadata and map +unchanged to Maven. Supported prerelease forms are `-alpha.N`, `-beta.N`, and +`-rc.N`. Direct dependency ranges use the Maven interval `[,)`, +where `next-breaking` is `0.(y+1).0` below `1.0.0` and `(major+1).0.0` otherwise. + +## Generated source tree + +```text +/ + pom.xml + src/main/java// + Declaration.java + Bindings.java + Field.java + .java + src/main/resources/runtimeconditions/ + runtimeconditions.bindings.yaml +``` + +Every public type is declared in its own file, named after the type. The +remaining binding resources belong beside the manifest in +`src/main/resources/runtimeconditions/` so that they are packaged in the jar. +Only readable Java source and the project file are committed; the jar is a build +output. + +Generated source starts with the non-editable header naming the model digest and +emitter version, and is rendered directly by the emitter, so its formatting does +not depend on an external formatter. `pom.xml` carries the same header in an XML +comment. + +## API rules + +1. Every owned kind becomes one static method `Bindings.kind(KindField... fields)` + returning `Declaration`. `Declaration` is an inert final class with a + package-private constructor; the method body only checks its argument. +2. `Field` is an interface whose only member is a marker method named + `rcMarker_<16 hex digits>`, derived from the owner and declaration + coordinate. Every applicable object record declares that method and + `implements` the interface, so a field belonging to another kind does not + compile. Cross-package additive fields implement the provider's interface in + the same way. +3. Objects become records whose components are the fields, required components + first. A required component of a non-nullable type is checked with + `Objects.requireNonNull`; an optional component is `null` when absent. Records + are shallowly immutable. The emitter does not add factories or builders. +4. Scalars map to boxed types so that an absent optional value is `null`: + `String`, `Boolean`, `Long` for integers, `Double` for numbers, `Void` for + JSON null, and `Object` for any JSON value. +5. Arrays and maps become single-component wrapper records named after the + shape, holding `List` or `Map`. Naming the wrapper keeps + recursive shapes expressible. +6. Unions become a `sealed interface` with one nested record per variant, + `Variant1`, `Variant2`, …, each holding its variant's value. +7. String value domains become `enum` types whose constants are upper snake case + and whose `value()` returns the serialized string. Integer and number shapes + remain distinct. +8. Imported declarations are not re-declared. A type implementing an imported + marker imports that interface from its direct provider package. + +Names derive only from exact source names. Types and marker interfaces use Pascal +case, fields and methods use camel case, and enum constants use upper snake case. +A leading digit gains `X` (types and constants) or `x`. A Java keyword, literal, +contextual type name, or an `Object` method name that cannot be a record component +gains one trailing underscore. A name that equals a `java.lang` or `java.util` +type the generated source uses (`Record`, `String`, `List`, …) or a generated +helper (`Declaration`, `Bindings`) stops emission instead of being renamed. +Collisions are resolved only by prepending parent path components, nearest first, +and otherwise stop emission. + +## Diagnostics + +Codes use the `RCJ` prefix and mirror the meanings of the Python `RCP` codes. + +| Code | Meaning | +| --- | --- | +| `RCJ1001` | YAML input cannot be read or exceeds the size limit. | +| `RCJ1002` | YAML syntax, profile, structure, or resource-limit failure. | +| `RCJ1003` | Binding-model schema validation failure. | +| `RCJ1004` | Unsupported binding-model `apiVersion`. | +| `RCJ1005` | Unsupported binding-model `kind`. | +| `RCJ1006` | Invalid Java package-target field or dependency metadata. | +| `RCJ1007` | Target source directory does not match its package key. | +| `RCJ1008` | Unsupported minimum Java version. | +| `RCJ1009` | Target root extension or direct dependency set differs from the model. | +| `RCJ1010` | Unknown structural shape kind. | +| `RCJ1011` | Inconsistent extension closure or declaration ownership. | +| `RCJ1012` | Inconsistent shape details, value domain, or unresolved reference. | +| `RCJ2000` | A source name cannot be derived as a valid Java identifier. | +| `RCJ2001` | Symbol collision or reserved-name conflict. | +| `RCJ3001` | Output directory is not new or empty. | +| `RCJ3002` | Output directory cannot be inspected or written. | + +## Input loading + +The model is read with the Python emitter's restrictions: UTF-8 without BOM, LF +line endings, one trailing newline, no comments, anchors, aliases, or tags, +string mapping keys only, no duplicate keys, finite numbers, and the same size, +node, and depth limits. It is then validated against +`model/runtimeconditions.binding-model.schema.yaml`, which the build copies into +the emitter's resources. The schema can be overridden with +`RC_BINDING_MODEL_SCHEMA` when the emitter runs against a different checkout. + +## Verification + +The tests run the shared conformance models in `model/conformance/expected` and +the committed first-party models. For each, they check that three independent +emissions are byte-identical, that `verify` accepts the output, and that the +package compiles with `javac --release 17 -Xlint:all -Werror`. A generated +conformance source constructs every type, enum constant, collection, map, union +variant, and declaration. Imported markers are checked against type-only provider +stubs, and a stub with a different marker method must fail to compile. Negative +tests cover invalid inputs, tampered output, and values the model does not allow. + +## Design notes + +- **Language key and layout.** The catalog language key is `java`, and a package + lives in `bindings//java`. +- **Functions.** Java has no free functions, so declaration functions are static + methods of `Bindings`. The manifest names that class in `functionClass` and + records the file that declares the method. +- **Unions.** Java has no union types. Each variant is wrapped in a nested + record so that object, scalar, and collection variants are all expressed the + same way and a record can belong to more than one union. +- **Build tooling.** The emitter is built with Gradle and its dependencies are + locked in `gradle.lockfile`. The generated project is a Maven project; the + emitter never runs Maven. JDK 21.0.12, Gradle 8.14.2, and the library versions + are recorded under `languages.java` in `toolchain.lock.yaml`. 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..cb543c1 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/build.gradle.kts @@ -0,0 +1,49 @@ +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 { + 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..03f375e --- /dev/null +++ b/tooling/extension-bindings/emitters/java/package-target.schema.yaml @@ -0,0 +1,98 @@ +$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]*))?$' +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..702e41b --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Metadata.java @@ -0,0 +1,470 @@ +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.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.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +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 = "runtimeconditions-binding-emitter-java"; + public static final String EMITTER_VERSION = "0.1.0"; + public static final String COMPILER_PLUGIN_VERSION = "3.14.0"; + public static final String MANIFEST_PATH = "src/main/resources/runtimeconditions/runtimeconditions.bindings.yaml"; + + private Metadata() {} + + 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 dependencyInterval(String value, String coordinate) { + Matcher match = Target.VERSION.matcher(value); + if (!match.matches()) { + throw fail("package-config", "RCJ1006", coordinate, "unsupported package version '" + value + "'", "/version"); + } + int major = Integer.parseInt(match.group(1)); + int minor = Integer.parseInt(match.group(2)); + 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 text = new StringBuilder(); + text.append("\n"); + text.append("\n"); + text.append("\n"); + text.append(" 4.0.0\n"); + text.append(" ").append(xml(target.groupId())).append("\n"); + text.append(" ").append(xml(target.artifactId())).append("\n"); + text.append(" ").append(xml(target.version())).append("\n"); + text.append(" jar\n"); + text.append(" ").append(xml(target.artifactId())).append("\n"); + text.append(" ").append(xml(target.repositoryUrl())).append("\n"); + text.append(" \n"); + text.append(" ").append(xml(target.repositoryUrl())).append("\n"); + text.append(" scm:git:").append(xml(repository)).append("\n"); + text.append(" \n"); + text.append(" \n"); + text.append(" ").append(target.minimumJavaVersion()).append("\n"); + text.append(" UTF-8\n"); + text.append(" \n"); + if (!dependencies.isEmpty()) { + text.append(" \n"); + for (Dependency dependency : dependencies) { + text.append(" \n"); + text.append(" ").append(xml(dependency.groupId())).append("\n"); + text.append(" ").append(xml(dependency.artifactId())).append("\n"); + text.append(" ") + .append(xml(dependencyInterval(dependency.version(), dependency.extension()))) + .append("\n"); + text.append(" \n"); + } + text.append(" \n"); + } + text.append(" \n"); + text.append(" \n"); + text.append(" \n"); + text.append(" org.apache.maven.plugins\n"); + text.append(" maven-compiler-plugin\n"); + text.append(" ").append(COMPILER_PLUGIN_VERSION).append("\n"); + text.append(" \n"); + text.append(" \n"); + text.append(" \n"); + text.append("\n"); + return text.toString(); + } + + 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 (!Target.SHA.matcher(plan.target().emitterSha256()).matches()) { + throw fail( + "package-config", + "RCJ1006", + plan.target().packageKey(), + "emitterSha256 must be a 64-character lowercase SHA-256 of the emitter tool", + "/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..a8c9cea --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java @@ -0,0 +1,695 @@ +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); + } + + 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(JsonNode model, PackageTarget target, String owner) { + Map> graph = new LinkedHashMap<>(); + for (JsonNode item : Json.list(model.path("extensions"))) { + List dependencies = new ArrayList<>(); + for (JsonNode dependency : Json.list(item.path("dependencies"))) { + dependencies.add(dependency.asText()); + } + graph.put(item.path("id").asText(), dependencies); + } + List dependencies = new ArrayList<>(target.dependencies()); + dependencies.sort(Comparator.comparing(Dependency::extension)); + for (Dependency dependency : dependencies) { + if (reaches(graph, dependency.extension(), owner, new HashSet<>())) { + return new ProviderPackage(dependency.coordinate(), dependency.javaPackage()); + } + } + throw fail("model", "RCJ1011", owner, "imported declaration has no direct dependency provider"); + } + + private static boolean reaches(Map> graph, String current, String owner, Set visited) { + if (current.equals(owner)) { + return true; + } + if (!visited.add(current)) { + return false; + } + for (String dependency : graph.getOrDefault(current, List.of())) { + if (reaches(graph, dependency, owner, visited)) { + return true; + } + } + return false; + } + + 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<>(); + + 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); + } + + 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()); + } + List directSorted = new ArrayList<>(new TreeSet<>(direct)); + List configuredSorted = new ArrayList<>(configured); + if (!directSorted.equals(configuredSorted)) { + throw fail( + "package-config", + "RCJ1009", + pkg, + "direct dependency set differs: model=" + jsonList(directSorted) + ", target=" + jsonList(configuredSorted), + "/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(model, 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); + } + } + } + + for (; ; ) { + List required = new ArrayList<>(); + for (String item : builder.referencedDefinitions) { + if (pendingDefinitions.containsKey(item)) { + required.add(item); + } + } + required.sort(Comparator.naturalOrder()); + if (required.isEmpty()) { + break; + } + for (String definitionKey : required) { + Object[] pending = pendingDefinitions.remove(definitionKey); + JsonNode definition = (JsonNode) pending[0]; + @SuppressWarnings("unchecked") + List scope = (List) pending[1]; + builder.registerShape( + definition.path("shape"), definitionKey, definition.path("name").asText(), scope, null, null); + } + } + + 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()) { + fields.add( + new FieldPlan( + field.coordinate(), + field.sourceName(), + names.get(field.key()), + field.typeExpr(), + annotate(field.typeExpr(), 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(), + 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..1203dee --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Target.java @@ -0,0 +1,282 @@ +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.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +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"; + public static final Pattern VERSION = + Pattern.compile( + "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\\.(0|[1-9][0-9]*))?$"); + public static final Pattern KEY = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + public static final Pattern GROUP_ID = Pattern.compile("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$"); + public static final Pattern ARTIFACT_ID = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + public static final Pattern JAVA_PACKAGE = + Pattern.compile("^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)*$"); + public static final Pattern SHA = Pattern.compile("^[0-9a-f]{64}$"); + private static final Pattern JAVA_VERSION = Pattern.compile("^(0|[1-9][0-9]*)$"); + private static final int MINIMUM_JAVA = 17; + + public record Dependency( + String extension, String groupId, String artifactId, String javaPackage, String version) { + 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 final List ALLOWED = + List.of( + "apiVersion", + "kind", + "packageKey", + "rootExtension", + "groupId", + "artifactId", + "javaPackage", + "version", + "sourceDirectory", + "minimumJavaVersion", + "publicationMode", + "registryId", + "repositoryUrl", + "dependencies", + "emitterSha256"); + private static final Set OPTIONAL = Set.of("registryId", "dependencies", "emitterSha256"); + private static final List DEPENDENCY_FIELDS = + List.of("extension", "groupId", "artifactId", "javaPackage", "version"); + + private Target() {} + + private static DiagnosticError configFail(String message, String pointer) { + return fail("package-config", "RCJ1006", "package-target", message, pointer); + } + + private static DiagnosticError configFail(String message, String pointer, String coordinate) { + return fail("package-config", "RCJ1006", coordinate, message, pointer); + } + + private static String text(JsonNode value, String name, String pointer, String coordinate) { + JsonNode node = value.get(name); + if (node == null || !node.isTextual() || node.asText().isEmpty()) { + throw configFail("invalid " + name, pointer, coordinate); + } + return node.asText(); + } + + 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 missing = new ArrayList<>(); + for (String name : ALLOWED) { + if (!OPTIONAL.contains(name) && !value.has(name)) { + missing.add(name); + } + } + missing.sort(Comparator.naturalOrder()); + for (String name : missing) { + throw configFail("missing required field '" + name + "'", "/" + name); + } + Set unknown = new TreeSet<>(); + for (Iterator names = value.fieldNames(); names.hasNext(); ) { + String name = names.next(); + if (!ALLOWED.contains(name)) { + unknown.add(name); + } + } + for (String name : unknown) { + throw configFail("unknown field '" + name + "'", "/" + name); + } + if (!API.equals(value.path("apiVersion").asText(null)) + || !KIND.equals(value.path("kind").asText(null))) { + throw configFail("unsupported target apiVersion or kind", ""); + } + String emitterSha256 = ""; + if (value.has("emitterSha256")) { + JsonNode node = value.get("emitterSha256"); + if (!node.isTextual() || !SHA.matcher(node.asText()).matches()) { + throw configFail( + "emitterSha256 must be a 64-character lowercase SHA-256 of the emitter tool", + "/emitterSha256"); + } + emitterSha256 = node.asText(); + } + for (String name : ALLOWED) { + if (OPTIONAL.contains(name) || name.equals("apiVersion") || name.equals("kind")) { + continue; + } + text(value, name, "/" + name, "package-target"); + } + String packageKey = value.get("packageKey").asText(); + String groupId = value.get("groupId").asText(); + String artifactId = value.get("artifactId").asText(); + String javaPackage = value.get("javaPackage").asText(); + String version = value.get("version").asText(); + if (!KEY.matcher(packageKey).matches()) { + throw configFail("invalid packageKey", "/packageKey"); + } + if (!GROUP_ID.matcher(groupId).matches() || groupId.length() > 128) { + throw configFail("invalid groupId", "/groupId"); + } + if (!ARTIFACT_ID.matcher(artifactId).matches() || artifactId.length() > 128) { + throw configFail("invalid artifactId", "/artifactId"); + } + if (!validJavaPackage(javaPackage)) { + throw configFail("invalid javaPackage", "/javaPackage"); + } + if (!VERSION.matcher(version).matches()) { + throw configFail("unsupported package version", "/version"); + } + if (!value.get("sourceDirectory").asText().equals("bindings/" + packageKey + "/java")) { + throw fail( + "package-config", + "RCJ1007", + packageKey, + "source directory does not match package key", + "/sourceDirectory"); + } + String minimum = value.get("minimumJavaVersion").asText(); + Matcher parts = JAVA_VERSION.matcher(minimum); + if (!parts.matches() || Integer.parseInt(minimum) < MINIMUM_JAVA) { + throw fail( + "package-config", + "RCJ1008", + packageKey, + "unsupported minimum Java version", + "/minimumJavaVersion"); + } + String mode = value.get("publicationMode").asText(); + if (!mode.equals("github-tag") && !mode.equals("registry")) { + throw configFail("unsupported publication mode", "/publicationMode", packageKey); + } + String repositoryUrl = value.get("repositoryUrl").asText(); + if (!repositoryUrl.startsWith("https://") || repositoryUrl.chars().anyMatch(Character::isWhitespace)) { + throw configFail("repositoryUrl must use https", "/repositoryUrl", packageKey); + } + String registry = value.has("registryId") ? text(value, "registryId", "/registryId", packageKey) : null; + if (mode.equals("registry") != (registry != null)) { + throw configFail( + "registryId is required exactly for registry publication", + "/registryId", + packageKey); + } + JsonNode entries = value.has("dependencies") ? value.get("dependencies") : Json.MAPPER.createArrayNode(); + if (!entries.isArray()) { + throw configFail("dependencies must be a sequence", "/dependencies", packageKey); + } + List dependencies = new ArrayList<>(); + Set seen = new HashSet<>(); + int index = 0; + for (JsonNode entry : entries) { + String pointer = "/dependencies/" + index; + index += 1; + if (!entry.isObject()) { + throw configFail("dependency must be a mapping", pointer, packageKey); + } + for (String name : DEPENDENCY_FIELDS.stream().sorted().toList()) { + if (!entry.has(name)) { + throw configFail("missing dependency field '" + name + "'", pointer + "/" + name, packageKey); + } + } + Set extra = new TreeSet<>(); + for (Iterator names = entry.fieldNames(); names.hasNext(); ) { + String name = names.next(); + if (!DEPENDENCY_FIELDS.contains(name)) { + extra.add(name); + } + } + for (String name : extra) { + throw configFail("unknown dependency field '" + name + "'", pointer + "/" + name, packageKey); + } + String extension = text(entry, "extension", pointer + "/extension", packageKey); + String dependencyGroup = text(entry, "groupId", pointer + "/groupId", packageKey); + String dependencyArtifact = text(entry, "artifactId", pointer + "/artifactId", packageKey); + String dependencyPackage = text(entry, "javaPackage", pointer + "/javaPackage", packageKey); + String dependencyVersion = text(entry, "version", pointer + "/version", packageKey); + if (!seen.add(extension)) { + throw configFail("duplicate dependency '" + extension + "'", pointer, packageKey); + } + if (!GROUP_ID.matcher(dependencyGroup).matches() + || !ARTIFACT_ID.matcher(dependencyArtifact).matches() + || !validJavaPackage(dependencyPackage) + || !VERSION.matcher(dependencyVersion).matches()) { + throw configFail("invalid dependency package metadata", pointer, packageKey); + } + dependencies.add( + new Dependency(extension, dependencyGroup, dependencyArtifact, dependencyPackage, dependencyVersion)); + } + return new PackageTarget( + packageKey, + value.get("rootExtension").asText(), + groupId, + artifactId, + javaPackage, + version, + value.get("sourceDirectory").asText(), + minimum, + mode, + repositoryUrl, + registry, + List.copyOf(dependencies), + emitterSha256); + } +} 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..4451b1a --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Verification.java @@ -0,0 +1,359 @@ +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; + 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); + } + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + Map result = new LinkedHashMap<>(); + 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); + String expected = Metadata.renderResources(plan, model).get(Metadata.MANIFEST_PATH); + String actual; + try { + actual = Files.readString(directory.resolve(Metadata.MANIFEST_PATH), StandardCharsets.UTF_8); + } catch (IOException error) { + throw new IllegalStateException("binding manifest is missing from the package"); + } + if (!actual.equals(expected)) { + throw new IllegalStateException("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..d82801b --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/YamlIo.java @@ -0,0 +1,235 @@ +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.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 { + data = Files.readAllBytes(path); + } 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/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..60205e5 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/CliTest.java @@ -0,0 +1,145 @@ +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); + } + } +} 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..b6cbc1c --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ContractTest.java @@ -0,0 +1,158 @@ +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()); + assertEquals(Metadata.COMPILER_PLUGIN_VERSION, 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()); + } + + 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/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/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..4055631 --- /dev/null +++ b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java @@ -0,0 +1,271 @@ +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))); + } + 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 (String extension : directDependencies(model)) { + int index = providers.get(extension); + dependencies.add( + new Dependency(extension, "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()); + } + + static Map providersOf(ObjectNode model) { + Map providers = new LinkedHashMap<>(); + for (String extension : directDependencies(model)) { + providers.put(extension, 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/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' From 5de661786d78d711f5dfda82438854770152f953 Mon Sep 17 00:00:00 2001 From: itvi-1234 Date: Wed, 21 Oct 2026 19:01:24 +0530 Subject: [PATCH 2/4] Read versions and config from files instead of code The emitter version now comes from the build, the pom.xml is filled from templates, and package targets are validated against package-target.schema.yaml instead of patterns in code. Dependencies take an optional versionRange, and CONTRACT.md is removed. Signed-off-by: itvi-1234 --- tooling/extension-bindings/README.md | 2 +- .../emitters/java/CONTRACT.md | 169 ---------- .../emitters/java/build.gradle.kts | 9 + .../emitters/java/package-target.schema.yaml | 4 + .../bindings/emitter/Metadata.java | 111 ++++--- .../bindings/emitter/Target.java | 310 ++++++++---------- .../bindings/emitter/Templates.java | 46 +++ .../src/main/resources/emitter.properties | 2 + .../bindings/emitter/ContractTest.java | 14 +- .../bindings/emitter/TestSupport.java | 6 + .../java/templates/dependencies.xml.tmpl | 3 + .../java/templates/dependency.xml.tmpl | 5 + .../emitters/java/templates/pom.xml.tmpl | 31 ++ 13 files changed, 308 insertions(+), 404 deletions(-) delete mode 100644 tooling/extension-bindings/emitters/java/CONTRACT.md create mode 100644 tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Templates.java create mode 100644 tooling/extension-bindings/emitters/java/src/main/resources/emitter.properties create mode 100644 tooling/extension-bindings/emitters/java/templates/dependencies.xml.tmpl create mode 100644 tooling/extension-bindings/emitters/java/templates/dependency.xml.tmpl create mode 100644 tooling/extension-bindings/emitters/java/templates/pom.xml.tmpl diff --git a/tooling/extension-bindings/README.md b/tooling/extension-bindings/README.md index a4e331f..8f3f837 100644 --- a/tooling/extension-bindings/README.md +++ b/tooling/extension-bindings/README.md @@ -3,7 +3,7 @@ `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. Native TypeScript -and Java emitters live under `emitters/typescript/` and `emitters/java/`; the Java emitter's contract is in `CONTRACT.md`. +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. diff --git a/tooling/extension-bindings/emitters/java/CONTRACT.md b/tooling/extension-bindings/emitters/java/CONTRACT.md deleted file mode 100644 index ab930a3..0000000 --- a/tooling/extension-bindings/emitters/java/CONTRACT.md +++ /dev/null @@ -1,169 +0,0 @@ -# Java emitter contract - -This records the Java target, layout, and naming decisions for the native Java -emitter. It is written in Java, as the implementation standard requires of every -emitter, and follows the Python emitter's structure. - -The emitter consumes one validated binding model and one selected, resolved Java -target through the shared command contract: - -```text -runtimeconditions-binding-emitter-java emit \ - --model runtimeconditions.binding-model.yaml \ - --package-config --output -``` - -`verify --model --package-config --input ` parses the generated source -with the JDK compiler's syntax tree API (never compiling or running it), checks -its public API against the model plan, and compares the structural manifest with -a fresh rendering. `api --input ` prints the structural signatures. - -## Target configuration - -The selected target uses `runtimeconditions.io/java-package-target/v1alpha1` and -`RuntimeConditionsJavaPackageTarget`. Its shape is defined by -`package-target.schema.yaml`; the emitter validates it field by field with the -same diagnostics as the schema. - -- `packageKey`, `rootExtension`, `version`, `sourceDirectory`, and - `publicationMode` correspond to the package set and language entry in the - catalog. `sourceDirectory` is exactly `bindings//java`. -- `groupId` and `artifactId` are the Maven coordinate. `javaPackage` is the Java - package every generated type is declared in. None of them is inferred from - extension vocabulary. -- `minimumJavaVersion` is a whole release number of at least `17`. It becomes - `maven.compiler.release`. -- `repositoryUrl` supplies the package repository metadata and must use HTTPS. - A registry target additionally requires `registryId`. -- `dependencies` contains only direct extension dependencies, with their Maven - coordinates, Java packages, and exact tested versions, and equals the model's - direct dependency set. -- `emitterSha256` is supplied by the caller and recorded in the manifest. - -Package versions are SemVer without a leading `v` or build metadata and map -unchanged to Maven. Supported prerelease forms are `-alpha.N`, `-beta.N`, and -`-rc.N`. Direct dependency ranges use the Maven interval `[,)`, -where `next-breaking` is `0.(y+1).0` below `1.0.0` and `(major+1).0.0` otherwise. - -## Generated source tree - -```text -/ - pom.xml - src/main/java// - Declaration.java - Bindings.java - Field.java - .java - src/main/resources/runtimeconditions/ - runtimeconditions.bindings.yaml -``` - -Every public type is declared in its own file, named after the type. The -remaining binding resources belong beside the manifest in -`src/main/resources/runtimeconditions/` so that they are packaged in the jar. -Only readable Java source and the project file are committed; the jar is a build -output. - -Generated source starts with the non-editable header naming the model digest and -emitter version, and is rendered directly by the emitter, so its formatting does -not depend on an external formatter. `pom.xml` carries the same header in an XML -comment. - -## API rules - -1. Every owned kind becomes one static method `Bindings.kind(KindField... fields)` - returning `Declaration`. `Declaration` is an inert final class with a - package-private constructor; the method body only checks its argument. -2. `Field` is an interface whose only member is a marker method named - `rcMarker_<16 hex digits>`, derived from the owner and declaration - coordinate. Every applicable object record declares that method and - `implements` the interface, so a field belonging to another kind does not - compile. Cross-package additive fields implement the provider's interface in - the same way. -3. Objects become records whose components are the fields, required components - first. A required component of a non-nullable type is checked with - `Objects.requireNonNull`; an optional component is `null` when absent. Records - are shallowly immutable. The emitter does not add factories or builders. -4. Scalars map to boxed types so that an absent optional value is `null`: - `String`, `Boolean`, `Long` for integers, `Double` for numbers, `Void` for - JSON null, and `Object` for any JSON value. -5. Arrays and maps become single-component wrapper records named after the - shape, holding `List` or `Map`. Naming the wrapper keeps - recursive shapes expressible. -6. Unions become a `sealed interface` with one nested record per variant, - `Variant1`, `Variant2`, …, each holding its variant's value. -7. String value domains become `enum` types whose constants are upper snake case - and whose `value()` returns the serialized string. Integer and number shapes - remain distinct. -8. Imported declarations are not re-declared. A type implementing an imported - marker imports that interface from its direct provider package. - -Names derive only from exact source names. Types and marker interfaces use Pascal -case, fields and methods use camel case, and enum constants use upper snake case. -A leading digit gains `X` (types and constants) or `x`. A Java keyword, literal, -contextual type name, or an `Object` method name that cannot be a record component -gains one trailing underscore. A name that equals a `java.lang` or `java.util` -type the generated source uses (`Record`, `String`, `List`, …) or a generated -helper (`Declaration`, `Bindings`) stops emission instead of being renamed. -Collisions are resolved only by prepending parent path components, nearest first, -and otherwise stop emission. - -## Diagnostics - -Codes use the `RCJ` prefix and mirror the meanings of the Python `RCP` codes. - -| Code | Meaning | -| --- | --- | -| `RCJ1001` | YAML input cannot be read or exceeds the size limit. | -| `RCJ1002` | YAML syntax, profile, structure, or resource-limit failure. | -| `RCJ1003` | Binding-model schema validation failure. | -| `RCJ1004` | Unsupported binding-model `apiVersion`. | -| `RCJ1005` | Unsupported binding-model `kind`. | -| `RCJ1006` | Invalid Java package-target field or dependency metadata. | -| `RCJ1007` | Target source directory does not match its package key. | -| `RCJ1008` | Unsupported minimum Java version. | -| `RCJ1009` | Target root extension or direct dependency set differs from the model. | -| `RCJ1010` | Unknown structural shape kind. | -| `RCJ1011` | Inconsistent extension closure or declaration ownership. | -| `RCJ1012` | Inconsistent shape details, value domain, or unresolved reference. | -| `RCJ2000` | A source name cannot be derived as a valid Java identifier. | -| `RCJ2001` | Symbol collision or reserved-name conflict. | -| `RCJ3001` | Output directory is not new or empty. | -| `RCJ3002` | Output directory cannot be inspected or written. | - -## Input loading - -The model is read with the Python emitter's restrictions: UTF-8 without BOM, LF -line endings, one trailing newline, no comments, anchors, aliases, or tags, -string mapping keys only, no duplicate keys, finite numbers, and the same size, -node, and depth limits. It is then validated against -`model/runtimeconditions.binding-model.schema.yaml`, which the build copies into -the emitter's resources. The schema can be overridden with -`RC_BINDING_MODEL_SCHEMA` when the emitter runs against a different checkout. - -## Verification - -The tests run the shared conformance models in `model/conformance/expected` and -the committed first-party models. For each, they check that three independent -emissions are byte-identical, that `verify` accepts the output, and that the -package compiles with `javac --release 17 -Xlint:all -Werror`. A generated -conformance source constructs every type, enum constant, collection, map, union -variant, and declaration. Imported markers are checked against type-only provider -stubs, and a stub with a different marker method must fail to compile. Negative -tests cover invalid inputs, tampered output, and values the model does not allow. - -## Design notes - -- **Language key and layout.** The catalog language key is `java`, and a package - lives in `bindings//java`. -- **Functions.** Java has no free functions, so declaration functions are static - methods of `Bindings`. The manifest names that class in `functionClass` and - records the file that declares the method. -- **Unions.** Java has no union types. Each variant is wrapped in a nested - record so that object, scalar, and collection variants are all expressed the - same way and a record can belong to more than one union. -- **Build tooling.** The emitter is built with Gradle and its dependencies are - locked in `gradle.lockfile`. The generated project is a Maven project; the - emitter never runs Maven. JDK 21.0.12, Gradle 8.14.2, and the library versions - are recorded under `languages.java` in `toolchain.lock.yaml`. diff --git a/tooling/extension-bindings/emitters/java/build.gradle.kts b/tooling/extension-bindings/emitters/java/build.gradle.kts index cb543c1..c7a4f8b 100644 --- a/tooling/extension-bindings/emitters/java/build.gradle.kts +++ b/tooling/extension-bindings/emitters/java/build.gradle.kts @@ -36,6 +36,15 @@ application { } 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") } diff --git a/tooling/extension-bindings/emitters/java/package-target.schema.yaml b/tooling/extension-bindings/emitters/java/package-target.schema.yaml index 03f375e..cf16a25 100644 --- a/tooling/extension-bindings/emitters/java/package-target.schema.yaml +++ b/tooling/extension-bindings/emitters/java/package-target.schema.yaml @@ -86,6 +86,10 @@ properties: 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: 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 index 702e41b..460a89b 100644 --- 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 @@ -15,27 +15,40 @@ 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 java.util.regex.Matcher; 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 = "runtimeconditions-binding-emitter-java"; - public static final String EMITTER_VERSION = "0.1.0"; - public static final String COMPILER_PLUGIN_VERSION = "3.14.0"; + 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 @@ -46,13 +59,17 @@ public static String header(JsonNode model) { + "; 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) { - Matcher match = Target.VERSION.matcher(value); - if (!match.matches()) { - throw fail("package-config", "RCJ1006", coordinate, "unsupported package version '" + value + "'", "/version"); - } - int major = Integer.parseInt(match.group(1)); - int minor = Integer.parseInt(match.group(2)); + 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 + ")"; } @@ -77,51 +94,33 @@ private static String pom(EmissionPlan plan, JsonNode model) { List dependencies = new ArrayList<>(target.dependencies()); dependencies.sort(Comparator.comparing(Dependency::coordinate)); String repository = target.repositoryUrl().endsWith(".git") ? target.repositoryUrl() : target.repositoryUrl() + ".git"; - StringBuilder text = new StringBuilder(); - text.append("\n"); - text.append("\n"); - text.append("\n"); - text.append(" 4.0.0\n"); - text.append(" ").append(xml(target.groupId())).append("\n"); - text.append(" ").append(xml(target.artifactId())).append("\n"); - text.append(" ").append(xml(target.version())).append("\n"); - text.append(" jar\n"); - text.append(" ").append(xml(target.artifactId())).append("\n"); - text.append(" ").append(xml(target.repositoryUrl())).append("\n"); - text.append(" \n"); - text.append(" ").append(xml(target.repositoryUrl())).append("\n"); - text.append(" scm:git:").append(xml(repository)).append("\n"); - text.append(" \n"); - text.append(" \n"); - text.append(" ").append(target.minimumJavaVersion()).append("\n"); - text.append(" UTF-8\n"); - text.append(" \n"); - if (!dependencies.isEmpty()) { - text.append(" \n"); - for (Dependency dependency : dependencies) { - text.append(" \n"); - text.append(" ").append(xml(dependency.groupId())).append("\n"); - text.append(" ").append(xml(dependency.artifactId())).append("\n"); - text.append(" ") - .append(xml(dependencyInterval(dependency.version(), dependency.extension()))) - .append("\n"); - text.append(" \n"); - } - text.append(" \n"); + 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))))); } - text.append(" \n"); - text.append(" \n"); - text.append(" \n"); - text.append(" org.apache.maven.plugins\n"); - text.append(" maven-compiler-plugin\n"); - text.append(" ").append(COMPILER_PLUGIN_VERSION).append("\n"); - text.append(" \n"); - text.append(" \n"); - text.append(" \n"); - text.append("\n"); - return text.toString(); + 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) { @@ -435,12 +434,12 @@ private static String manifest(EmissionPlan plan, JsonNode model) { } public static Map renderResources(EmissionPlan plan, JsonNode model) { - if (!Target.SHA.matcher(plan.target().emitterSha256()).matches()) { + if (plan.target().emitterSha256().isEmpty()) { throw fail( "package-config", "RCJ1006", plan.target().packageKey(), - "emitterSha256 must be a 64-character lowercase SHA-256 of the emitter tool", + "emitterSha256 is required when emitting package resources", "/emitterSha256"); } if (!model.path("metadata").path("semanticSha256").asText().equals(plan.modelDigest()) 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 index 1203dee..d50ca08 100644 --- 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 @@ -4,34 +4,39 @@ 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.Iterator; import java.util.List; import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Matcher; 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"; - public static final Pattern VERSION = - Pattern.compile( - "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\\.(0|[1-9][0-9]*))?$"); - public static final Pattern KEY = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); - public static final Pattern GROUP_ID = Pattern.compile("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$"); - public static final Pattern ARTIFACT_ID = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); - public static final Pattern JAVA_PACKAGE = + 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 static final Pattern SHA = Pattern.compile("^[0-9a-f]{64}$"); - private static final Pattern JAVA_VERSION = Pattern.compile("^(0|[1-9][0-9]*)$"); - private static final int MINIMUM_JAVA = 17; public record Dependency( - String extension, String groupId, String artifactId, String javaPackage, String version) { + 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; } @@ -73,46 +78,99 @@ public PackageTarget withEmitterSha256(String digest) { } } - private static final List ALLOWED = - List.of( - "apiVersion", - "kind", - "packageKey", - "rootExtension", - "groupId", - "artifactId", - "javaPackage", - "version", - "sourceDirectory", - "minimumJavaVersion", - "publicationMode", - "registryId", - "repositoryUrl", - "dependencies", - "emitterSha256"); - private static final Set OPTIONAL = Set.of("registryId", "dependencies", "emitterSha256"); - private static final List DEPENDENCY_FIELDS = - List.of("extension", "groupId", "artifactId", "javaPackage", "version"); + private static JsonSchema validator; private Target() {} - private static DiagnosticError configFail(String message, String pointer) { - return fail("package-config", "RCJ1006", "package-target", message, pointer); - } + private record Described(String code, String message, String pointer) {} - private static DiagnosticError configFail(String message, String pointer, String coordinate) { - return fail("package-config", "RCJ1006", coordinate, message, 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 String text(JsonNode value, String name, String pointer, String coordinate) { - JsonNode node = value.get(name); - if (node == null || !node.isTextual() || node.asText().isEmpty()) { - throw configFail("invalid " + name, pointer, coordinate); + private static synchronized JsonSchema validator() { + if (validator == null) { + validator = Model.createValidator(schemaText()); } - return node.asText(); + return validator; + } + + private static Described configError(String message, String pointer) { + return new Described("RCJ1006", message, pointer); } - static boolean validJavaPackage(String value) { + 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; } @@ -126,157 +184,55 @@ static boolean validJavaPackage(String value) { public static PackageTarget load(Path path) { ObjectNode value = YamlIo.load(path, false); - List missing = new ArrayList<>(); - for (String name : ALLOWED) { - if (!OPTIONAL.contains(name) && !value.has(name)) { - missing.add(name); - } - } - missing.sort(Comparator.naturalOrder()); - for (String name : missing) { - throw configFail("missing required field '" + name + "'", "/" + name); - } - Set unknown = new TreeSet<>(); - for (Iterator names = value.fieldNames(); names.hasNext(); ) { - String name = names.next(); - if (!ALLOWED.contains(name)) { - unknown.add(name); - } - } - for (String name : unknown) { - throw configFail("unknown field '" + name + "'", "/" + name); - } - if (!API.equals(value.path("apiVersion").asText(null)) - || !KIND.equals(value.path("kind").asText(null))) { - throw configFail("unsupported target apiVersion or kind", ""); - } - String emitterSha256 = ""; - if (value.has("emitterSha256")) { - JsonNode node = value.get("emitterSha256"); - if (!node.isTextual() || !SHA.matcher(node.asText()).matches()) { - throw configFail( - "emitterSha256 must be a 64-character lowercase SHA-256 of the emitter tool", - "/emitterSha256"); - } - emitterSha256 = node.asText(); - } - for (String name : ALLOWED) { - if (OPTIONAL.contains(name) || name.equals("apiVersion") || name.equals("kind")) { - continue; - } - text(value, name, "/" + name, "package-target"); + 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(); - String groupId = value.get("groupId").asText(); - String artifactId = value.get("artifactId").asText(); - String javaPackage = value.get("javaPackage").asText(); - String version = value.get("version").asText(); - if (!KEY.matcher(packageKey).matches()) { - throw configFail("invalid packageKey", "/packageKey"); - } - if (!GROUP_ID.matcher(groupId).matches() || groupId.length() > 128) { - throw configFail("invalid groupId", "/groupId"); - } - if (!ARTIFACT_ID.matcher(artifactId).matches() || artifactId.length() > 128) { - throw configFail("invalid artifactId", "/artifactId"); - } - if (!validJavaPackage(javaPackage)) { - throw configFail("invalid javaPackage", "/javaPackage"); - } - if (!VERSION.matcher(version).matches()) { - throw configFail("unsupported package version", "/version"); + 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"); - } - String minimum = value.get("minimumJavaVersion").asText(); - Matcher parts = JAVA_VERSION.matcher(minimum); - if (!parts.matches() || Integer.parseInt(minimum) < MINIMUM_JAVA) { - throw fail( - "package-config", - "RCJ1008", - packageKey, - "unsupported minimum Java version", - "/minimumJavaVersion"); - } - String mode = value.get("publicationMode").asText(); - if (!mode.equals("github-tag") && !mode.equals("registry")) { - throw configFail("unsupported publication mode", "/publicationMode", packageKey); - } - String repositoryUrl = value.get("repositoryUrl").asText(); - if (!repositoryUrl.startsWith("https://") || repositoryUrl.chars().anyMatch(Character::isWhitespace)) { - throw configFail("repositoryUrl must use https", "/repositoryUrl", packageKey); - } - String registry = value.has("registryId") ? text(value, "registryId", "/registryId", packageKey) : null; - if (mode.equals("registry") != (registry != null)) { - throw configFail( - "registryId is required exactly for registry publication", - "/registryId", - packageKey); - } - JsonNode entries = value.has("dependencies") ? value.get("dependencies") : Json.MAPPER.createArrayNode(); - if (!entries.isArray()) { - throw configFail("dependencies must be a sequence", "/dependencies", packageKey); + 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 : entries) { + for (JsonNode entry : value.has("dependencies") ? value.get("dependencies") : Json.MAPPER.createArrayNode()) { String pointer = "/dependencies/" + index; index += 1; - if (!entry.isObject()) { - throw configFail("dependency must be a mapping", pointer, packageKey); - } - for (String name : DEPENDENCY_FIELDS.stream().sorted().toList()) { - if (!entry.has(name)) { - throw configFail("missing dependency field '" + name + "'", pointer + "/" + name, packageKey); - } - } - Set extra = new TreeSet<>(); - for (Iterator names = entry.fieldNames(); names.hasNext(); ) { - String name = names.next(); - if (!DEPENDENCY_FIELDS.contains(name)) { - extra.add(name); - } - } - for (String name : extra) { - throw configFail("unknown dependency field '" + name + "'", pointer + "/" + name, packageKey); - } - String extension = text(entry, "extension", pointer + "/extension", packageKey); - String dependencyGroup = text(entry, "groupId", pointer + "/groupId", packageKey); - String dependencyArtifact = text(entry, "artifactId", pointer + "/artifactId", packageKey); - String dependencyPackage = text(entry, "javaPackage", pointer + "/javaPackage", packageKey); - String dependencyVersion = text(entry, "version", pointer + "/version", packageKey); + String extension = entry.get("extension").asText(); if (!seen.add(extension)) { - throw configFail("duplicate dependency '" + extension + "'", pointer, packageKey); + throw fail("package-config", "RCJ1006", packageKey, "duplicate dependency '" + extension + "'", pointer); } - if (!GROUP_ID.matcher(dependencyGroup).matches() - || !ARTIFACT_ID.matcher(dependencyArtifact).matches() - || !validJavaPackage(dependencyPackage) - || !VERSION.matcher(dependencyVersion).matches()) { - throw configFail("invalid dependency package metadata", pointer, packageKey); + if (!validJavaPackage(entry.get("javaPackage").asText())) { + throw fail("package-config", "RCJ1006", packageKey, "invalid dependency package metadata", pointer); } dependencies.add( - new Dependency(extension, dependencyGroup, dependencyArtifact, dependencyPackage, dependencyVersion)); + 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(), - groupId, - artifactId, - javaPackage, - version, + value.get("groupId").asText(), + value.get("artifactId").asText(), + value.get("javaPackage").asText(), + value.get("version").asText(), value.get("sourceDirectory").asText(), - minimum, - mode, - repositoryUrl, - registry, + value.get("minimumJavaVersion").asText(), + value.get("publicationMode").asText(), + value.get("repositoryUrl").asText(), + value.has("registryId") ? value.get("registryId").asText() : null, List.copyOf(dependencies), - emitterSha256); + 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/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/ContractTest.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/ContractTest.java index b6cbc1c..9a71206 100644 --- 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 @@ -139,7 +139,9 @@ void theEmittersToolPinsEqualTheSharedToolchainLock() { JsonNode tools = java.path("tools"); assertEquals("maven", java.path("packageManager").asText()); assertEquals("gradle", java.path("buildBackend").asText()); - assertEquals(Metadata.COMPILER_PLUGIN_VERSION, tools.path("maven-compiler-plugin").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; @@ -152,6 +154,16 @@ void theEmittersToolPinsEqualTheSharedToolchainLock() { 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/TestSupport.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java index 4055631..adf8450 100644 --- 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 @@ -86,6 +86,12 @@ static String emitterDigest() { 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) { 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 + + + + From e5a5f451376478f6ca482740184c77b1ce5f5f24 Mon Sep 17 00:00:00 2001 From: itvi-1234 Date: Thu, 22 Oct 2026 10:46:30 +0530 Subject: [PATCH 3/4] Address review of the Java emitter Keep the resolved type of scalar, any, and chained definitions instead of discarding it, so a property that references one no longer gets a null type. Resolve an imported declaration's package from its owner's own dependency entry, so root -> middle -> leaf imports the marker from leaf. Compare pom.xml during verification, read inputs through a bounded stream so an oversized file is rejected before it is held in memory, and let a package with no sources verify. Add a Java quickstart, and make the Maven publication workflow state that it is a placeholder, reject dry_run false, and record in its summary that nothing was verified or published. Signed-off-by: itvi-1234 --- .github/workflows/binding-publish-maven.yml | 22 ++- tooling/extension-bindings/README.md | 47 ++++++ .../bindings/emitter/Plan.java | 103 +++++++----- .../bindings/emitter/Verification.java | 41 +++-- .../bindings/emitter/YamlIo.java | 5 +- .../bindings/emitter/CliTest.java | 34 ++++ .../bindings/emitter/DefinitionsTest.java | 136 ++++++++++++++++ .../bindings/emitter/PomVerificationTest.java | 52 +++++++ .../bindings/emitter/TestSupport.java | 11 +- .../bindings/emitter/TransitiveTest.java | 147 ++++++++++++++++++ 10 files changed, 534 insertions(+), 64 deletions(-) create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/DefinitionsTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/PomVerificationTest.java create mode 100644 tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TransitiveTest.java diff --git a/.github/workflows/binding-publish-maven.yml b/.github/workflows/binding-publish-maven.yml index f841195..0ed6730 100644 --- a/.github/workflows/binding-publish-maven.yml +++ b/.github/workflows/binding-publish-maven.yml @@ -1,4 +1,4 @@ -name: Publish Java bindings +name: Publish Java bindings (placeholder, publishes nothing) on: workflow_dispatch: @@ -8,7 +8,7 @@ on: type: string default: '' dry_run: - description: 'Verify publication without pushing to Maven' + description: 'Must stay true: the workflow is a placeholder and verifies and publishes nothing' type: boolean default: true @@ -26,11 +26,23 @@ jobs: timeout-minutes: 15 steps: - - name: Publish to Maven (placeholder) + - 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 "Maven publication is not implemented; no package was published." - echo "targets=${TARGETS:-} dry_run=${DRY_RUN}" + { + 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/tooling/extension-bindings/README.md b/tooling/extension-bindings/README.md index 8f3f837..d29e4f0 100644 --- a/tooling/extension-bindings/README.md +++ b/tooling/extension-bindings/README.md @@ -149,6 +149,53 @@ 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/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java b/tooling/extension-bindings/emitters/java/src/main/java/io/runtimeconditions/bindings/emitter/Plan.java index a8c9cea..ef5a0d9 100644 --- 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 @@ -95,6 +95,38 @@ 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"; @@ -140,38 +172,18 @@ private static Set directDependencies(JsonNode model) { throw fail("model", "RCJ1011", root, "root extension is absent from resolved extension closure"); } - private static ProviderPackage providerPackage(JsonNode model, PackageTarget target, String owner) { - Map> graph = new LinkedHashMap<>(); - for (JsonNode item : Json.list(model.path("extensions"))) { - List dependencies = new ArrayList<>(); - for (JsonNode dependency : Json.list(item.path("dependencies"))) { - dependencies.add(dependency.asText()); - } - graph.put(item.path("id").asText(), dependencies); - } - List dependencies = new ArrayList<>(target.dependencies()); - dependencies.sort(Comparator.comparing(Dependency::extension)); - for (Dependency dependency : dependencies) { - if (reaches(graph, dependency.extension(), owner, new HashSet<>())) { + 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("model", "RCJ1011", owner, "imported declaration has no direct dependency provider"); - } - - private static boolean reaches(Map> graph, String current, String owner, Set visited) { - if (current.equals(owner)) { - return true; - } - if (!visited.add(current)) { - return false; - } - for (String dependency : graph.getOrDefault(current, List.of())) { - if (reaches(graph, dependency, owner, visited)) { - return true; - } - } - return false; + throw fail( + "package-config", + "RCJ1011", + owner, + "imported declaration owner has no entry in the target dependencies", + "/dependencies"); } static String location(JsonNode item) { @@ -210,6 +222,7 @@ private static final class Builder { 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; @@ -389,14 +402,21 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { for (Dependency dependency : target.dependencies()) { configured.add(dependency.extension()); } - List directSorted = new ArrayList<>(new TreeSet<>(direct)); - List configuredSorted = new ArrayList<>(configured); - if (!directSorted.equals(configuredSorted)) { + 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, - "direct dependency set differs: model=" + jsonList(directSorted) + ", target=" + jsonList(configuredSorted), + "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<>(); @@ -451,7 +471,7 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { functionKey, protocolKey, markerMethod(owner, coordinate), - providerPackage(model, target, owner)); + providerPackage(target, owner)); imported.add(plan); if (importedKinds.containsKey(kind)) { throw fail("model", "RCJ1011", coordinate, "duplicate imported kind"); @@ -605,8 +625,10 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { JsonNode definition = (JsonNode) pending[0]; @SuppressWarnings("unchecked") List scope = (List) pending[1]; - builder.registerShape( - definition.path("shape"), definitionKey, definition.path("name").asText(), scope, null, null); + builder.definitionExpressions.put( + definitionKey, + builder.registerShape( + definition.path("shape"), definitionKey, definition.path("name").asText(), scope, null, null)); } } @@ -617,13 +639,14 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { 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()), - field.typeExpr(), - annotate(field.typeExpr(), names), + resolved, + annotate(resolved, names), field.required())); } fields.sort( @@ -648,7 +671,9 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { names.get(key), raw.kind(), List.copyOf(fields), - raw.expression(), + raw.expression() == null + ? null + : resolve(raw.expression(), builder.definitionExpressions, new ArrayList<>()), List.copyOf(members), marker, raw.marker() != null && raw.kind() == Kind.OBJECT, 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 index 4451b1a..a7de642 100644 --- 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 @@ -58,15 +58,21 @@ record ParsedType(String path, String packageName, List imports, ClassTr static Map parse(Path directory) { Path root = directory.resolve("src/main/java"); - List files; - 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); + 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<>(); - Map result = new LinkedHashMap<>(); try (StandardJavaFileManager manager = compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { JavacTask task = @@ -344,15 +350,20 @@ public record Verified(List api, boolean modelMapping) {} public static Verified verifyPackage(EmissionPlan plan, Path directory, JsonNode model) { checkPublicApi(plan, directory); - String expected = Metadata.renderResources(plan, model).get(Metadata.MANIFEST_PATH); - String actual; - try { - actual = Files.readString(directory.resolve(Metadata.MANIFEST_PATH), StandardCharsets.UTF_8); - } catch (IOException error) { - throw new IllegalStateException("binding manifest is missing from the package"); - } - if (!actual.equals(expected)) { - throw new IllegalStateException("binding manifest mappings differ from normalized model"); + 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 index d82801b..cd85e7e 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -197,8 +198,8 @@ private static void checkDataTree(Object value, int depth, Counter counter, Stri static ObjectNode load(Path path, boolean model) { String coordinate = model ? "model" : "package-target"; byte[] data; - try { - data = Files.readAllBytes(path); + 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"); } 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 index 60205e5..3525c65 100644 --- 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 @@ -142,4 +142,38 @@ void verifyRejectsATamperedApiOrManifest() throws Exception { 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/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/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/TestSupport.java b/tooling/extension-bindings/emitters/java/src/test/java/io/runtimeconditions/bindings/emitter/TestSupport.java index adf8450..5925f26 100644 --- 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 @@ -126,10 +126,11 @@ static String javaPackageFor(String name) { static PackageTarget targetFor(String name, ObjectNode model, Map providers) { PackageTarget base = developmentTarget(); List dependencies = new ArrayList<>(); - for (String extension : directDependencies(model)) { - int index = providers.get(extension); + for (Map.Entry provider : providers.entrySet()) { + int index = provider.getValue(); dependencies.add( - new Dependency(extension, "io.runtimeconditions.providers", "provider-" + index, "provider.p" + index, "1.0.0")); + new Dependency( + provider.getKey(), "io.runtimeconditions.providers", "provider-" + index, "provider.p" + index, "1.0.0")); } return new PackageTarget( name, @@ -147,11 +148,15 @@ static PackageTarget targetFor(String name, ObjectNode model, 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; } 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()); + } +} From df8fbed8c0587dd229f242f9b38a9e6d62d4bd9b Mon Sep 17 00:00:00 2001 From: itvi-1234 Date: Fri, 23 Oct 2026 12:17:41 +0530 Subject: [PATCH 4/4] Make the definition planning loop's exit visible Replace for (; ; ) and its inner break with a while loop whose condition names what is left to plan, and explain why it ends: each pass removes the definitions it plans from pendingDefinitions, so the set only shrinks. Signed-off-by: itvi-1234 --- .../bindings/emitter/Plan.java | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) 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 index ef5a0d9..fc948ca 100644 --- 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 @@ -386,6 +386,17 @@ 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(); @@ -609,17 +620,10 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { } } - for (; ; ) { - List required = new ArrayList<>(); - for (String item : builder.referencedDefinitions) { - if (pendingDefinitions.containsKey(item)) { - required.add(item); - } - } - required.sort(Comparator.naturalOrder()); - if (required.isEmpty()) { - break; - } + // 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]; @@ -630,6 +634,7 @@ public static EmissionPlan build(JsonNode model, PackageTarget target) { 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);