diff --git a/.github/actions/binding-tools/action.yml b/.github/actions/binding-tools/action.yml index 5f5f243..025d507 100644 --- a/.github/actions/binding-tools/action.yml +++ b/.github/actions/binding-tools/action.yml @@ -2,7 +2,7 @@ name: Prepare binding tools description: Select targets, install the exact compilers and prerequisites, and build the shared generator inputs: targets: - description: Comma-separated package-key:go, package-key:python, or package-key:typescript targets, or all + description: Comma-separated package-key:go, package-key:python, package-key:typescript, or package-key:rust targets, or all required: true publication: description: Include the committed package dependency closure for publication @@ -23,6 +23,12 @@ outputs: has-typescript: description: Whether a TypeScript target is selected value: ${{ steps.configure.outputs.has-typescript }} + rust-version: + description: Exact catalog Rust version, empty when no Rust target is selected + value: ${{ steps.configure.outputs.rust-version }} + has-rust: + description: Whether a Rust target is selected + value: ${{ steps.configure.outputs.has-rust }} source-commit: description: Checked-out source commit value: ${{ steps.configure.outputs.source-commit }} @@ -60,6 +66,7 @@ runs: python tooling/extension-bindings/ci/generate-go.py configure --targets "$INPUT_TARGETS" python tooling/extension-bindings/ci/generate-python.py configure --targets "$INPUT_TARGETS" python tooling/extension-bindings/ci/generate-typescript.py configure --targets "$INPUT_TARGETS" + python tooling/extension-bindings/ci/generate-rust.py configure --targets "$INPUT_TARGETS" fi - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -129,6 +136,15 @@ runs: shell: bash working-directory: tooling/extension-bindings/emitters/typescript run: npm ci --ignore-scripts && npm run build + - name: Install the pinned Rust toolchain + if: steps.configure.outputs.has-rust == 'true' + shell: bash + env: + RUST_VERSION: ${{ steps.configure.outputs.rust-version }} + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal --component rustfmt + rustup default "$RUST_VERSION" - name: Build shared generator shell: bash run: go -C tooling/extension-bindings/orchestrator build -trimpath -buildvcs=false -o "$RUNNER_TEMP/binding-tools/rc" ./cmd/rc diff --git a/.github/workflows/binding-generate.yml b/.github/workflows/binding-generate.yml index 4de9d4b..c1c2995 100644 --- a/.github/workflows/binding-generate.yml +++ b/.github/workflows/binding-generate.yml @@ -1,4 +1,4 @@ -name: Generate and commit Go, Python and TypeScript bindings +name: Generate and commit Go, Python, TypeScript and Rust bindings on: push: @@ -11,6 +11,7 @@ on: - 'bindings/**/go/**' - 'bindings/**/python/**' - 'bindings/**/typescript/**' + - 'bindings/**/rust/**' - 'tooling/extension-bindings/packages.yaml' - 'tooling/extension-bindings/toolchain.lock.yaml' - 'tooling/extension-bindings/model/**' @@ -18,12 +19,13 @@ on: - 'tooling/extension-bindings/emitters/go/**' - 'tooling/extension-bindings/emitters/python/**' - 'tooling/extension-bindings/emitters/typescript/**' + - 'tooling/extension-bindings/emitters/rust/**' - 'tooling/extension-bindings/orchestrator/**' - 'tooling/extension-bindings/ci/**' workflow_dispatch: inputs: targets: - description: 'Comma-separated package-key:go, package-key:python, or package-key:typescript targets, or all; empty uses automatic targets' + description: 'Comma-separated package-key:go, package-key:python, package-key:typescript, or package-key:rust targets, or all; empty uses automatic targets' type: string default: '' @@ -42,7 +44,7 @@ jobs: permissions: contents: write env: - INPUT_TARGETS: ${{ inputs.targets || vars.BINDING_GENERATION_TARGETS || 'common-integrations:go,env-configuration:go,common-integrations:python,env-configuration:python,common-integrations:typescript,env-configuration:typescript' }} + INPUT_TARGETS: ${{ inputs.targets || vars.BINDING_GENERATION_TARGETS || 'common-integrations:go,env-configuration:go,common-integrations:python,env-configuration:python,common-integrations:typescript,env-configuration:typescript,common-integrations:rust,env-configuration:rust' }} GOTOOLCHAIN: local steps: - name: Check out current main @@ -62,6 +64,7 @@ jobs: env: HAS_PYTHON: ${{ steps.tools.outputs.has-python }} HAS_TYPESCRIPT: ${{ steps.tools.outputs.has-typescript }} + HAS_RUST: ${{ steps.tools.outputs.has-rust }} run: | set -euo pipefail python -m unittest discover -s tooling/extension-bindings/ci -p 'test_*.py' @@ -77,6 +80,9 @@ jobs: if [[ "$HAS_TYPESCRIPT" == true ]]; then npm --prefix tooling/extension-bindings/emitters/typescript test fi + if [[ "$HAS_RUST" == true ]]; then + cargo test --locked --manifest-path tooling/extension-bindings/emitters/rust/Cargo.toml + fi - name: Generate and verify bindings env: @@ -89,6 +95,8 @@ jobs: --rc "$RC" --core-schema "$CORE_SCHEMA" python tooling/extension-bindings/ci/generate-typescript.py update --targets "$INPUT_TARGETS" \ --rc "$RC" --core-schema "$CORE_SCHEMA" + python tooling/extension-bindings/ci/generate-rust.py update --targets "$INPUT_TARGETS" \ + --rc "$RC" --core-schema "$CORE_SCHEMA" - name: Commit verified generated source to main env: diff --git a/.github/workflows/binding-publish-cargo.yml b/.github/workflows/binding-publish-cargo.yml new file mode 100644 index 0000000..03db88c --- /dev/null +++ b/.github/workflows/binding-publish-cargo.yml @@ -0,0 +1,36 @@ +name: Publish Rust bindings + +on: + workflow_dispatch: + inputs: + targets: + description: 'Comma-separated package-key:rust targets, or all' + type: string + default: '' + dry_run: + description: 'Verify publication without pushing to crates.io' + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: binding-publish-cargo + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Publish to crates.io (placeholder) + env: + TARGETS: ${{ inputs.targets }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + echo "crates.io 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..a630401 100644 --- a/.github/workflows/extension-binding-normalizer.yml +++ b/.github/workflows/extension-binding-normalizer.yml @@ -9,6 +9,7 @@ on: - "tooling/extension-bindings/README.md" - "tooling/extension-bindings/emitters/go/**" - "tooling/extension-bindings/emitters/typescript/**" + - "tooling/extension-bindings/emitters/rust/**" - "tooling/extension-bindings/model/**" - "tooling/extension-bindings/normalizer/**" push: @@ -20,6 +21,7 @@ on: - "tooling/extension-bindings/README.md" - "tooling/extension-bindings/emitters/go/**" - "tooling/extension-bindings/emitters/typescript/**" + - "tooling/extension-bindings/emitters/rust/**" - "tooling/extension-bindings/model/**" - "tooling/extension-bindings/normalizer/**" workflow_dispatch: @@ -111,3 +113,31 @@ jobs: - name: Test the TypeScript emitter and generated conformance packages run: npm test + + verify-rust-emitter: + name: Verify Rust emitter on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + defaults: + run: + working-directory: tooling/extension-bindings/emitters/rust + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Set up Rust + run: rustup toolchain install 1.92.0 --profile minimal --component clippy --component rustfmt && rustup default 1.92.0 + + - name: Test the Rust emitter and generated conformance packages + run: cargo test --locked + + - name: Lint Rust emitter + run: cargo clippy --locked -- -D warnings diff --git a/tooling/extension-bindings/README.md b/tooling/extension-bindings/README.md index 6b9749e..d195229 100644 --- a/tooling/extension-bindings/README.md +++ b/tooling/extension-bindings/README.md @@ -3,7 +3,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/`. +emitter lives under `emitters/typescript/`, and a native Rust emitter lives under +`emitters/rust/`; its 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. diff --git a/tooling/extension-bindings/ci/generate-rust.py b/tooling/extension-bindings/ci/generate-rust.py new file mode 100644 index 0000000..8bd8e3c --- /dev/null +++ b/tooling/extension-bindings/ci/generate-rust.py @@ -0,0 +1,81 @@ +"""Configure and update verified Rust bindings for the Actions workflow.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +from pathlib import Path + +from generation import ( + Target, + exact_version, + of_language, + select_targets, + summarize, + write_outputs, +) + +LANGUAGE = "rust" +TOOLS = ("cargo", "rustc", "rustfmt") + + +def require_tools() -> None: + missing = [name for name in TOOLS if shutil.which(name) is None] + if missing: + raise ValueError( + "Rust targets require the provisioned toolchain on PATH: missing " + + ", ".join(missing) + ) + + +def update(root: Path, targets: list[Target], rc: Path, core: Path) -> None: + selected = of_language(targets, LANGUAGE) + if not selected: + summarize("No Rust targets selected; nothing to update.") + return + require_tools() + tooling = root / "tooling" / "extension-bindings" + args = [ + str(rc.resolve()), + "bindings", + "update", + "--packages", + str(tooling / "packages.yaml"), + "--toolchain-lock", + str(tooling / "toolchain.lock.yaml"), + "--core-schema", + str(core.resolve()), + ] + for key, _, _ in selected: + args.extend(["--target", key]) + subprocess.run(args, cwd=root, check=True) + summarize(f"Verified and synchronized {len(selected)} Rust binding targets.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["configure", "update"]) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--targets", default="") + parser.add_argument("--rc", type=Path) + parser.add_argument("--core-schema", type=Path) + args = parser.parse_args() + root = args.root.resolve() + try: + targets = select_targets( + root / "tooling/extension-bindings/packages.yaml", args.targets + ) + if args.command == "configure": + version = exact_version(targets, LANGUAGE) + write_outputs(rust_version=version, has_rust="true" if version else "false") + else: + if args.rc is None or args.core_schema is None: + raise ValueError("update requires --rc and --core-schema") + update(root, targets, args.rc, args.core_schema) + except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: + parser.exit(1, f"binding generation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/tooling/extension-bindings/ci/generation.py b/tooling/extension-bindings/ci/generation.py index b1cba1d..b2a0e76 100644 --- a/tooling/extension-bindings/ci/generation.py +++ b/tooling/extension-bindings/ci/generation.py @@ -10,7 +10,7 @@ import yaml -LANGUAGES = {"go": "Go", "python": "Python", "typescript": "TypeScript"} +LANGUAGES = {"go": "Go", "python": "Python", "typescript": "TypeScript", "rust": "Rust"} Target = tuple[str, str, str] diff --git a/tooling/extension-bindings/ci/test_generate_rust.py b/tooling/extension-bindings/ci/test_generate_rust.py new file mode 100644 index 0000000..4245b5e --- /dev/null +++ b/tooling/extension-bindings/ci/test_generate_rust.py @@ -0,0 +1,72 @@ +"""Exercise the Rust generation script.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from generation import select_targets +from testsupport import RepoTestCase, load_script, run_script + +script = load_script("generate-rust.py") + + +class RustGenerationTests(RepoTestCase): + def setUp(self) -> None: + super().setUp() + self.packages["owner"]["languages"]["rust"] = { + "sourceDirectory": "bindings/owner/rust", + "languageVersion": "1.92.0", + } + self.write_catalog() + + def test_configure_reports_the_version_and_whether_rust_is_selected(self) -> None: + output = self.root.parent / "github-output" + for targets, version, has_rust in [ + ("owner:rust", "1.92.0", "true"), + ("owner:go", "", "false"), + ("owner:go,owner:rust", "1.92.0", "true"), + ]: + with self.subTest(targets=targets): + values = run_script( + "generate-rust.py", + self.root, + output, + "configure", + "--targets", + targets, + ) + self.assertEqual(values, {"rust-version": version, "has-rust": has_rust}) + + def test_update_refuses_rust_targets_without_the_toolchain(self) -> None: + targets = select_targets(self.catalog, "owner:rust") + with patch.object(script.shutil, "which", return_value=None), self.assertRaisesRegex( + ValueError, "cargo, rustc, rustfmt" + ): + script.update(self.root, targets, Path("rc"), Path("core.yaml")) + + def test_update_passes_only_rust_targets_to_the_generator(self) -> None: + targets = select_targets(self.catalog, "") + with patch.object(script.shutil, "which", return_value="/bin/tool"), patch.object( + script.subprocess, "run" + ) as run: + script.update(self.root, targets, Path("rc"), Path("core.yaml")) + arguments = run.call_args.args[0] + self.assertEqual( + [arguments[i + 1] for i, arg in enumerate(arguments) if arg == "--target"], + ["owner:rust"], + ) + + def test_update_needs_no_tools_without_rust_targets(self) -> None: + targets = select_targets(self.catalog, "owner:go") + with patch.object(script.shutil, "which", return_value=None), patch.object( + script.subprocess, "run" + ) as run: + script.update(self.root, targets, Path("rc"), Path("core.yaml")) + run.assert_not_called() + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tooling/extension-bindings/emitters/rust/.gitignore b/tooling/extension-bindings/emitters/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tooling/extension-bindings/emitters/rust/CONTRACT.md b/tooling/extension-bindings/emitters/rust/CONTRACT.md new file mode 100644 index 0000000..2f9a5a5 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/CONTRACT.md @@ -0,0 +1,181 @@ +# Rust emitter contract + +This records the Rust target, layout, and naming decisions for the native Rust +emitter. It is written in Rust, as the implementation standard requires of every +emitter, and follows the Python emitter's structure. + +```text +runtimeconditions-binding-emitter-rust emit \ + --model runtimeconditions.binding-model.yaml \ + --package-config --output +``` + +`verify --model --package-config --input ` parses the generated source +with `syn` (never compiling or running it), re-renders the package from the +model, and requires every file to equal the fresh rendering. It prints the +structural signatures and a mapping summary. `api --input ` prints the +structural signatures only. + +## Target configuration + +The selected target uses `runtimeconditions.io/rust-package-target/v1alpha1` and +`RuntimeConditionsRustPackageTarget`, defined by `package-target.schema.yaml`. + +- `packageKey`, `rootExtension`, `version`, `sourceDirectory`, and + `publicationMode` correspond to the package set and language entry in the + catalog. `sourceDirectory` is exactly `bindings//rust`. +- `crateName` is the Cargo package name. Its library identifier replaces `-` + with `_`. It is never inferred from extension vocabulary. +- `minimumRustVersion` is `.` and at least `1.70`. It becomes + `rust-version`. +- `repositoryUrl` must use HTTPS. A registry target additionally requires + `registryId`. +- `dependencies` contains only direct extension dependencies, with their crate + names 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. Supported +prerelease forms are `-alpha.N`, `-beta.N`, and `-rc.N`. Direct dependency +requirements use `>=, `, where `next-breaking` is +`0.(y+1).0` below `1.0.0` and `(major+1).0.0` otherwise. + +## Generated source tree + +```text +/ + Cargo.toml + src/lib.rs + src/bindings.rs + src/bindings/.rs + runtimeconditions/runtimeconditions.bindings.yaml +``` + +`lib.rs` re-exports `bindings.rs`, which declares one module per type, re-exports each +type, and holds `Declaration`, the marker traits, and the declaration functions. A type +module is the snake-case form of the type name; a name that is not ASCII is stored in +`module_<16 hex digits>.rs` and declared with `#[path]`. Every type module starts with +`use super::*;`. `serde_json` is a dependency only when the model uses a JSON value. +The `serde` feature adds `Serialize` and `Deserialize` derives: fields and values keep +their source names, optional fields are skipped when absent, unions are untagged, and +declaration-field wrappers are transparent. Generated source is formatted by `rustfmt` (edition 2021, Unix newlines) from the pinned +toolchain at emission time and starts with the non-editable header +naming the model digest and emitter version; `Cargo.toml` carries it as a `#` +comment. + +## API rules + +1. Every owned kind becomes one function `kind(&[&dyn KindField]) -> Declaration`. + `Declaration` is an inert tuple struct with a private field. +2. A crate that imports marker traits re-exports them from `bindings.rs` with `pub use`, so a + consumer that depends only on the middle package of a chain can import a trait owned by a + package further down. +3. `KindField` is a trait whose only method is `rc_marker_<16 hex digits>`, + derived from the owner and declaration coordinate. Every applicable struct + implements it, so a field belonging to another kind does not compile. + Cross-package additive fields implement the provider's trait, imported with + `use ::`. +4. Objects become structs with public fields, required fields first. An + optional field is `Option`. The emitter does not add constructors or + builders. +5. Scalars map to `String`, `bool`, `i64`, `f64`, `()` for JSON null, and + `serde_json::Value` for any JSON value. +6. Arrays and maps become tuple structs around `Vec` and + `BTreeMap`. Named wrappers keep recursive shapes expressible. +7. Unions become enums with variants `Variant1`, `Variant2`, ... +8. String value domains become enums whose variants are Pascal case and whose + `value()` returns the serialized string. +9. A direct reference is boxed only when it lies on a cycle of direct references. + References through `Vec` and `BTreeMap` already have a finite size. +10. Every type, field, and value carries a doc comment naming its source and the + coordinate and JSON Pointer it was generated from. + +Source names are NFKC-normalized before they are split into words. Types and traits +use Pascal case; fields and functions use snake case. A leading digit gains `X` +(types) or `x`. A Rust keyword becomes a raw identifier (`r#type`); the keywords that +cannot be raw (`self`, `Self`, `super`, `crate`, `_`) gain one trailing underscore. A +name that equals a prelude or generated helper (`Option`, `Vec`, `Declaration`, +...) stops emission instead of being renamed. Collisions are resolved only by +prepending parent path components, nearest first, and otherwise stop emission. + +## Diagnostics + +Every error has a category, a code, a coordinate, an optional JSON Pointer into the +input that failed, and a message. By default it prints as +` : `. With `--json` it prints one JSON object +on stderr: `{"error": {"category", "code", "coordinate", "jsonPointer", "message"}}`. +Categories are `usage`, `input`, `model`, `package-config`, `rust-naming`, `output`, +and `verification`. Target errors point at the failing field, such as `/version`; +schema failures point at the first failing instance location; model shape errors +use the provenance coordinate and JSON Pointer. + +| Code | Meaning | +| --- | --- | +| `RCR0001` | Invalid command line. | +| `RCR1001` | YAML input cannot be read or exceeds the size limit. | +| `RCR1002` | YAML syntax, profile, structure, or resource-limit failure. | +| `RCR1003` | Binding-model schema validation failure. | +| `RCR1004` | Unsupported binding-model `apiVersion`. | +| `RCR1005` | Unsupported binding-model `kind`. | +| `RCR1006` | Invalid Rust package-target field or dependency metadata. | +| `RCR1007` | Target source directory does not match its package key. | +| `RCR1008` | Unsupported minimum Rust version. | +| `RCR1009` | Target root extension or direct dependency set differs from the model. | +| `RCR1010` | Unknown structural shape kind. | +| `RCR1011` | Inconsistent extension closure or declaration ownership. | +| `RCR1012` | Inconsistent shape details, value domain, or unresolved reference. | +| `RCR2000` | A source name cannot be derived as a valid Rust identifier. | +| `RCR2001` | Symbol collision or reserved-name conflict. | +| `RCR3001` | Output directory is not new or empty. | +| `RCR3002` | Output directory cannot be inspected or written. | +| `RCR4001` | Generated source cannot be parsed or has a non-public item. | +| `RCR4002` | Generated package differs from a fresh rendering. | +| `RCR4003` | `rustfmt` is unavailable or rejects the generated source. | + +## Input loading + +The model and target are read as 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 bounded size, node count, and depth. At most +64 MiB plus one byte of a file is read before it is rejected. +The model is then validated against +`model/runtimeconditions.binding-model.schema.yaml`, embedded at build time; +`RC_BINDING_MODEL_SCHEMA` overrides it. + +## Verification + +`cargo test` runs the shared conformance models in `model/conformance/expected`. +For each it checks that three emissions are byte-identical, that `verify` +accepts the output, that the source is `rustfmt --check` clean, that every crate passes `cargo check` with and without `--features serde`, that dependency-free crates round-trip every constructed value through JSON, that the manifest satisfies the binding-manifest schema, and +that the crate compiles with `rustc -D warnings` when it has no dependencies. +Tampered output and invalid inputs must fail with the codes above. + +## Orchestration + +`rc bindings` drives the emitter as an external binary, built with +`cargo build --release --locked` unless `--rust-emitter` or +`RC_BINDINGS_RUST_EMITTER` names an installed one. The catalog entry's +`coordinate` is the crate name, `name` its library identifier, and +`languageVersion` the exact Rust release; `publicationMode` is `github-tag` +until a registry workflow exists. + +- The emitter's version is the crate version in `Cargo.toml`, reported by `--version`. Its + identity digest hashes `Cargo.toml`, `Cargo.lock`, the target schema, and `src/*.rs`, not the + binary, so every platform records the same `emitterSha256` and regenerating is reproducible. +- Assembly writes the model, extension, and release resources into + `runtimeconditions/` and the file manifest at the crate root. `verify` + ignores exactly those four files. +- The release manifest records no profiler, because no independent Rust + profiler exists. The release schema forbids a profiler for Rust and requires + one for Go and Python. +- Native verification requires `rustc` at exactly the catalog version, a + `rustfmt --check`, and `cargo check` of a staged copy whose direct + dependencies point at the staged provider crates. +- The package archive is a deterministic zip of the crate tree, recorded as + `rust-source-zip`. Registry publication is a placeholder workflow. + +## Design notes + +- **Functions.** The declaration functions are free functions in `bindings.rs`; + the manifest names that file. +- **Build tooling.** The emitter and the generated crate use Cargo. Tool + versions are recorded under `languages.rust` in `toolchain.lock.yaml`. diff --git a/tooling/extension-bindings/emitters/rust/Cargo.lock b/tooling/extension-bindings/emitters/rust/Cargo.lock new file mode 100644 index 0000000..171b284 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/Cargo.lock @@ -0,0 +1,1065 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "referencing" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "runtimeconditions-binding-emitter-rust" +version = "0.1.0" +dependencies = [ + "jsonschema", + "quote", + "serde_json", + "serde_yaml", + "sha2", + "syn 2.0.119", + "unicode-normalization", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tooling/extension-bindings/emitters/rust/Cargo.toml b/tooling/extension-bindings/emitters/rust/Cargo.toml new file mode 100644 index 0000000..af3fa53 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "runtimeconditions-binding-emitter-rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde_json = { version = "1", features = ["preserve_order"] } +serde_yaml = "0.9" +sha2 = "0.10" +jsonschema = { version = "0.30", default-features = false } +syn = { version = "2", features = ["full"] } +quote = "1" +unicode-normalization = "0.1" diff --git a/tooling/extension-bindings/emitters/rust/package-target.schema.yaml b/tooling/extension-bindings/emitters/rust/package-target.schema.yaml new file mode 100644 index 0000000..5dd876c --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/package-target.schema.yaml @@ -0,0 +1,70 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://runtimeconditions.io/schemas/rust-package-target/v1alpha1 +title: Runtime Conditions Rust package target +type: object +additionalProperties: false +required: [apiVersion, kind, packageKey, rootExtension, crateName, version, sourceDirectory, minimumRustVersion, publicationMode, repositoryUrl] +properties: + apiVersion: + const: runtimeconditions.io/rust-package-target/v1alpha1 + kind: + const: RuntimeConditionsRustPackageTarget + packageKey: + type: string + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + rootExtension: + type: string + minLength: 1 + crateName: + description: The Cargo package name. It is never inferred from extension vocabulary. + type: string + maxLength: 64 + pattern: '^[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]+)*/rust$' + minimumRustVersion: + type: string + pattern: '^[0-9]+\.[0-9]+$' + 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, crateName, version] + properties: + extension: + type: string + minLength: 1 + crateName: + type: string + maxLength: 64 + pattern: '^[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/rust/src/common.rs b/tooling/extension-bindings/emitters/rust/src/common.rs new file mode 100644 index 0000000..08445f2 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/common.rs @@ -0,0 +1,40 @@ +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use crate::diag::*; + +pub const NAME: &str = "runtimeconditions-binding-emitter-rust"; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const MANIFEST: &str = "runtimeconditions/runtimeconditions.bindings.yaml"; + +pub fn s<'a>(v: &'a Value, k: &str) -> &'a str { + v[k].as_str().unwrap_or("") +} + +pub fn arr<'a>(v: &'a Value, k: &str) -> &'a [Value] { + v[k].as_array().map_or(&[], |a| a.as_slice()) +} + +pub fn loc(v: &Value) -> String { + match s(&v["provenance"], "jsonPointer") { + "" => s(&v["provenance"], "coordinate").into(), + j => format!("{}{SEP}{j}", s(&v["provenance"], "coordinate")), + } +} + +pub fn mref(p: &Value) -> Value { + match s(p, "jsonPointer") { + "" => json!({"coordinate": s(p, "coordinate")}), + j => json!({"coordinate": s(p, "coordinate"), "jsonPointer": j}), + } +} + +pub fn marker_method(owner: &str, coord: &str) -> String { + let d = Sha256::new().chain_update(owner).chain_update([0]).chain_update(coord).finalize(); + format!("rc_marker_{}", &format!("{d:x}")[..16]) +} + +pub fn header(m: &Value) -> String { + format!("Code generated by {NAME} {VERSION} from model {}; DO NOT EDIT.", s(&m["metadata"], "semanticSha256")) +} diff --git a/tooling/extension-bindings/emitters/rust/src/diag.rs b/tooling/extension-bindings/emitters/rust/src/diag.rs new file mode 100644 index 0000000..67829e5 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/diag.rs @@ -0,0 +1,40 @@ +pub type R = Result; + +pub const SEP: char = '\u{1f}'; + +pub struct Diag { + pub category: &'static str, + pub code: String, + pub coordinate: String, + pub pointer: String, + pub message: String, +} + +impl std::fmt::Display for Diag { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let place = if self.pointer.is_empty() { self.coordinate.clone() } else { format!("{} {}", self.coordinate, self.pointer) }; + write!(f, "{} {place}: {}", self.code, self.message) + } +} + +pub fn ep(code: &str, at: &str, pointer: &str, msg: impl std::fmt::Display) -> Diag { + let (coordinate, joined) = at.split_once(SEP).unwrap_or((at, "")); + let category = match code.as_bytes().get(3) { + Some(b'0') => "usage", + Some(b'1') if code == "RCR1001" || code == "RCR1002" => "input", + Some(b'1') if ("RCR1006"..="RCR1009").contains(&code) => "package-config", + Some(b'1') => "model", + Some(b'2') => "rust-naming", + Some(b'3') => "output", + _ => "verification", + }; + Diag { category, code: code.into(), coordinate: coordinate.into(), pointer: if pointer.is_empty() { joined.into() } else { pointer.into() }, message: msg.to_string() } +} + +pub fn e(code: &str, at: &str, msg: impl std::fmt::Display) -> Diag { + ep(code, at, "", msg) +} + +pub fn shown(at: &str) -> String { + at.replace(SEP, "") +} diff --git a/tooling/extension-bindings/emitters/rust/src/input.rs b/tooling/extension-bindings/emitters/rust/src/input.rs new file mode 100644 index 0000000..5973953 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/input.rs @@ -0,0 +1,141 @@ +use serde_json::Value; +use std::collections::HashSet; +use std::{env, fs}; +use crate::common::*; +use crate::diag::*; + +pub const MAX_BYTES: u64 = 64 << 20; + +pub fn read_limited(path: &str) -> R { + use std::io::Read; + let mut buf = Vec::new(); + fs::File::open(path).and_then(|f| f.take(MAX_BYTES + 1).read_to_end(&mut buf)).map_err(|x| e("RCR1001", path, x))?; + if buf.len() as u64 > MAX_BYTES { + return Err(e("RCR1001", path, "exceeds the size limit")); + } + String::from_utf8(buf).map_err(|_| e("RCR1002", path, "must be UTF-8")) +} + +pub fn semver(v: &str) -> Option<(u64, u64, u64)> { + let (core, pre) = v.split_once('-').map_or((v, None), |(c, p)| (c, Some(p))); + let ok = |x: &str| !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit()) && (x == "0" || !x.starts_with('0')); + if let Some(p) = pre { + let (k, n) = p.split_once('.')?; + if !["alpha", "beta", "rc"].contains(&k) || !ok(n) { + return None; + } + } + let p: Vec<&str> = core.split('.').collect(); + (p.len() == 3 && p.iter().all(|x| ok(x))).then(|| (p[0].parse().unwrap(), p[1].parse().unwrap(), p[2].parse().unwrap())) +} + +pub fn load_target(p: &str) -> R { + let t = strict(&read_limited(p)?, p)?; + let k = s(&t, "packageKey"); + let bad = |f: &str, m: &str| ep("RCR1006", k, &format!("/{f}"), m); + if s(&t, "apiVersion") != "runtimeconditions.io/rust-package-target/v1alpha1" || s(&t, "kind") != "RuntimeConditionsRustPackageTarget" { + return Err(bad("apiVersion", "unsupported target apiVersion or kind")); + } + for f in ["packageKey", "rootExtension", "crateName", "version", "sourceDirectory", "minimumRustVersion", "publicationMode", "repositoryUrl"] { + if s(&t, f).is_empty() { + return Err(bad(f, "required string is missing")); + } + } + let crate_ok = |c: &str| c.starts_with(|x: char| x.is_ascii_lowercase()) && c.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_'); + if !crate_ok(s(&t, "crateName")) { + return Err(bad("crateName", "invalid crate name")); + } + if semver(s(&t, "version")).is_none() { + return Err(bad("version", "unsupported package version")); + } + if s(&t, "sourceDirectory") != format!("bindings/{k}/rust") { + return Err(ep("RCR1007", k, "/sourceDirectory", "source directory does not match package key")); + } + let msrv: Vec = s(&t, "minimumRustVersion").split('.').filter_map(|x| x.parse().ok()).collect(); + if msrv.len() != 2 || (msrv[0], msrv[1]) < (1, 70) { + return Err(ep("RCR1008", k, "/minimumRustVersion", "minimum Rust version must be . and at least 1.70")); + } + if !s(&t, "repositoryUrl").starts_with("https://") { + return Err(bad("repositoryUrl", "must use HTTPS")); + } + match (s(&t, "publicationMode"), t.get("registryId").is_some()) { + ("registry", true) | ("github-tag", false) => {} + _ => return Err(bad("registryId", "required exactly for registry publication")), + } + let sha = s(&t, "emitterSha256"); + if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return Err(bad("emitterSha256", "must be a 64-character lowercase SHA-256")); + } + let mut seen = HashSet::new(); + for d in arr(&t, "dependencies") { + if s(d, "extension").is_empty() || !crate_ok(s(d, "crateName")) || semver(s(d, "version")).is_none() || !seen.insert(s(d, "crateName")) { + return Err(bad("dependencies", "invalid or duplicate direct dependency")); + } + } + Ok(t) +} + +pub fn strict(text: &str, at: &str) -> R { + let bad = |m: &str| e("RCR1002", at, m); + if text.len() as u64 > MAX_BYTES { + return Err(e("RCR1001", at, "exceeds the size limit")); + } + if text.starts_with('\u{feff}') || text.contains('\r') || !text.ends_with('\n') || text.ends_with("\n\n") { + return Err(bad("must be UTF-8 without BOM, LF line endings and one trailing newline")); + } + for l in text.lines() { + let (mut q, mut prev, mut ns) = (None, ' ', true); + for c in l.chars() { + if let Some(k) = q { + q = (c != k).then_some(k); + } else { + if ns && (c == '"' || c == '\'') { + q = Some(c); + } else if (ns && "&*!".contains(c)) || (c == '#' && (ns || prev == ' ')) { + return Err(bad("comments, anchors, aliases and tags are not allowed")); + } + ns = (ns && c == ' ') || ":-,[{".contains(c); + } + prev = c; + } + } + fn conv(v: serde_yaml::Value, d: usize, n: &mut usize) -> Result { + use serde_yaml::Value as Y; + *n += 1; + if d > 64 || *n > 1_000_000 { + return Err("exceeds the node or depth limit"); + } + Ok(match v { + Y::Null => Value::Null, + Y::Bool(b) => b.into(), + Y::Number(x) if x.as_f64().is_some_and(|f| !f.is_finite()) => return Err("numbers must be finite"), + Y::Number(x) => serde_json::to_value(x).map_err(|_| "unrepresentable number")?, + Y::String(s) => s.into(), + Y::Sequence(a) => Value::Array(a.into_iter().map(|x| conv(x, d + 1, n)).collect::>()?), + Y::Mapping(m) => Value::Object( + m.into_iter() + .map(|(k, x)| match k { + Y::String(k) => Ok((k, conv(x, d + 1, n)?)), + _ => Err("mapping keys must be strings"), + }) + .collect::>()?, + ), + Y::Tagged(_) => return Err("tags are not allowed"), + }) + } + conv(serde_yaml::from_str(text).map_err(|x| bad(&x.to_string()))?, 0, &mut 0).map_err(bad) +} + +pub fn validate(m: &Value, at: &str) -> R<()> { + let text = match env::var("RC_BINDING_MODEL_SCHEMA") { + Ok(p) => read_limited(&p)?, + Err(_) => include_str!("../../../model/runtimeconditions.binding-model.schema.yaml").into(), + }; + let schema: Value = serde_yaml::from_str(&text).map_err(|x| e("RCR1003", at, x))?; + let v = jsonschema::validator_for(&schema).map_err(|x| e("RCR1003", at, x))?; + let found: Vec<(String, String)> = v.iter_errors(m).map(|x| (x.instance_path.to_string(), x.to_string())).collect(); + match found.first() { + None => Ok(()), + Some((pointer, _)) => Err(ep("RCR1003", at, pointer, found.iter().map(|(p, x)| format!("{p}: {x}")).collect::>().join("; "))), + } +} diff --git a/tooling/extension-bindings/emitters/rust/src/main.rs b/tooling/extension-bindings/emitters/rust/src/main.rs new file mode 100644 index 0000000..9f06eca --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/main.rs @@ -0,0 +1,96 @@ +mod common; +mod diag; +mod input; +mod manifest; +mod naming; +mod plan; +mod render; +mod verify; +mod yaml; + +use serde_json::json; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::{env, fs, path::Path, process::exit}; +use crate::common::*; +use crate::diag::*; +use crate::input::*; +use crate::plan::*; +use crate::render::*; +use crate::verify::*; + +fn run(a: &[String]) -> R { + let usage = || ep("RCR0001", "usage", "", format!("usage: {NAME} [--json] emit|verify|api --model FILE --package-config FILE (--output DIR | --input DIR)")); + let cmd = a.first().map(String::as_str).unwrap_or(""); + let mut o: HashMap<&str, &str> = HashMap::new(); + if !["emit", "verify", "api"].contains(&cmd) || a.len().is_multiple_of(2) { + return Err(usage()); + } + for kv in a[1..].chunks(2) { + if !["--model", "--package-config", "--output", "--input"].contains(&kv[0].as_str()) || o.insert(&kv[0], &kv[1]).is_some() { + return Err(usage()); + } + } + let need = |k: &str| o.get(k).copied().ok_or_else(usage); + if cmd == "api" { + let dir = Path::new(need("--input")?); + let mut have = BTreeMap::new(); + read_tree(dir, dir, &mut have)?; + return Ok(json!({"api": api(&have)?}).to_string()); + } + let (mp, tp) = (need("--model")?, need("--package-config")?); + let m = strict(&read_limited(mp)?, mp)?; + if s(&m, "apiVersion") != "runtimeconditions.io/binding-model/v1alpha1" { + return Err(e("RCR1004", mp, "unsupported binding-model apiVersion")); + } + if s(&m, "kind") != "RuntimeConditionsBindingModel" { + return Err(e("RCR1005", mp, "unsupported binding-model kind")); + } + validate(&m, mp)?; + let t = load_target(tp)?; + let key = s(&t, "packageKey"); + let files = render(&m, &t, &build(&m, &t)?)?; + if cmd == "verify" { + let dir = Path::new(need("--input")?); + let mut have = BTreeMap::new(); + read_tree(dir, dir, &mut have)?; + for assembled in ["binding-model", "binding-release"] { + have.remove(&format!("runtimeconditions/runtimeconditions.{assembled}.yaml")); + } + have.remove("runtimeconditions/runtimeconditions.extension.yaml"); + have.remove("runtimeconditions.file-manifest.yaml"); + if have != files { + let diff: Vec<&String> = have.keys().chain(files.keys()).filter(|k| have.get(*k) != files.get(*k)).collect::>().into_iter().collect(); + return Err(e("RCR4002", key, format!("generated package differs from a fresh rendering: {diff:?}"))); + } + return Ok(json!({"api": api(&have)?, "modelMapping": format!("verified {} files", have.len())}).to_string()); + } + let out = Path::new(need("--output")?); + if out.is_symlink() || (out.exists() && (!out.is_dir() || out.read_dir().map_err(|_| e("RCR3002", key, "cannot inspect output directory"))?.next().is_some())) { + return Err(e("RCR3001", key, "output directory must be new or empty and not a symbolic link")); + } + for (f, c) in &files { + let d = out.join(f); + fs::create_dir_all(d.parent().unwrap()).and_then(|_| fs::write(&d, c)).map_err(|_| e("RCR3002", key, "cannot write generated package files"))?; + } + Ok(json!({"output": out}).to_string()) +} + +fn main() { + let mut a: Vec = env::args().skip(1).collect(); + let json = a.iter().any(|x| x == "--json"); + a.retain(|x| x != "--json"); + if a == ["--version"] { + return println!("{VERSION}"); + } + match run(&a) { + Ok(x) => println!("{x}"), + Err(d) => { + if json { + eprintln!("{}", json!({"error": {"category": d.category, "code": d.code, "coordinate": d.coordinate, "jsonPointer": d.pointer, "message": d.message}})); + } else { + eprintln!("{d}"); + } + exit(1); + } + } +} diff --git a/tooling/extension-bindings/emitters/rust/src/manifest.rs b/tooling/extension-bindings/emitters/rust/src/manifest.rs new file mode 100644 index 0000000..160b876 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/manifest.rs @@ -0,0 +1,120 @@ +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use crate::common::*; +use crate::diag::*; +use crate::plan::*; +use crate::render::*; +use crate::yaml::*; + +pub fn manifest(m: &Value, t: &Value, p: &Plan, h: &str) -> R { + let n = &p.names; + let root = s(&m["rootExtension"], "id"); + let r = |x: &Ty| match x { + Ty::Prim(b) => json!({"builtin": b}), + Ty::Named(k) => json!({"type": n[k]}), + }; + let contracts: HashMap<&str, &str> = p.decls.iter().map(|d| (d.method.as_str(), d.coord.as_str())).chain(p.imps.iter().map(|i| (i.method.as_str(), i.coord.as_str()))).collect(); + let mut types = vec![]; + for (k, raw) in &p.raw { + let wrapper = matches!(raw.body, Body::Obj(_)) && s(&raw.shape, "kind") != "object"; + let construct = match &raw.body { + Body::Obj(_) if wrapper => "wrapper", + Body::Obj(_) => "object", + Body::Enm(_) => "scalar", + Body::Arr(_) => "collection", + Body::Map(_) => "map", + Body::Uni(_) => "union", + }; + let prov = &raw.shape["provenance"]; + let mut v = json!({"modelRef": mref(prov), "sourceName": raw.src, "nativeName": n[k], "construct": construct, "file": format!("bindings/{}.rs", module_file(&n[k]))}); + match &raw.body { + Body::Enm(ms) => { + v["underlying"] = "string".into(); + v["members"] = ms.iter().map(|(mk, val)| json!({"modelRef": mref(prov), "nativeName": n[mk], "value": val})).collect(); + } + Body::Obj(fs) => { + let mut fs: Vec<&Fld> = fs.iter().collect(); + fs.sort_by(|a, b| (!a.req, &n[&a.key]).cmp(&(!b.req, &n[&b.key]))); + v["fields"] = fs + .iter() + .map(|f| { + let mut x = json!({"modelRef": mref(&f.prov), "sourceName": if wrapper { &raw.src } else { &f.src }, "nativeName": n[&f.key], "required": f.req, "value": r(&f.ty)}); + if wrapper { + x["synthetic"] = true.into(); + } + x + }) + .collect(); + if let Some(mk) = &raw.mk { + v["implements"] = json!([{"declarationCoordinate": contracts[mk.method.as_str()], "markerMethod": mk.method}]); + } + } + Body::Arr(x) | Body::Map(x) => { + let c = &raw.shape[if construct == "collection" { "items" } else { "mapValues" }]; + v["element"] = json!({"modelRef": mref(&c["provenance"]), "value": r(x)}); + } + Body::Uni(xs) => { + v["variants"] = arr(&raw.shape, "variants").iter().zip(xs).map(|(c, x)| json!({"modelRef": mref(&c["provenance"]), "value": r(x)})).collect(); + } + } + types.push(v); + } + types.sort_by_key(|v| s(v, "nativeName").to_string()); + + let decl: HashMap<&str, &Value> = arr(&m["vocabulary"], "ownedDeclarations") + .iter() + .chain(arr(&m["vocabulary"], "importedDeclarations")) + .map(|d| (s(d, "kind"), d)) + .collect(); + let cfields: HashSet<(&str, &str, &str)> = arr(&m["vocabulary"], "conditionFields") + .iter() + .filter(|f| s(f, "owner") == root) + .map(|f| (s(f, "kind"), s(f, "interfaceType"), s(f, "path"))) + .collect(); + let mut binds = vec![]; + for sc in arr(m, "schemas").iter().filter(|x| s(x, "owner") == root) { + let kind = sc["kind"].as_str().unwrap_or_else(|| s(sc, "id")); + let Some(d) = decl.get(kind) else { continue }; + let iface = s(sc, "interfaceType"); + for pr in arr(&sc["projection"], "properties") { + let nm = s(pr, "name"); + let sco = s(sc, "coordinate"); + let native = n.get(&format!("type:field:{sco}:{nm}")).ok_or_else(|| e("RCR1012", sco, format!("root property '{nm}' has no declaration field type")))?; + let is_if = nm == "interface" && !iface.is_empty(); + let vf = cfields.contains(&(kind, iface, nm)); + let role = if is_if { "interface" } else if vf { "condition-field" } else { "schema-field" }; + let mut scope = json!({"kind": kind}); + if !iface.is_empty() { + scope["interfaceType"] = iface.into(); + } + let mut b = json!({"role": role, "modelRef": mref(&pr["provenance"]), "declarationCoordinate": s(d, "coordinate"), "scope": scope, "sourceName": if is_if { iface } else { nm }, "path": [{"name": nm}], "value": {"type": native}, "schemaCoordinate": sco}); + if is_if { + b["fixedInterfaceType"] = iface.into(); + } + if vf { + if let Some(f) = arr(&m["vocabulary"], "conditionFields").iter().find(|f| s(f, "owner") == root && s(f, "kind") == kind && s(f, "interfaceType") == iface && s(f, "path") == nm) { + b["vocabularyCoordinate"] = s(f, "coordinate").into(); + } + } + binds.push(b); + } + } + binds.sort_by_key(|b| (s(b, "declarationCoordinate").to_string(), s(&b["modelRef"], "coordinate").to_string(), s(&b["modelRef"], "jsonPointer").to_string())); + + let crate_name = s(t, "crateName"); + let doc = json!({ + "apiVersion": "runtimeconditions.io/bindings/v1alpha2", + "kind": "RuntimeConditionsBindingManifest", + "generated": {"nonEditable": true, "emitter": format!("{NAME}@sha256:{}", s(t, "emitterSha256")), "version": VERSION}, + "model": {"apiVersion": s(m, "apiVersion"), "semanticSha256": s(&m["metadata"], "semanticSha256")}, + "extension": {"id": root, "semanticSha256": s(&m["rootExtension"], "semanticSha256")}, + "package": {"language": "rust", "coordinate": crate_name, "name": crate_name.replace('-', "_"), "version": s(t, "version"), "minimumRustVersion": s(t, "minimumRustVersion")}, + "declarations": p.decls.iter().map(|d| json!({"modelRef": mref(&d.prov), "owner": root, "sourceName": d.kind, "function": n[&d.func], "markerInterface": n[&d.proto], "markerMethod": d.method, "file": "bindings.rs"})).collect::>(), + "importedMarkerContracts": p.imps.iter().map(|i| json!({"modelRef": mref(&i.prov), "owner": i.owner, "sourceName": i.kind, "markerInterface": n[&i.proto], "markerMethod": i.method, "providerPackage": i.krate})).collect::>(), + "rootBindings": binds, + "types": types, + }); + let mut out = format!("# {h}\n"); + yaml(&doc, 0, false, &mut out); + Ok(out) +} diff --git a/tooling/extension-bindings/emitters/rust/src/naming.rs b/tooling/extension-bindings/emitters/rust/src/naming.rs new file mode 100644 index 0000000..6ded791 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/naming.rs @@ -0,0 +1,145 @@ +use unicode_normalization::UnicodeNormalization; +use std::collections::{BTreeMap, HashMap}; +use crate::diag::*; + +pub const KEYWORDS: &[&str] = &[ + "_", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate", "do", "dyn", + "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", + "match", "mod", "move", "mut", "override", "priv", "pub", "ref", "return", "self", "Self", "static", "struct", + "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where", "while", + "yield", +]; + +pub const NOT_RAW: &[&str] = &["_", "self", "Self", "super", "crate"]; + +pub const RESERVED: &[&str] = &[ + "Option", "Some", "None", "Result", "Ok", "Err", "Vec", "String", "Box", "BTreeMap", "Value", "Clone", "Copy", + "Debug", "PartialEq", "Eq", "Default", "Send", "Sync", "Sized", "Drop", "Fn", "From", "Into", "Iterator", +]; + +#[derive(Clone, Copy, PartialEq)] +pub enum St { + Pascal, + Snake, + Exact, +} + +pub fn ident(n: &str) -> bool { + let mut c = n.chars(); + c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || x == '_') && n != "_" +} + +pub fn tokens(x: &str) -> Vec { + let c: Vec = x.nfkc().collect(); + let (mut out, mut cur) = (vec![], String::new()); + for i in 0..c.len() { + if !c[i].is_alphanumeric() { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + continue; + } + let p = i.checked_sub(1).map(|j| c[j]); + let n = c.get(i + 1).copied(); + let cut = c[i].is_uppercase() + && p.is_some_and(|p| { + p.is_lowercase() || p.is_ascii_digit() || (p.is_uppercase() && n.is_some_and(|n| n.is_lowercase())) + }); + if cut && !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + cur.push(c[i]); + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +pub fn name(src: &str, st: St, at: &str) -> R { + let bad = |n: &str| e("RCR2000", at, format!("invalid Rust identifier {n:?}")); + if st == St::Exact { + return if ident(src) { Ok(src.into()) } else { Err(bad(src)) }; + } + let t = tokens(src); + if t.is_empty() { + return Err(bad(src)); + } + let mut n = if st == St::Pascal { + t.iter() + .map(|p| { + let mut c = p.chars(); + c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::()).unwrap() + }) + .collect() + } else { + t.iter().map(|p| p.to_lowercase()).collect::>().join("_") + }; + if n.starts_with(|c: char| c.is_numeric()) { + n.insert(0, if st == St::Snake { 'x' } else { 'X' }); + } + if !ident(&n) { + return Err(bad(&n)); + } + if KEYWORDS.contains(&n.as_str()) { + if NOT_RAW.contains(&n.as_str()) { n.push('_') } else { n.insert_str(0, "r#") } + } + Ok(n) +} + +pub struct Sym { + pub key: String, + pub at: String, + pub src: String, + pub st: St, + pub ns: String, + pub par: Vec, + pub fixed: bool, +} + +pub fn cand(y: &Sym, l: usize) -> R { + let mut p: Vec<&str> = y.par[..l].iter().rev().map(String::as_str).collect(); + p.push(&y.src); + name(&p.join("_"), y.st, &y.at) +} + +pub fn allocate(pkg: &str, syms: &[Sym]) -> R> { + let mut lv: HashMap<&str, usize> = syms.iter().map(|y| (y.key.as_str(), 0)).collect(); + if lv.len() != syms.len() { + return Err(e("RCR2001", pkg, "duplicate symbol request key")); + } + let depth = syms.iter().map(|y| y.par.len()).max().unwrap_or(0); + for _ in 0..=syms.len() * (depth + 1) { + let mut groups: BTreeMap<(&str, String), Vec<&Sym>> = BTreeMap::new(); + for y in syms { + groups.entry((&y.ns, cand(y, lv[y.key.as_str()])?)).or_default().push(y); + } + let mut hit = vec![]; + for ((ns, n), g) in &groups { + let reserved = *ns == "module" && RESERVED.contains(&n.as_str()); + if g.len() > 1 || reserved { + if reserved || g.iter().any(|y| y.fixed) { + let mut at: Vec = g.iter().map(|y| shown(&y.at)).collect(); + at.sort(); + return Err(e("RCR2001", pkg, format!("symbol '{n}' conflicts: {}", at.join(" and ")))); + } + hit.push(g.clone()); + } + } + if hit.is_empty() { + return syms.iter().map(|y| Ok((y.key.clone(), cand(y, lv[y.key.as_str()])?))).collect(); + } + for g in hit { + for y in &g { + let l = lv.get_mut(y.key.as_str()).unwrap(); + if *l == y.par.len() { + let mut at: Vec = g.iter().map(|y| shown(&y.at)).collect(); + at.sort(); + return Err(e("RCR2001", pkg, format!("symbol '{}' conflicts after full path: {}", y.src, at.join(" and ")))); + } + *l += 1; + } + } + } + Err(e("RCR2001", pkg, "symbol allocation did not converge")) +} diff --git a/tooling/extension-bindings/emitters/rust/src/plan.rs b/tooling/extension-bindings/emitters/rust/src/plan.rs new file mode 100644 index 0000000..56226c7 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/plan.rs @@ -0,0 +1,388 @@ +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use crate::common::*; +use crate::diag::*; +use crate::naming::*; + +#[derive(Clone)] +pub enum Ty { + Prim(&'static str), + Named(String), +} + +#[derive(Clone)] +pub struct Mk { + pub owner: String, + pub proto: String, + pub method: String, + pub krate: Option, +} + +pub struct Fld { + pub key: String, + pub src: String, + pub ty: Ty, + pub req: bool, + pub prov: Value, +} + +pub enum Body { + Obj(Vec), + Enm(Vec<(String, String)>), + Arr(Ty), + Map(Ty), + Uni(Vec), +} + +pub struct Raw { + pub shape: Value, + pub body: Body, + pub mk: Option, + pub src: String, +} + +pub struct Decl { + pub coord: String, + pub kind: String, + pub func: String, + pub proto: String, + pub method: String, + pub prov: Value, +} + +pub struct Imp { + pub coord: String, + pub owner: String, + pub kind: String, + pub proto: String, + pub method: String, + pub krate: String, + pub prov: Value, +} + +#[derive(Default)] +pub struct B { + pub syms: Vec, + pub raw: BTreeMap, + pub defs: HashMap<(String, String), String>, + pub used: BTreeSet, + pub resolved: HashMap, + pub any: bool, +} + +impl B { + #[allow(clippy::too_many_arguments)] + pub fn sym(&mut self, key: &str, at: &str, src: &str, st: St, ns: &str, par: &[String], fixed: bool) { + let ns = if ns.is_empty() { "module" } else { ns }; + self.syms.push(Sym { key: key.into(), at: at.into(), src: src.into(), st, ns: ns.into(), par: par.into(), fixed }); + } + + pub fn reg(&mut self, sh: &Value, key: &str, src: &str, par: &[String], mk: Option<&Mk>, exact: Option<&str>) -> R { + let at = loc(sh); + let kind = s(sh, "kind"); + let sn = exact.unwrap_or(src).to_string(); + let put = |b: &mut B, body: Body| { + b.raw.insert(key.into(), Raw { shape: sh.clone(), body, mk: mk.cloned(), src: sn.clone() }); + }; + match kind { + "any" => { + self.any = true; + Ok(Ty::Prim("serde_json::Value")) + } + "scalar" => { + let vals = arr(sh, "values"); + if vals.is_empty() || s(sh, "scalar") != "string" { + return Ok(Ty::Prim(match s(sh, "scalar") { + "string" => "String", + "boolean" => "bool", + "integer" => "i64", + "number" => "f64", + _ => "()", + })); + } + self.sym(key, &at, src, St::Pascal, "", par, false); + let mut ms = vec![]; + for (i, v) in vals.iter().enumerate() { + let v = v["value"].as_str().ok_or_else(|| e("RCR1012", &at, "string domain contains non-string value"))?; + let mk = format!("member:{key}:{i}"); + self.sym(&mk, &format!("{at}#value:{v}"), v, St::Pascal, &format!("enum:{key}"), &[], false); + ms.push((mk, v.to_string())); + } + put(self, Body::Enm(ms)); + Ok(Ty::Named(key.into())) + } + "ref" => { + let r = s(sh, "ref"); + let r = r.strip_prefix('#').unwrap_or(r); + let k = self.defs.get(&(s(&sh["provenance"], "coordinate").into(), r.into())).cloned(); + let k = k.ok_or_else(|| e("RCR1012", &at, format!("unresolved reference '{}'", s(sh, "ref"))))?; + self.used.insert(k.clone()); + Ok(Ty::Named(k)) + } + "object" => { + self.sym(key, &at, src, St::Pascal, "", par, false); + let ns = format!("fields:{key}"); + if let Some(m) = mk { + self.sym(&format!("method:{key}"), &m.owner, &m.method, St::Exact, &ns, &[], true); + } + let props = arr(sh, "properties"); + let names: BTreeSet<&str> = props.iter().map(|p| s(p, "name")).collect(); + let req: BTreeSet<&str> = arr(sh, "required").iter().filter_map(Value::as_str).collect(); + let missing: Vec<&&str> = req.difference(&names).collect(); + if !missing.is_empty() { + return Err(e("RCR1012", &at, format!("required properties are missing: {missing:?}"))); + } + if names.len() != props.len() { + return Err(e("RCR1012", &at, "duplicate object property")); + } + let mut fs = vec![]; + for p in props { + let n = s(p, "name"); + let fk = format!("field:{key}:{n}"); + self.sym(&fk, &loc(p), n, St::Snake, &ns, &[], false); + let cp: Vec = std::iter::once(src.to_string()).chain(par.iter().cloned()).collect(); + let ty = self.reg(&p["shape"], &format!("type:{key}:{n}"), n, &cp, None, None)?; + let req = req.contains(n) || p["required"].as_bool().unwrap_or(false); + fs.push(Fld { key: fk, src: n.into(), ty, req, prov: p["provenance"].clone() }); + } + put(self, Body::Obj(fs)); + Ok(Ty::Named(key.into())) + } + "array" | "map" | "union" => { + let kids: Vec<&Value> = if kind == "union" { arr(sh, "variants").iter().collect() } else { vec![&sh[if kind == "array" { "items" } else { "mapValues" }]] }; + if kids.is_empty() { + return Err(e("RCR1012", &at, "union has no variants")); + } + let mut tys = vec![]; + for (i, k) in kids.iter().enumerate() { + let suf = match kind { "array" => "Item".into(), "map" => "Value".into(), _ => format!("Variant{}", i + 1) }; + let base = if kind == "array" { src.strip_suffix(" Value").unwrap_or(src) } else { src }; + tys.push(self.reg(k, &format!("type:{key}:{suf}"), &format!("{base} {suf}"), par, None, Some(&sn))?); + } + self.sym(key, &at, src, St::Pascal, "", par, false); + let mut tys = tys.into_iter(); + put(self, match kind { + "array" => Body::Arr(tys.next().unwrap()), + "map" => Body::Map(tys.next().unwrap()), + _ => Body::Uni(tys.collect()), + }); + Ok(Ty::Named(key.into())) + } + _ => Err(e("RCR1010", &at, format!("unknown structural node '{kind}'"))), + } + } +} + +pub struct Plan { + pub names: HashMap, + pub decls: Vec, + pub imps: Vec, + pub raw: BTreeMap, + pub any: bool, +} + +pub fn reaches(g: &HashMap<&str, Vec<&str>>, cur: &str, owner: &str, seen: &mut HashSet) -> bool { + cur == owner + || (seen.insert(cur.into()) && g.get(cur).is_some_and(|d| d.iter().any(|x| reaches(g, x, owner, seen)))) +} + +pub fn build(m: &Value, t: &Value) -> R { + let root = s(&m["rootExtension"], "id"); + let pkg = s(t, "packageKey"); + if root != s(t, "rootExtension") { + return Err(ep("RCR1009", pkg, "/rootExtension", format!("target root '{}' differs from model root '{root}'", s(t, "rootExtension")))); + } + let exts = arr(m, "extensions"); + let ext = exts.iter().find(|x| s(x, "id") == root).ok_or_else(|| e("RCR1011", root, "root extension is absent from resolved extension closure"))?; + let direct: BTreeSet<&str> = arr(ext, "dependencies").iter().filter_map(Value::as_str).collect(); + let deps = arr(t, "dependencies"); + let conf: BTreeSet<&str> = deps.iter().map(|d| s(d, "extension")).collect(); + if direct != conf { + return Err(ep("RCR1009", pkg, "/dependencies", format!("direct dependency set differs: model={direct:?}, target={conf:?}"))); + } + let ids: HashSet<&str> = exts.iter().map(|x| s(x, "id")).collect(); + if !conf.iter().all(|c| ids.contains(c)) { + return Err(e("RCR1011", root, "target dependency is absent from resolved closure")); + } + let graph: HashMap<&str, Vec<&str>> = + exts.iter().map(|x| (s(x, "id"), arr(x, "dependencies").iter().filter_map(Value::as_str).collect())).collect(); + let mut sorted: Vec<&Value> = deps.iter().collect(); + sorted.sort_by_key(|d| s(d, "extension")); + + let mut b = B::default(); + let voc = &m["vocabulary"]; + let (mut decls, mut imps, mut owned, mut imported) = (vec![], vec![], HashMap::new(), HashMap::new()); + for d in arr(voc, "ownedDeclarations") { + let (c, k) = (s(d, "coordinate"), s(d, "kind")); + if s(d, "owner") != root { + return Err(e("RCR1011", c, "owned declaration has a foreign owner")); + } + if owned.insert(k, c).is_some() { + return Err(e("RCR1011", c, format!("duplicate owned kind '{k}'"))); + } + b.sym(&format!("function:{c}"), c, k, St::Snake, "", &[], true); + b.sym(&format!("protocol:{c}"), c, &format!("{k} Field"), St::Pascal, "", &[], true); + decls.push(Decl { coord: c.into(), kind: k.into(), func: format!("function:{c}"), proto: format!("protocol:{c}"), method: marker_method(root, c), prov: d["provenance"].clone() }); + } + if !decls.is_empty() { + b.sym("builtin:Declaration", "rust:Declaration", "Declaration", St::Pascal, "", &[], true); + } + for d in arr(voc, "importedDeclarations") { + let (o, c, k) = (s(d, "owner"), s(d, "coordinate"), s(d, "kind")); + if o == root || !ids.contains(o) { + return Err(e("RCR1011", c, "invalid imported declaration owner")); + } + b.sym(&format!("import:function:{c}"), c, k, St::Snake, "", &[], true); + b.sym(&format!("import:protocol:{c}"), c, &format!("{k} Field"), St::Pascal, "", &[], true); + let krate = sorted + .iter() + .find(|x| reaches(&graph, s(x, "extension"), o, &mut HashSet::new())) + .map(|x| s(x, "crateName")) + .ok_or_else(|| e("RCR1011", o, "imported declaration has no direct dependency provider"))?; + let imp = Imp { coord: c.into(), owner: o.into(), kind: k.into(), proto: format!("import:protocol:{c}"), method: marker_method(o, c), krate: krate.into(), prov: d["provenance"].clone() }; + if imported.insert(k, (c, imps.len())).is_some() { + return Err(e("RCR1011", c, "duplicate imported kind")); + } + imps.push(imp); + } + + let fields_of = |k: &str, i: &str, n: &str| (k.to_string(), i.to_string(), n.to_string()); + let roots: HashSet<_> = arr(voc, "conditionFields") + .iter() + .filter(|f| s(f, "owner") == root) + .map(|f| fields_of(s(f, "kind"), s(f, "interfaceType"), s(&f["segments"][0], "name"))) + .collect(); + let scopes: HashMap<_, _> = + arr(m, "scopes").iter().map(|x| ((s(x, "kind"), s(x, "interfaceType")), &x["projection"])).collect(); + let schemas = arr(m, "schemas"); + for sc in schemas { + for d in arr(sc, "definitions") { + b.defs.insert((s(sc, "coordinate").into(), s(d, "jsonPointer").into()), format!("type:{}:{}", s(sc, "coordinate"), s(d, "jsonPointer"))); + } + } + let mut pending: BTreeMap)> = BTreeMap::new(); + for sc in schemas.iter().filter(|x| s(x, "owner") == root) { + let sco = s(sc, "coordinate"); + let kind = sc["kind"].as_str().unwrap_or_else(|| s(sc, "id")); + let iface = s(sc, "interfaceType"); + let scope: Vec = [iface, kind].into_iter().filter(|x| !x.is_empty()).map(String::from).collect(); + let mk = if let Some(c) = owned.get(kind) { + Some(Mk { owner: root.into(), proto: format!("protocol:{c}"), method: marker_method(root, c), krate: None }) + } else { + imported.get(kind).map(|(_, i)| { + let p: &Imp = &imps[*i]; + Mk { owner: p.owner.clone(), proto: p.proto.clone(), method: p.method.clone(), krate: Some(p.krate.clone()) } + }) + }; + let proj = &sc["projection"]; + if s(proj, "kind") != "object" { + return Err(e("RCR1012", sco, "root condition projection must be an object")); + } + for p in arr(proj, "properties") { + let n = s(p, "name"); + let is_if = n == "interface" && !iface.is_empty(); + let fsrc = if is_if { iface } else { n }; + let par: Vec = is_if.then(|| n.to_string()).into_iter().chain(scope.iter().cloned()).collect(); + let key = format!("type:field:{sco}:{n}"); + let mut shape = &p["shape"]; + if roots.contains(&fields_of(kind, iface, n)) { + shape = scopes + .get(&(kind, iface)) + .and_then(|pr| arr(pr, "properties").iter().find(|x| s(x, "name") == n)) + .map(|x| &x["shape"]) + .ok_or_else(|| e("RCR1012", sco, format!("condition field '{n}' is absent from its scope projection")))?; + } + if s(shape, "kind") == "object" { + b.reg(shape, &key, fsrc, &par, mk.as_ref(), None)?; + continue; + } + let m = mk.as_ref().ok_or_else(|| e("RCR1011", sco, format!("field '{n}' has no declaration contract")))?; + let (at, ns, vk) = (loc(p), format!("fields:{key}"), format!("field:{key}:value")); + b.sym(&key, &at, fsrc, St::Pascal, "", &par, false); + b.sym(&format!("method:{key}"), &m.owner, &m.method, St::Exact, &ns, &[], true); + b.sym(&vk, &at, "value", St::Snake, &ns, &[], true); + let ty = b.reg(shape, &format!("type:{key}:value"), &format!("{fsrc} Value"), &par, None, None)?; + let f = Fld { key: vk, src: "value".into(), ty, req: true, prov: p["provenance"].clone() }; + b.raw.insert(key, Raw { shape: shape.clone(), body: Body::Obj(vec![f]), mk: mk.clone(), src: fsrc.into() }); + } + for d in arr(sc, "definitions") { + let dk = b.defs[&(sco.to_string(), s(d, "jsonPointer").to_string())].clone(); + if arr(proj, "properties").is_empty() { + b.used.insert(dk.clone()); + } + pending.insert(dk, (d, scope.clone())); + } + } + loop { + let need: Vec = b.used.iter().filter(|k| pending.contains_key(*k)).cloned().collect(); + if need.is_empty() { + break; + } + for k in need { + let (d, sc) = pending.remove(&k).unwrap(); + let ty = b.reg(&d["shape"], &k, s(d, "name"), &sc, None, None)?; + if !b.raw.contains_key(&k) { + b.resolved.insert(k, ty); + } + } + } + let keys: HashSet = b.raw.keys().cloned().collect(); + for r in b.raw.values_mut() { + match &mut r.body { + Body::Obj(fs) => { + for f in fs { + f.ty = subst(&f.ty, &b.resolved, &keys, 0)?; + } + } + Body::Arr(t) | Body::Map(t) => *t = subst(t, &b.resolved, &keys, 0)?, + Body::Uni(vs) => { + for v in vs { + *v = subst(v, &b.resolved, &keys, 0)?; + } + } + Body::Enm(_) => {} + } + } + let names = allocate(pkg, &b.syms)?; + decls.sort_by(|a, c| a.coord.cmp(&c.coord)); + Ok(Plan { names, decls, imps, raw: b.raw, any: b.any }) +} + +fn direct(raw: &BTreeMap, key: &str) -> Vec { + let named = |t: &Ty| match t { + Ty::Named(k) if raw.contains_key(k) => Some(k.clone()), + _ => None, + }; + match &raw[key].body { + Body::Obj(fs) => fs.iter().filter_map(|f| named(&f.ty)).collect(), + Body::Uni(vs) => vs.iter().filter_map(named).collect(), + _ => vec![], + } +} + +fn reaches_type(raw: &BTreeMap, from: &str, to: &str, seen: &mut HashSet) -> bool { + from == to || (seen.insert(from.into()) && direct(raw, from).iter().any(|k| reaches_type(raw, k, to, seen))) +} + +pub fn boxed(raw: &BTreeMap) -> HashSet<(String, String)> { + let mut out = HashSet::new(); + for owner in raw.keys() { + for target in direct(raw, owner) { + if reaches_type(raw, &target, owner, &mut HashSet::new()) { + out.insert((owner.clone(), target)); + } + } + } + out +} + +fn subst(t: &Ty, resolved: &HashMap, keys: &HashSet, depth: usize) -> R { + match t { + Ty::Named(k) if !keys.contains(k) => match resolved.get(k) { + Some(next) if depth < 64 => subst(next, resolved, keys, depth + 1), + Some(_) => Err(e("RCR1012", k, "circular reference chain")), + None => Err(e("RCR1012", k, "unresolved reference")), + }, + _ => Ok(t.clone()), + } +} diff --git a/tooling/extension-bindings/emitters/rust/src/render.rs b/tooling/extension-bindings/emitters/rust/src/render.rs new file mode 100644 index 0000000..2f68281 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/render.rs @@ -0,0 +1,174 @@ +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use crate::common::*; +use crate::diag::*; +use crate::input::*; +use crate::manifest::*; +use crate::naming::*; +use crate::plan::*; + +const DERIVE: &str = "#[derive(Clone, Debug, PartialEq)]"; +const SERDE: &str = "#[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]"; + +pub fn ann(t: &Ty, n: &HashMap) -> String { + match t { + Ty::Prim(p) => p.to_string(), + Ty::Named(k) => n[k].clone(), + } +} + +fn held(t: &Ty, owner: &str, n: &HashMap, boxed: &HashSet<(String, String)>) -> String { + match t { + Ty::Named(k) if boxed.contains(&(owner.to_string(), k.clone())) => format!("Box<{}>", n[k]), + _ => ann(t, n), + } +} + +pub fn module_ident(type_name: &str) -> R { + name(type_name, St::Snake, type_name) +} + +pub fn module_file(type_name: &str) -> String { + let ident = module_ident(type_name).unwrap_or_default(); + if ident.is_ascii() { + ident.trim_start_matches("r#").to_string() + } else { + format!("module_{}", &format!("{:x}", Sha256::digest(type_name))[..16]) + } +} + +fn origin(prov: &Value) -> String { + match s(prov, "jsonPointer") { + "" => s(prov, "coordinate").into(), + j => format!("{} {j}", s(prov, "coordinate")), + } +} + +pub fn interval(v: &str) -> String { + let (a, b, _) = semver(v).unwrap(); + format!(">={v}, <{}", if a == 0 { format!("0.{}.0", b + 1) } else { format!("{}.0.0", a + 1) }) +} + +pub fn rustfmt(src: String) -> R { + use std::io::Write; + use std::process::{Command, Stdio}; + let bad = |x: String| e("RCR4003", "rustfmt", x); + let mut c = Command::new("rustfmt") + .args(["--edition", "2021", "--config", "newline_style=Unix"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|x| bad(x.to_string()))?; + let mut stdin = c.stdin.take().unwrap(); + let writer = std::thread::spawn(move || stdin.write_all(src.as_bytes())); + let out = c.wait_with_output().map_err(|x| bad(x.to_string()))?; + writer.join().unwrap().map_err(|x| bad(x.to_string()))?; + if !out.status.success() { + return Err(bad(String::from_utf8_lossy(&out.stderr).into_owned())); + } + String::from_utf8(out.stdout).map_err(|x| bad(x.to_string())) +} + +fn type_code(key: &str, r: &Raw, n: &HashMap, boxed: &HashSet<(String, String)>, imports: &mut BTreeSet) -> String { + let nm = &n[key]; + let wrapper = matches!(r.body, Body::Obj(_)) && s(&r.shape, "kind") != "object"; + let mut o = format!("/// `{}` generated from `{}`.\n", r.src, origin(&r.shape["provenance"])); + match &r.body { + Body::Enm(ms) => { + o += &format!("#[derive(Clone, Copy, Debug, PartialEq, Eq)]\n{SERDE}\npub enum {nm} {{\n"); + for (k, v) in ms { + o += &format!("/// Serialized as {v:?}.\n#[cfg_attr(feature = \"serde\", serde(rename = {v:?}))]\n{},\n", n[k]); + } + o += &format!("}}\n\nimpl {nm} {{\npub fn value(&self) -> &'static str {{\nmatch self {{\n"); + ms.iter().for_each(|(k, v)| o += &format!("{nm}::{} => {v:?},\n", n[k])); + o += "}\n}\n}\n"; + } + Body::Arr(x) => o += &format!("{DERIVE}\n{SERDE}\npub struct {nm}(pub Vec<{}>);\n", ann(x, n)), + Body::Map(x) => o += &format!("{DERIVE}\n{SERDE}\npub struct {nm}(pub std::collections::BTreeMap);\n", ann(x, n)), + Body::Uni(vs) => { + o += &format!("{DERIVE}\n{SERDE}\n#[cfg_attr(feature = \"serde\", serde(untagged))]\npub enum {nm} {{\n"); + vs.iter().enumerate().for_each(|(i, x)| o += &format!("Variant{}({}),\n", i + 1, held(x, key, n, boxed))); + o += "}\n"; + } + Body::Obj(fs) => { + let mut fs: Vec<(&Fld, &String)> = fs.iter().map(|f| (f, &n[&f.key])).collect(); + fs.sort_by(|a, b| (!a.0.req, a.1).cmp(&(!b.0.req, b.1))); + o += &format!("{DERIVE}\n{SERDE}\n"); + if wrapper { + o += "#[cfg_attr(feature = \"serde\", serde(transparent))]\n"; + } + o += &format!("pub struct {nm} {{\n"); + for (f, fnm) in &fs { + let ty = held(&f.ty, key, n, boxed); + o += &format!("/// `{}` at `{}`.\n", f.src, origin(&f.prov)); + if !wrapper && fnm.trim_start_matches("r#") != f.src { + o += &format!("#[cfg_attr(feature = \"serde\", serde(rename = {:?}))]\n", f.src); + } + if !f.req { + o += "#[cfg_attr(feature = \"serde\", serde(default, skip_serializing_if = \"Option::is_none\"))]\n"; + } + o += &format!("pub {fnm}: {},\n", if f.req { ty } else { format!("Option<{ty}>") }); + } + o += "}\n"; + if let Some(mk) = &r.mk { + if let Some(c) = &mk.krate { + imports.insert(format!("{}::{}", c.replace('-', "_"), n[&mk.proto])); + } + o += &format!("\nimpl {} for {nm} {{\nfn {}(&self) {{}}\n}}\n", n[&mk.proto], mk.method); + } + } + } + o +} + +pub fn render(m: &Value, t: &Value, p: &Plan) -> R> { + let n = &p.names; + let h = header(m); + let boxed = boxed(&p.raw); + let mut files = BTreeMap::new(); + let mut facade = format!("// {h}\n\n"); + let mut exports = String::new(); + let mut taken: HashMap = HashMap::new(); + let ordered: BTreeMap<&String, (&String, &Raw)> = p.raw.iter().map(|(k, r)| (&n[k], (k, r))).collect(); + for (nm, (key, r)) in ordered { + let ident = module_ident(nm)?; + let file = module_file(nm); + if let Some(other) = taken.insert(file.clone(), nm.clone()) { + return Err(e("RCR2001", s(t, "packageKey"), format!("types '{other}' and '{nm}' share module '{file}'"))); + } + let mut imports = BTreeSet::new(); + let code = type_code(key, r, n, &boxed, &mut imports); + let uses: String = imports.iter().map(|i| format!("use {i};\n")).collect(); + files.insert(format!("src/bindings/{file}.rs"), rustfmt(format!("// {h}\n\n#[allow(unused_imports)]\nuse super::*;\n{uses}\n{code}"))?); + if !ident.is_ascii() { + facade += &format!("#[path = \"bindings/{file}.rs\"]\n"); + } + facade += &format!("mod {ident};\n"); + exports += &format!("pub use {ident}::{nm};\n"); + } + let reexports: BTreeSet = p.imps.iter().map(|i| format!("pub use {}::{};\n", i.krate.replace('-', "_"), n[&i.proto])).collect(); + facade += &format!("\n{exports}{}", reexports.into_iter().collect::()); + if !p.decls.is_empty() { + facade += "\n/// An inert value returned by the declaration functions.\n#[derive(Clone, Debug, PartialEq)]\npub struct Declaration(());\n"; + } + for d in &p.decls { + facade += &format!("\n/// Marker for fields that belong to the `{}` declaration.\npub trait {} {{\nfn {}(&self);\n}}\n", d.kind, n[&d.proto], d.method); + facade += &format!("\n/// Declares a `{}` condition from fields that carry its marker.\npub fn {}(_fields: &[&dyn {}]) -> Declaration {{\nDeclaration(())\n}}\n", d.kind, n[&d.func], n[&d.proto]); + } + files.insert("src/bindings.rs".into(), rustfmt(facade)?); + files.insert("src/lib.rs".into(), rustfmt(format!("// {h}\n\n#![allow(clippy::all)]\n\nmod bindings;\n\npub use bindings::*;\n"))?); + + let mut cargo = format!("# {h}\n\n[package]\nname = \"{}\"\nversion = \"{}\"\nedition = \"2021\"\nrust-version = \"{}\"\nrepository = \"{}\"\n", s(t, "crateName"), s(t, "version"), s(t, "minimumRustVersion"), s(t, "repositoryUrl")); + cargo += "include = [\"src/**\", \"runtimeconditions/**\", \"Cargo.toml\"]\n\n[features]\nserde = [\"dep:serde\"]\n\n[dependencies]\nserde = { version = \"1\", features = [\"derive\"], optional = true }\n"; + if p.any { + cargo += "serde_json = \"1\"\n"; + } + let mut deps: Vec<&Value> = arr(t, "dependencies").iter().collect(); + deps.sort_by_key(|d| s(d, "crateName")); + deps.iter().for_each(|d| cargo += &format!("{} = \"{}\"\n", s(d, "crateName"), interval(s(d, "version")))); + files.insert("Cargo.toml".into(), cargo); + files.insert(MANIFEST.into(), manifest(m, t, p, &h)?); + Ok(files) +} diff --git a/tooling/extension-bindings/emitters/rust/src/verify.rs b/tooling/extension-bindings/emitters/rust/src/verify.rs new file mode 100644 index 0000000..c639909 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/verify.rs @@ -0,0 +1,33 @@ +use std::collections::BTreeMap; +use std::{fs, path::Path}; +use crate::diag::*; + +pub fn api(files: &BTreeMap) -> R> { + use syn::{Item, Visibility}; + use quote::ToTokens; + let mut out = vec![]; + for (file, src) in files.iter().filter(|(f, _)| f.starts_with("src/") && f.ends_with(".rs")) { + for i in syn::parse_file(src).map_err(|x| e("RCR4001", file, x))?.items { + out.push(match &i { + Item::Fn(f) if matches!(f.vis, Visibility::Public(_)) => f.sig.to_token_stream().to_string(), + Item::Impl(x) => format!("impl {} for {}", x.trait_.as_ref().map_or(String::new(), |t| t.1.to_token_stream().to_string()), x.self_ty.to_token_stream()), + Item::Struct(syn::ItemStruct { vis: Visibility::Public(_), .. }) | Item::Enum(syn::ItemEnum { vis: Visibility::Public(_), .. }) | Item::Trait(syn::ItemTrait { vis: Visibility::Public(_), .. }) | Item::Use(_) | Item::Mod(_) => i.to_token_stream().to_string(), + _ => return Err(e("RCR4001", file, "unexpected non-public item")), + }); + } + } + Ok(out) +} + +pub fn read_tree(dir: &Path, base: &Path, out: &mut BTreeMap) -> R<()> { + for ent in fs::read_dir(dir).map_err(|x| e("RCR4001", &dir.display().to_string(), x))? { + let p = ent.map_err(|x| e("RCR4001", "input", x))?.path(); + if p.is_dir() { + read_tree(&p, base, out)?; + } else { + let rel = p.strip_prefix(base).unwrap().to_string_lossy().into_owned(); + out.insert(rel, fs::read_to_string(&p).map_err(|x| e("RCR4001", &p.display().to_string(), x))?); + } + } + Ok(()) +} diff --git a/tooling/extension-bindings/emitters/rust/src/yaml.rs b/tooling/extension-bindings/emitters/rust/src/yaml.rs new file mode 100644 index 0000000..42ba44a --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/src/yaml.rs @@ -0,0 +1,57 @@ +use serde_json::Value; + +pub fn scalar(x: &str) -> String { + let plain = !x.is_empty() + && !x.starts_with(|c: char| "-?:,[]{}#&*!|>'\"%@` ".contains(c)) + && !x.ends_with([' ', ':']) + && !x.contains(": ") + && !x.contains(" #") + && x.chars().all(|c| c.is_alphanumeric() || "_-./:@#+=,()$ <>*{}[]".contains(c)) + && x.parse::().is_err() + && !["true", "false", "null", "yes", "no", "on", "off", "~"].contains(&x.to_lowercase().as_str()); + if plain { x.into() } else { format!("'{}'", x.replace('\'', "''")) } +} + +pub fn inline(v: &Value) -> String { + match v { + Value::String(x) => scalar(x), + Value::Array(_) => "[]".into(), + Value::Object(_) => "{}".into(), + x => x.to_string(), + } +} + +pub fn yaml(v: &Value, ind: usize, first_inline: bool, o: &mut String) { + let pad = " ".repeat(ind); + match v { + Value::Object(m) => { + for (i, (k, x)) in m.iter().enumerate() { + if !(first_inline && i == 0) { + o.push_str(&pad); + } + o.push_str(&format!("{}:", scalar(k))); + match x { + Value::Object(c) if !c.is_empty() => { + o.push('\n'); + yaml(x, ind + 2, false, o); + } + Value::Array(a) if !a.is_empty() => { + o.push('\n'); + yaml(x, ind + 2, false, o); + } + _ => o.push_str(&format!(" {}\n", inline(x))), + } + } + } + Value::Array(a) => { + for x in a { + o.push_str(&format!("{pad}- ")); + match x { + Value::Object(c) if !c.is_empty() => yaml(x, ind + 2, true, o), + _ => o.push_str(&format!("{}\n", inline(x))), + } + } + } + _ => {} + } +} diff --git a/tooling/extension-bindings/emitters/rust/testdata/negative-diagnostics.yaml b/tooling/extension-bindings/emitters/rust/testdata/negative-diagnostics.yaml new file mode 100644 index 0000000..df4c3d3 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/testdata/negative-diagnostics.yaml @@ -0,0 +1,12 @@ +- case: reserved-global + positiveCase: 01-owned-kind-interface + message: "RCR2001 01-owned-kind-interface: symbol 'Option' conflicts: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0#schema:service-http/properties/region" +- case: fixed-function-collision + positiveCase: 01-owned-kind-interface + message: "RCR2001 01-owned-kind-interface: symbol 'ServiceField' conflicts: kind:Service and kind:service" +- case: enum-member-collision + positiveCase: 10-scoped-domains-collisions + message: "RCR2001 10-scoped-domains-collisions: 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 + message: "RCR2000 https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0#schema:service-http /properties/region: invalid Rust identifier \"💥\"" diff --git a/tooling/extension-bindings/emitters/rust/testdata/package-target.yaml b/tooling/extension-bindings/emitters/rust/testdata/package-target.yaml new file mode 100644 index 0000000..0365cbf --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/testdata/package-target.yaml @@ -0,0 +1,11 @@ +apiVersion: runtimeconditions.io/rust-package-target/v1alpha1 +kind: RuntimeConditionsRustPackageTarget +packageKey: conformance-owned-kind-interface +rootExtension: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0 +crateName: runtimeconditions-conformance-owned-kind-interface +version: 1.0.0 +sourceDirectory: bindings/conformance-owned-kind-interface/rust +minimumRustVersion: '1.75' +publicationMode: github-tag +repositoryUrl: https://github.com/runtimeconditions/extensions +emitterSha256: '0000000000000000000000000000000000000000000000000000000000000000' diff --git a/tooling/extension-bindings/emitters/rust/tests/conformance.rs b/tooling/extension-bindings/emitters/rust/tests/conformance.rs new file mode 100644 index 0000000..2eaae32 --- /dev/null +++ b/tooling/extension-bindings/emitters/rust/tests/conformance.rs @@ -0,0 +1,604 @@ +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::{env, fs}; + +fn bin(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_runtimeconditions-binding-emitter-rust")).args(args).output().unwrap() +} + +fn yaml(p: &Path) -> Value { + serde_yaml::from_str(&fs::read_to_string(p).unwrap()).unwrap() +} + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../model") +} + +fn scratch(name: &str) -> PathBuf { + let p = env::temp_dir().join(format!("rc-rust-emitter-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&p); + p +} + +fn target(model: &Value, key: &str, dir: &Path) -> PathBuf { + let id = model["rootExtension"]["id"].as_str().unwrap(); + let ext = model["extensions"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap(); + let mut deps: Vec<&str> = ext["dependencies"].as_array().map_or(vec![], |d| d.iter().filter_map(Value::as_str).collect()); + deps.sort(); + let deps: Vec = deps.iter().enumerate().map(|(i, d)| serde_json::json!({"extension": d, "crateName": format!("rc-dep-{i}"), "version": "1.0.0"})).collect(); + let t = serde_json::json!({ + "apiVersion": "runtimeconditions.io/rust-package-target/v1alpha1", "kind": "RuntimeConditionsRustPackageTarget", + "packageKey": key, "rootExtension": id, "crateName": format!("rc-{key}"), "version": "1.0.0", + "sourceDirectory": format!("bindings/{key}/rust"), "minimumRustVersion": "1.75", "publicationMode": "github-tag", + "repositoryUrl": "https://github.com/runtimeconditions/extensions", "emitterSha256": "0".repeat(64), "dependencies": deps, + }); + let p = dir.join("target.yaml"); + fs::write(&p, serde_yaml::to_string(&t).unwrap()).unwrap(); + p +} + +fn models() -> Vec<(String, PathBuf)> { + let mut v: Vec<_> = fs::read_dir(root().join("conformance/expected")) + .unwrap() + .map(|d| d.unwrap().path()) + .filter(|p| p.join("runtimeconditions.binding-model.yaml").exists()) + .map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), p.join("runtimeconditions.binding-model.yaml"))) + .collect(); + v.sort(); + v +} + +fn val(r: &Value, types: &HashMap<&str, &Value>, stack: &mut Vec) -> Option { + if let Some(b) = r["builtin"].as_str() { + return Some(match b { + "String" => "String::new()", + "bool" => "false", + "i64" => "0", + "f64" => "0.0", + "()" => "()", + _ => "serde_json::Value::Null", + }.into()); + } + ctor(r["type"].as_str().unwrap(), types, stack) +} + +fn named(r: &Value, expr: String) -> String { + if r.get("type").is_some() { format!("{expr}.into()") } else { expr } +} + +fn ctor(name: &str, types: &HashMap<&str, &Value>, stack: &mut Vec) -> Option { + if stack.iter().any(|s| s == name) { + return None; + } + stack.push(name.into()); + let t = types[name]; + let out = match t["construct"].as_str().unwrap() { + "object" | "wrapper" => { + let mut fields = vec![]; + for f in t["fields"].as_array().unwrap() { + let v = val(&f["value"], types, stack).map(|e| named(&f["value"], e)); + let fnm = f["nativeName"].as_str().unwrap(); + fields.push(match (f["required"].as_bool().unwrap(), v) { + (true, Some(e)) => format!("{fnm}: {e}"), + (true, None) => panic!("{name}.{fnm}: required cycle"), + (false, Some(e)) => format!("{fnm}: Some({e})"), + (false, None) => format!("{fnm}: None"), + }); + } + Some(format!("{name} {{ {} }}", fields.join(", "))) + } + "scalar" => Some(format!("{name}::{}", t["members"][0]["nativeName"].as_str().unwrap())), + "collection" => Some(format!("{name}(vec![{}])", val(&t["element"]["value"], types, stack).unwrap_or_default())), + "map" => Some(match val(&t["element"]["value"], types, stack) { + Some(e) => format!("{name}([(\"k\".to_string(), {e})].into_iter().collect())"), + None => format!("{name}(Default::default())"), + }), + _ => (0..t["variants"].as_array().unwrap().len()).find_map(|i| variant(name, t, i, types, stack)), + }; + stack.pop(); + out +} + +fn variant(name: &str, t: &Value, i: usize, types: &HashMap<&str, &Value>, stack: &mut Vec) -> Option { + let v = &t["variants"][i]["value"]; + val(v, types, stack).map(|e| format!("{name}::Variant{}({})", i + 1, named(v, e))) +} + +fn exercise(manifest: &Value, krate: &str, round_trip: bool) -> String { + let all = manifest["types"].as_array().unwrap(); + let types: HashMap<&str, &Value> = all.iter().map(|t| (t["nativeName"].as_str().unwrap(), t)).collect(); + let mut body = String::new(); + for t in all { + let name = t["nativeName"].as_str().unwrap(); + let built = ctor(name, &types, &mut vec![]).unwrap_or_else(|| panic!("{name}: cannot be constructed")); + body += &format!(" let _ = {built};\n"); + if round_trip { + body += &format!(" {{ let v = {built}; let j = serde_json::to_string(&v).unwrap(); let back: {name} = serde_json::from_str(&j).unwrap(); assert_eq!(v, back, \"{name}\"); }}\n"); + } + for m in t["members"].as_array().into_iter().flatten() { + body += &format!(" assert_eq!({name}::{}.value(), {:?});\n", m["nativeName"].as_str().unwrap(), m["value"].as_str().unwrap()); + } + for i in 0..t["variants"].as_array().map_or(0, Vec::len) { + if let Some(v) = variant(name, t, i, &types, &mut vec![name.to_string()]) { + body += &format!(" let _ = {v};\n"); + } + } + } + for d in manifest["declarations"].as_array().unwrap() { + let objs: Vec = all + .iter() + .filter(|t| t["implements"].as_array().is_some_and(|i| i.iter().any(|x| x["markerMethod"] == d["markerMethod"]))) + .map(|t| format!("&{}", ctor(t["nativeName"].as_str().unwrap(), &types, &mut vec![]).unwrap())) + .collect(); + body += &format!(" let _ = {}(&[{}]);\n", d["function"].as_str().unwrap(), objs.join(", ")); + } + format!("#![allow(dead_code, unused)]\nuse {krate}::*;\nfn main() {{\n{body}}}\n") +} + +fn rustc(args: &[&str]) -> Output { + Command::new("rustc").args(["--edition", "2021"]).args(args).output().unwrap() +} + +fn compile_and_run(key: &str, dir: &Path, out: &Path, manifest: &Value, extra: &[String]) { + let mut stubs: BTreeMap = BTreeMap::new(); + for c in manifest["importedMarkerContracts"].as_array().unwrap() { + let krate = c["providerPackage"].as_str().unwrap().replace('-', "_"); + *stubs.entry(krate).or_default() += &format!("pub trait {} {{ fn {}(&self); }}\n", c["markerInterface"].as_str().unwrap(), c["markerMethod"].as_str().unwrap()); + } + let build = |suffix: &str| -> Output { + let lib = dir.join(format!("stub{suffix}")); + fs::create_dir_all(&lib).unwrap(); + let mut ext = vec![]; + for (k, src) in &stubs { + let (f, o) = (lib.join(format!("{k}.rs")), lib.join(format!("lib{k}.rlib"))); + fs::write(&f, if suffix.is_empty() { src.clone() } else { src.replace("(&self)", "_x(&self)") }).unwrap(); + let r = rustc(&["--crate-type", "rlib", "--crate-name", k, f.to_str().unwrap(), "-o", o.to_str().unwrap()]); + assert!(r.status.success(), "{key}: stub {k}: {}", String::from_utf8_lossy(&r.stderr)); + ext.push(format!("{k}={}", o.display())); + } + let package = lib.join("libgenpkg.rlib"); + let mut lib_args: Vec = ["--crate-type", "rlib", "--crate-name", "genpkg", "-D", "warnings"].map(String::from).to_vec(); + lib_args.extend([out.join("src/lib.rs").to_str().unwrap().into(), "-o".into(), package.to_str().unwrap().into()]); + lib_args.extend(extra.iter().cloned()); + ext.iter().for_each(|e| lib_args.extend(["--extern".into(), e.clone()])); + let built = rustc(&lib_args.iter().map(String::as_str).collect::>()); + if !built.status.success() { + return built; + } + let main = lib.join("main.rs"); + fs::write(&main, exercise(manifest, "genpkg", false)).unwrap(); + let exe = lib.join("exercise"); + let mut args = vec![main.to_str().unwrap().to_string(), "-o".into(), exe.to_str().unwrap().into()]; + args.extend(extra.iter().cloned()); + ext.iter().for_each(|e| args.extend(["--extern".into(), e.clone()])); + args.extend(["--extern".into(), format!("genpkg={}", package.display()), "-L".into(), format!("dependency={}", lib.display())]); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + rustc(&args) + }; + let good = build(""); + assert!(good.status.success(), "{key}: {}", String::from_utf8_lossy(&good.stderr)); + let run = Command::new(dir.join("stub/exercise")).output().unwrap(); + assert!(run.status.success(), "{key}: exercise failed: {}", String::from_utf8_lossy(&run.stderr)); + if !stubs.is_empty() { + assert!(!build("-mismatch").status.success(), "{key}: a provider stub with a different marker method must fail to compile"); + } +} + +fn cargo(args: &[&str], manifest: &Path, target: &Path) -> Output { + Command::new("cargo").args(args).args(["--offline", "--manifest-path", manifest.to_str().unwrap()]).env("CARGO_TARGET_DIR", target).output().unwrap() +} + +fn cargo_check(key: &str, dir: &Path, emit_args: &[&str], bindings_src: &str) -> Vec { + let crate_dir = dir.join("cargo"); + let mut args = emit_args.to_vec(); + let out = crate_dir.to_str().unwrap(); + args.extend(["--output", out]); + let r = bin(&args); + assert!(r.status.success(), "{key}: {}", String::from_utf8_lossy(&r.stderr)); + let manifest = Path::new(out).join("Cargo.toml"); + let text = fs::read_to_string(&manifest).unwrap(); + let imports = yaml(&crate_dir.join("runtimeconditions/runtimeconditions.bindings.yaml"))["importedMarkerContracts"].as_array().unwrap().clone(); + let mut rewritten = String::new(); + for line in text.lines() { + match line.strip_prefix("rc-dep-").and_then(|l| l.split_once(" = ")) { + Some((n, req)) => { + let name = format!("rc-dep-{n}"); + let stub = dir.join("cargo-stubs").join(&name); + fs::create_dir_all(stub.join("src")).unwrap(); + fs::write(stub.join("Cargo.toml"), format!("[package]\nname = \"{name}\"\nversion = \"1.0.0\"\nedition = \"2021\"\n")).unwrap(); + let traits: String = imports + .iter() + .filter(|c| c["providerPackage"] == name.as_str()) + .map(|c| format!("pub trait {} {{ fn {}(&self); }}\n", c["markerInterface"].as_str().unwrap(), c["markerMethod"].as_str().unwrap())) + .collect(); + fs::write(stub.join("src/lib.rs"), traits).unwrap(); + rewritten += &format!("{name} = {{ version = {req}, path = {:?} }}\n", stub); + } + None => rewritten += &format!("{line}\n"), + } + } + fs::write(&manifest, rewritten).unwrap(); + let target = env::temp_dir().join(format!("rc-rust-emitter-target-{}", std::process::id())); + let uses_json = bindings_src.contains("serde_json"); + let r = cargo(&[if uses_json { "build" } else { "check" }], &manifest, &target); + let with_serde = cargo(&["check", "--features", "serde"], &manifest, &target); + assert!(with_serde.status.success(), "{key}: cargo check --features serde failed: {}", String::from_utf8_lossy(&with_serde.stderr)); + assert!(r.status.success(), "{key}: cargo failed: {}", String::from_utf8_lossy(&r.stderr)); + if !uses_json { + return vec![]; + } + let deps = target.join("debug/deps"); + let rlib = fs::read_dir(&deps).unwrap().map(|d| d.unwrap().path()).find(|p| p.file_name().unwrap().to_string_lossy().starts_with("libserde_json-") && p.extension().is_some_and(|e| e == "rlib")).unwrap(); + vec!["-L".into(), format!("dependency={}", deps.display()), "--extern".into(), format!("serde_json={}", rlib.display())] +} + +fn check_model(key: &str, model: &Path, validator: &jsonschema::Validator) -> String { + let dir = scratch(key); + fs::create_dir_all(&dir).unwrap(); + let t = target(&yaml(model), key, &dir); + let (m, t) = (model.to_str().unwrap(), t.to_str().unwrap()); + let outs: Vec = (0..3).map(|i| dir.join(format!("out{i}"))).collect(); + for o in &outs { + let r = bin(&["emit", "--model", m, "--package-config", t, "--output", o.to_str().unwrap()]); + assert!(r.status.success(), "{key}: {}", String::from_utf8_lossy(&r.stderr)); + } + let read = |o: &Path, f: &str| fs::read_to_string(o.join(f)).unwrap(); + let tree = |o: &Path| -> BTreeMap { + fn walk(d: &Path, base: &Path, out: &mut BTreeMap) { + for e in fs::read_dir(d).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + walk(&p, base, out); + } else { + out.insert(p.strip_prefix(base).unwrap().to_string_lossy().into_owned(), fs::read_to_string(&p).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(o, o, &mut out); + out + }; + assert_eq!(tree(&outs[0]), tree(&outs[1]), "{key}"); + assert_eq!(tree(&outs[0]), tree(&outs[2]), "{key}"); + let o0 = outs[0].to_str().unwrap(); + for f in tree(&outs[0]).keys().filter(|f| f.ends_with(".rs")) { + let r = Command::new("rustfmt").args(["--check", "--edition", "2021", outs[0].join(f).to_str().unwrap()]).output().unwrap(); + assert!(r.status.success(), "{key}: {f} is not rustfmt-stable: {}", String::from_utf8_lossy(&r.stdout)); + } + let ok = bin(&["verify", "--model", m, "--package-config", t, "--input", o0]); + assert!(ok.status.success(), "{key}: {}", String::from_utf8_lossy(&ok.stderr)); + assert!(bin(&["api", "--input", o0]).status.success()); + let assembled = outs[2].join("runtimeconditions/runtimeconditions.binding-model.yaml"); + fs::write(&assembled, "assembled\n").unwrap(); + fs::write(outs[2].join("runtimeconditions.file-manifest.yaml"), "assembled\n").unwrap(); + let after = bin(&["verify", "--model", m, "--package-config", t, "--input", outs[2].to_str().unwrap()]); + assert!(after.status.success(), "{key}: assembly files must not fail verify: {}", String::from_utf8_lossy(&after.stderr)); + let manifest = yaml(&outs[0].join("runtimeconditions/runtimeconditions.bindings.yaml")); + let errs: Vec = validator.iter_errors(&manifest).map(|x| format!("{}: {x}", x.instance_path)).collect(); + assert!(errs.is_empty(), "{key}: {errs:?}"); + + let src: String = tree(&outs[0]).iter().filter(|(f, _)| f.starts_with("src/")).map(|(_, c)| c.as_str()).collect(); + let extra = cargo_check(key, &dir, &["emit", "--model", m, "--package-config", t], &src); + if !src.contains("serde_json") || !extra.is_empty() { + compile_and_run(key, &dir, &outs[0], &manifest, &extra); + } + + fs::write(outs[1].join("src/bindings.rs"), read(&outs[1], "src/bindings.rs") + "pub fn extra() {}\n").unwrap(); + let bad = bin(&["verify", "--model", m, "--package-config", t, "--input", outs[1].to_str().unwrap()]); + assert!(!bad.status.success(), "{key}: tampered output accepted"); + let again = bin(&["emit", "--model", m, "--package-config", t, "--output", o0]); + assert!(String::from_utf8_lossy(&again.stderr).contains("RCR3001")); + if !read(&outs[0], "Cargo.toml").contains("rc-dep-") { + serde_round_trip(key, &dir, &outs[0], &manifest); + } + let _ = fs::remove_dir_all(&dir); + src +} + +fn serde_round_trip(key: &str, dir: &Path, package: &Path, manifest: &Value) { + let project = dir.join("serde-exercise"); + fs::create_dir_all(project.join("src")).unwrap(); + let cargo_toml = format!("[package]\nname = \"serde-exercise\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ngenpkg = {{ package = \"rc-{key}\", path = {:?}, features = [\"serde\"] }}\nserde_json = \"1\"\n", package); + fs::write(project.join("Cargo.toml"), cargo_toml).unwrap(); + fs::write(project.join("src/main.rs"), exercise(manifest, "genpkg", true)).unwrap(); + let target = env::temp_dir().join(format!("rc-rust-emitter-target-{}", std::process::id())); + let r = cargo(&["run"], &project.join("Cargo.toml"), &target); + assert!(r.status.success(), "{key}: serde round trip failed: {}", String::from_utf8_lossy(&r.stderr)); +} + +#[test] +fn conformance_models_emit_verify_compile_and_run() { + let validator = jsonschema::validator_for(&yaml(&root().join("runtimeconditions.binding-manifest.schema.yaml"))).unwrap(); + for (key, model) in models() { + check_model(&key, &model, &validator); + } +} + +#[test] +fn json_value_fields_compile_and_run() { + let validator = jsonschema::validator_for(&yaml(&root().join("runtimeconditions.binding-manifest.schema.yaml"))).unwrap(); + let mut model = yaml(&root().join("conformance/expected/01-owned-kind-interface/runtimeconditions.binding-model.yaml")); + for root in ["schemas", "scopes"] { + let props = model[root][0]["projection"]["properties"].as_array_mut().unwrap(); + let shape = &mut props.iter_mut().find(|p| p["name"] == "region").unwrap()["shape"]; + *shape = serde_json::json!({"kind": "any", "provenance": shape["provenance"].clone()}); + } + let dir = scratch("any-model"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("model.yaml"); + fs::write(&path, serde_yaml::to_string(&model).unwrap()).unwrap(); + let src = check_model("json-value", &path, &validator); + assert!(src.contains("serde_json::Value")); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn committed_first_party_models_emit_packages_that_compile() { + let validator = jsonschema::validator_for(&yaml(&root().join("runtimeconditions.binding-manifest.schema.yaml"))).unwrap(); + let bindings = root().join("../../../bindings"); + let Ok(dirs) = fs::read_dir(&bindings) else { return }; + for d in dirs.map(|d| d.unwrap().path()) { + let model = d.join("go/runtimeconditions.binding-model.yaml"); + if model.exists() { + check_model(d.file_name().unwrap().to_str().unwrap(), &model, &validator); + } + } +} + +#[test] +fn negative_diagnostics_match_the_fixtures() { + let schema = yaml(&root().join("runtimeconditions.binding-model.schema.yaml")); + let validator = jsonschema::validator_for(&schema).unwrap(); + let fixtures = yaml(&Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/negative-diagnostics.yaml")); + assert!(fixtures.as_array().unwrap().len() >= 4); + for f in fixtures.as_array().unwrap() { + let case = f["case"].as_str().unwrap(); + let mut model = yaml(&root().join(format!("conformance/expected/{}/runtimeconditions.binding-model.yaml", f["positiveCase"].as_str().unwrap()))); + match case { + "reserved-global" => model["schemas"][0]["projection"]["properties"][1]["name"] = "option".into(), + "unnameable-field" => model["schemas"][0]["projection"]["properties"][1]["name"] = "\u{1f4a5}".into(), + "fixed-function-collision" => { + let mut second = model["vocabulary"]["ownedDeclarations"][0].clone(); + second["coordinate"] = "kind:Service".into(); + second["kind"] = "Service".into(); + model["vocabulary"]["ownedDeclarations"].as_array_mut().unwrap().push(second); + } + "enum-member-collision" => { + let at = |m: &Value, root: &str| m[root][0]["projection"]["properties"].as_array().unwrap().iter().position(|x| x["name"] == "mode").unwrap(); + let (i, j) = (at(&model, "schemas"), at(&model, "scopes")); + let shape = &mut model["schemas"][0]["projection"]["properties"][i]["shape"]; + let first = shape["values"][0]["value"].as_str().unwrap().to_uppercase(); + shape["values"].as_array_mut().unwrap().push(serde_json::json!({"value": first})); + let copy = shape.clone(); + model["scopes"][0]["projection"]["properties"][j]["shape"] = copy; + } + _ => panic!("{case}"), + } + let errs: Vec = validator.iter_errors(&model).map(|x| x.to_string()).collect(); + assert!(errs.is_empty(), "{case}: the mutated model must stay schema-valid: {errs:?}"); + let dir = scratch(case); + fs::create_dir_all(&dir).unwrap(); + let t = target(&model, f["positiveCase"].as_str().unwrap(), &dir); + let mp = dir.join("model.yaml"); + fs::write(&mp, serde_yaml::to_string(&model).unwrap()).unwrap(); + let r = bin(&["emit", "--model", mp.to_str().unwrap(), "--package-config", t.to_str().unwrap(), "--output", dir.join("out").to_str().unwrap()]); + assert!(!r.status.success(), "{case}: accepted"); + assert_eq!(String::from_utf8_lossy(&r.stderr).trim_end(), f["message"].as_str().unwrap(), "{case}"); + let _ = fs::remove_dir_all(&dir); + } +} + +#[test] +fn invalid_inputs_are_rejected_with_codes() { + let (key, model) = models().remove(0); + let dir = scratch("negative"); + fs::create_dir_all(&dir).unwrap(); + let good = fs::read_to_string(target(&yaml(&model), &key, &dir)).unwrap(); + let text = fs::read_to_string(&model).unwrap(); + let cases: Vec<(&str, String, String, &str)> = vec![ + ("bom", format!("\u{feff}{text}"), good.clone(), "RCR1002"), + ("crlf", text.replace('\n', "\r\n"), good.clone(), "RCR1002"), + ("comment", format!("# note\n{text}"), good.clone(), "RCR1002"), + ("version", text.clone(), good.replace("version: 1.0.0", "version: 1.0.0-snapshot"), "RCR1006"), + ("sourcedir", text.clone(), good.replace("/rust\n", "/rs\n"), "RCR1007"), + ("msrv", text.clone(), good.replace("'1.75'", "'1.60'"), "RCR1008"), + ("root", text.clone(), good.replace("rootExtension: ", "rootExtension: x"), "RCR1009"), + ("schema", text.replace("semanticSha256:", "semanticShaX:"), good.clone(), "RCR1003"), + ("apiversion", text.replace("binding-model/v1alpha1", "binding-model/v9"), good.clone(), "RCR1004"), + ]; + for (n, model_text, target_text, code) in cases { + let (mp, tp) = (dir.join(format!("{n}.model.yaml")), dir.join(format!("{n}.target.yaml"))); + fs::write(&mp, model_text).unwrap(); + fs::write(&tp, target_text).unwrap(); + let r = bin(&["emit", "--model", mp.to_str().unwrap(), "--package-config", tp.to_str().unwrap(), "--output", dir.join(format!("{n}.out")).to_str().unwrap()]); + assert!(!r.status.success(), "{n}: accepted"); + assert!(String::from_utf8_lossy(&r.stderr).contains(code), "{n}: {}", String::from_utf8_lossy(&r.stderr)); + } + let _ = fs::remove_dir_all(&dir); +} + +fn rename(model: &mut Value, from: &str, to: &str) { + match model { + Value::String(s) if s == from => *s = to.to_string(), + Value::Array(a) => a.iter_mut().for_each(|x| rename(x, from, to)), + Value::Object(m) => m.iter_mut().filter(|(k, _)| !matches!(k.as_str(), "exact" | "provenance" | "vocabulary")).for_each(|(_, x)| rename(x, from, to)), + _ => {} + } +} + +fn emit_renamed(from: &str, to: &str) -> (String, String) { + let validator = jsonschema::validator_for(&yaml(&root().join("runtimeconditions.binding-manifest.schema.yaml"))).unwrap(); + let mut model = yaml(&root().join("conformance/expected/01-owned-kind-interface/runtimeconditions.binding-model.yaml")); + rename(&mut model["schemas"], from, to); + rename(&mut model["scopes"], from, to); + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = scratch(&format!("rename-{id}")); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("model.yaml"); + fs::write(&path, serde_yaml::to_string(&model).unwrap()).unwrap(); + let src = check_model(&format!("renamed-{id}"), &path, &validator); + let _ = fs::remove_dir_all(&dir); + (src, to.to_string()) +} + +#[test] +fn keyword_names_become_raw_identifiers_that_compile_and_run() { + let (src, _) = emit_renamed("endpoint", "type"); + assert!(src.contains("pub r#type: String"), "{src}"); + let (src, _) = emit_renamed("endpoint", "self"); + assert!(src.contains("pub self_: String"), "{src}"); +} + +#[test] +fn compatibility_forms_are_normalized_before_naming() { + let (src, _) = emit_renamed("endpoint", "\u{ff35}\u{ff32}\u{ff2c}"); + assert!(src.contains("pub url: String"), "{src}"); +} + +#[test] +fn diagnostics_are_structured_with_json_pointers() { + let dir = scratch("diagnostics"); + fs::create_dir_all(&dir).unwrap(); + let (key, model) = models().remove(0); + let good = fs::read_to_string(target(&yaml(&model), &key, &dir)).unwrap(); + let tp = dir.join("bad.yaml"); + fs::write(&tp, good.replace("version: 1.0.0", "version: nope")).unwrap(); + let out = dir.join("out"); + let args = ["emit", "--model", model.to_str().unwrap(), "--package-config", tp.to_str().unwrap(), "--output", out.to_str().unwrap()]; + let plain = bin(&args); + assert!(String::from_utf8_lossy(&plain.stderr).starts_with(&format!("RCR1006 {key} /version: "))); + let mut json_args = vec!["--json"]; + json_args.extend(args); + let r = bin(&json_args); + assert!(!r.status.success()); + let d: Value = serde_json::from_slice(&r.stderr).unwrap(); + assert_eq!(d["error"]["code"], "RCR1006"); + assert_eq!(d["error"]["category"], "package-config"); + assert_eq!(d["error"]["coordinate"], key.as_str()); + assert_eq!(d["error"]["jsonPointer"], "/version"); + + let mut bad = yaml(&model); + bad["metadata"]["semanticSha256"] = "short".into(); + let mp = dir.join("bad-model.yaml"); + fs::write(&mp, serde_yaml::to_string(&bad).unwrap()).unwrap(); + let (tp2, o2) = (target(&yaml(&model), &key, &dir), dir.join("o2")); + let r = bin(&["--json", "emit", "--model", mp.to_str().unwrap(), "--package-config", tp2.to_str().unwrap(), "--output", o2.to_str().unwrap()]); + let d: Value = serde_json::from_slice(&r.stderr).unwrap(); + assert_eq!(d["error"]["code"], "RCR1003"); + assert_eq!(d["error"]["category"], "model"); + assert_eq!(d["error"]["jsonPointer"], "/metadata/semanticSha256"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn scalar_and_chained_definitions_resolve_to_their_types() { + let validator = jsonschema::validator_for(&yaml(&root().join("runtimeconditions.binding-manifest.schema.yaml"))).unwrap(); + let mut model = yaml(&root().join("conformance/expected/01-owned-kind-interface/runtimeconditions.binding-model.yaml")); + let coordinate = model["schemas"][0]["coordinate"].as_str().unwrap().to_string(); + let base = model["schemas"][0]["projection"]["properties"][1]["shape"]["provenance"].clone(); + let at = |pointer: &str| { + let mut p = base.clone(); + p["jsonPointer"] = pointer.into(); + p + }; + let definition = |name: &str, shape: Value| serde_json::json!({"name": name, "jsonPointer": format!("/$defs/{name}"), "shape": shape, "provenance": at(&format!("/$defs/{name}"))}); + let reference = |name: &str| serde_json::json!({"kind": "ref", "ref": format!("#/$defs/{name}"), "provenance": {"owner": base["owner"], "extensionSha256": base["extensionSha256"], "coordinate": coordinate, "jsonPointer": "/properties/region"}}); + model["schemas"][0]["definitions"] = serde_json::json!([ + definition("label", serde_json::json!({"kind": "scalar", "scalar": "string", "provenance": at("/$defs/label")})), + definition("alias", reference("label")), + definition("blob", serde_json::json!({"kind": "any", "provenance": at("/$defs/blob")})), + ]); + for root in ["schemas", "scopes"] { + let props = model[root][0]["projection"]["properties"].as_array_mut().unwrap(); + props.iter_mut().find(|p| p["name"] == "region").unwrap()["shape"] = reference("alias"); + } + model["schemas"][0]["projection"]["properties"].as_array_mut().unwrap().iter_mut().find(|p| p["name"] == "interface").unwrap()["shape"]["properties"][0]["shape"] = reference("blob"); + let dir = scratch("scalar-definitions-model"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("model.yaml"); + fs::write(&path, serde_yaml::to_string(&model).unwrap()).unwrap(); + let src = check_model("scalar-definitions", &path, &validator); + assert!(src.contains("pub value: String"), "{src}"); + assert!(src.contains("serde_json::Value"), "{src}"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn oversized_inputs_are_rejected_before_they_are_read_into_memory() { + let dir = scratch("oversized"); + fs::create_dir_all(&dir).unwrap(); + let (key, model) = models().remove(0); + let config = target(&yaml(&model), &key, &dir); + let big = dir.join("big.yaml"); + fs::File::create(&big).unwrap().set_len((64 << 20) + 1).unwrap(); + let out = dir.join("out"); + let r = bin(&["emit", "--model", big.to_str().unwrap(), "--package-config", config.to_str().unwrap(), "--output", out.to_str().unwrap()]); + assert!(!r.status.success()); + let stderr = String::from_utf8_lossy(&r.stderr); + assert!(stderr.starts_with("RCR1001 "), "{stderr}"); + assert!(stderr.contains("exceeds the size limit"), "{stderr}"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn transitive_markers_compile_through_the_middle_crate() { + let model = yaml(&root().join("conformance/expected/03-transitive-closure/runtimeconditions.binding-model.yaml")); + let root_id = model["rootExtension"]["id"].as_str().unwrap().to_string(); + let leaf_id = model["vocabulary"]["importedDeclarations"][0]["owner"].as_str().unwrap().to_string(); + let extensions = model["extensions"].as_array().unwrap(); + let extension = |id: &str| extensions.iter().find(|x| x["id"] == id).unwrap().clone(); + let middle_id = extensions.iter().find(|x| x["dependencies"].as_array().is_some_and(|d| d.iter().any(|y| *y == leaf_id.as_str()))).unwrap()["id"].as_str().unwrap().to_string(); + let identity = |id: &str| { + let e = extension(id); + serde_json::json!({"id": e["id"], "version": e["version"], "semanticSha256": e["semanticSha256"]}) + }; + + let mut leaf = model.clone(); + leaf["rootExtension"] = identity(&leaf_id); + leaf["extensions"] = serde_json::json!([extension(&leaf_id)]); + leaf["vocabulary"]["ownedDeclarations"] = model["vocabulary"]["importedDeclarations"].clone(); + leaf["vocabulary"]["importedDeclarations"] = serde_json::json!([]); + let mut middle = model.clone(); + middle["rootExtension"] = identity(&middle_id); + middle["extensions"] = serde_json::json!([extension(&leaf_id), extension(&middle_id)]); + middle["vocabulary"]["ownedDeclarations"] = serde_json::json!([]); + + let dir = scratch("transitive-chain"); + fs::create_dir_all(&dir).unwrap(); + let packages = [("leaf", &leaf, &leaf_id, None), ("middle", &middle, &middle_id, Some(("leaf", &leaf_id))), ("root", &model, &root_id, Some(("middle", &middle_id)))]; + for (name, m, id, dependency) in packages { + let dependencies: Vec = dependency.iter().map(|(crate_name, extension)| serde_json::json!({"extension": extension, "crateName": format!("rc-chain-{crate_name}"), "version": "1.0.0"})).collect(); + let config = serde_json::json!({ + "apiVersion": "runtimeconditions.io/rust-package-target/v1alpha1", "kind": "RuntimeConditionsRustPackageTarget", + "packageKey": format!("chain-{name}"), "rootExtension": id, "crateName": format!("rc-chain-{name}"), "version": "1.0.0", + "sourceDirectory": format!("bindings/chain-{name}/rust"), "minimumRustVersion": "1.75", "publicationMode": "github-tag", + "repositoryUrl": "https://github.com/runtimeconditions/extensions", "emitterSha256": "0".repeat(64), "dependencies": dependencies, + }); + let (model_path, config_path, out) = (dir.join(format!("{name}.model.yaml")), dir.join(format!("{name}.target.yaml")), dir.join(name)); + fs::write(&model_path, serde_yaml::to_string(m).unwrap()).unwrap(); + fs::write(&config_path, serde_yaml::to_string(&config).unwrap()).unwrap(); + let r = bin(&["emit", "--model", model_path.to_str().unwrap(), "--package-config", config_path.to_str().unwrap(), "--output", out.to_str().unwrap()]); + assert!(r.status.success(), "{name}: {}", String::from_utf8_lossy(&r.stderr)); + let manifest = out.join("Cargo.toml"); + let text = fs::read_to_string(&manifest).unwrap(); + let rewritten: String = text + .lines() + .map(|line| match line.strip_prefix("rc-chain-").and_then(|l| l.split_once(" = ")) { + Some((dep, req)) => format!("rc-chain-{dep} = {{ version = {req}, path = {:?} }}\n", dir.join(dep)), + None => format!("{line}\n"), + }) + .collect(); + fs::write(&manifest, rewritten).unwrap(); + } + let middle_facade = fs::read_to_string(dir.join("middle/src/bindings.rs")).unwrap(); + assert!(middle_facade.contains("pub use rc_chain_leaf::WorkerField;"), "{middle_facade}"); + let target = env::temp_dir().join(format!("rc-rust-emitter-target-{}", std::process::id())); + for name in ["leaf", "middle", "root"] { + let r = cargo(&["check"], &dir.join(name).join("Cargo.toml"), &target); + assert!(r.status.success(), "{name}: {}", String::from_utf8_lossy(&r.stderr)); + } + let _ = fs::remove_dir_all(&dir); +} diff --git a/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml b/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml index e4ac3c0..9d97045 100644 --- a/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml +++ b/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml @@ -58,20 +58,23 @@ 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. + the npm package name and version is the installed package version. For + Rust, coordinate is the crate name, name is the crate's library + identifier, and version is the crate 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, rust]} 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]+$'} + minimumRustVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} allOf: - if: properties: @@ -84,6 +87,7 @@ properties: anyOf: - required: [minimumPythonVersion] - required: [minimumNodeVersion] + - required: [minimumRustVersion] - if: properties: language: {const: python} @@ -95,6 +99,7 @@ properties: anyOf: - required: [minimumGoVersion] - required: [minimumNodeVersion] + - required: [minimumRustVersion] - if: properties: language: {const: typescript} @@ -106,6 +111,19 @@ properties: anyOf: - required: [minimumGoVersion] - required: [minimumPythonVersion] + - required: [minimumRustVersion] + - if: + properties: + language: {const: rust} + then: + required: [minimumRustVersion] + properties: + name: {pattern: '^[a-z][a-z0-9_]*$'} + not: + anyOf: + - required: [minimumGoVersion] + - required: [minimumPythonVersion] + - required: [minimumNodeVersion] declarations: type: array items: {$ref: '#/$defs/declaration'} @@ -170,6 +188,24 @@ allOf: items: properties: file: {const: bindings.ts} + - if: + properties: + package: + properties: + language: {const: rust} + then: + properties: + declarations: + items: + properties: + file: {const: bindings.rs} + importedMarkerContracts: + items: + required: [providerPackage] + types: + items: + properties: + file: {pattern: '^bindings/[a-z_][a-z0-9_]*\.rs$'} $defs: extensionIdentifier: type: string @@ -217,7 +253,7 @@ $defs: 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, i64, f64, '()', serde_json::Value]} pointer: {const: true} nullable: {const: true} not: @@ -236,7 +272,7 @@ $defs: function: {$ref: '#/$defs/nativeIdentifier'} markerInterface: {$ref: '#/$defs/nativeIdentifier'} markerMethod: {$ref: '#/$defs/nativeIdentifier'} - file: {enum: [bindings.go, bindings.py, bindings.ts]} + file: {type: string, minLength: 1} importedMarkerContract: type: object additionalProperties: false @@ -351,7 +387,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/model/runtimeconditions.binding-release.schema.yaml b/tooling/extension-bindings/model/runtimeconditions.binding-release.schema.yaml index 8ab4fa8..3aafd35 100644 --- a/tooling/extension-bindings/model/runtimeconditions.binding-release.schema.yaml +++ b/tooling/extension-bindings/model/runtimeconditions.binding-release.schema.yaml @@ -56,11 +56,17 @@ allOf: properties: kind: {const: go-module-zip} provenance: + required: [profiler] properties: profiler: properties: name: {const: go-rc-profiler} - else: + - if: + properties: + package: + properties: + language: {const: python} + then: properties: packageDependencies: items: @@ -69,10 +75,27 @@ allOf: properties: kind: {const: python-wheel} provenance: + required: [profiler] properties: profiler: properties: name: {const: python-rc-profiler} + - if: + properties: + package: + properties: + language: {const: rust} + then: + properties: + packageDependencies: + items: + properties: + artifact: + properties: + kind: {const: rust-source-zip} + provenance: + not: + required: [profiler] - if: properties: provenance: @@ -121,7 +144,7 @@ $defs: packageKey: type: string pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' - language: {enum: [go, python]} + language: {enum: [go, python, rust]} coordinate: {$ref: '#/$defs/nonEmptyString'} name: type: string @@ -133,6 +156,9 @@ $defs: minimumPythonVersion: type: string pattern: '^[0-9]+\.[0-9]+$' + minimumRustVersion: + type: string + pattern: '^[0-9]+\.[0-9]+$' publicationMode: {enum: [github-tag, registry]} registryId: {$ref: '#/$defs/nonEmptyString'} allOf: @@ -142,11 +168,27 @@ $defs: then: required: [minimumGoVersion] not: - required: [minimumPythonVersion] - else: + anyOf: + - required: [minimumPythonVersion] + - required: [minimumRustVersion] + - if: + properties: + language: {const: python} + then: required: [minimumPythonVersion] not: - required: [minimumGoVersion] + anyOf: + - required: [minimumGoVersion] + - required: [minimumRustVersion] + - if: + properties: + language: {const: rust} + then: + required: [minimumRustVersion] + not: + anyOf: + - required: [minimumGoVersion] + - required: [minimumPythonVersion] - if: properties: publicationMode: {const: registry} @@ -244,7 +286,7 @@ $defs: additionalProperties: false required: [kind, sha256] properties: - kind: {enum: [go-module-zip, python-wheel]} + kind: {enum: [go-module-zip, python-wheel, rust-source-zip]} sha256: {$ref: '#/$defs/sha256'} fixtureProvenance: description: >- @@ -264,14 +306,14 @@ $defs: must enforce these cross-field equalities. type: object additionalProperties: false - required: [mode, orchestrator, profiler, sourceDirectory, targetReleaseTag] + required: [mode, orchestrator, sourceDirectory, targetReleaseTag] properties: mode: {const: production} orchestrator: {$ref: '#/$defs/toolIdentity'} profiler: {$ref: '#/$defs/toolIdentity'} sourceDirectory: type: string - pattern: '^bindings/[a-z0-9]+(?:-[a-z0-9]+)*/(go|python)$' + pattern: '^bindings/[a-z0-9]+(?:-[a-z0-9]+)*/(go|python|rust)$' targetReleaseTag: type: string - pattern: '^bindings/[a-z0-9]+(?:-[a-z0-9]+)*/(go|python)/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + pattern: '^bindings/[a-z0-9]+(?:-[a-z0-9]+)*/(go|python|rust)/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' diff --git a/tooling/extension-bindings/orchestrator/archive.go b/tooling/extension-bindings/orchestrator/archive.go index b7603e1..77ab9d3 100644 --- a/tooling/extension-bindings/orchestrator/archive.go +++ b/tooling/extension-bindings/orchestrator/archive.go @@ -314,6 +314,25 @@ func (p *Pipeline) nativeArchives(bt *BuiltTarget) (map[string]string, error) { } return map[string]string{"go-module-zip": path}, nil } + if bt.Target.Language == "rust" { + files, err := readTree(bt.Directory) + if err != nil { + return nil, err + } + prefix := bt.Target.Config.Coordinate + "-" + bt.Target.Config.Version + "/" + data, err := deterministicZip(files, prefix) + if err != nil { + return nil, err + } + if err = verifyZip(data, files, prefix); err != nil { + return nil, err + } + path := filepath.Join(output, strings.TrimSuffix(prefix, "/")+".zip") + if err = os.WriteFile(path, data, 0644); err != nil { + return nil, err + } + return map[string]string{"rust-source-zip": path}, nil + } model, target, err := p.emitterInputs(bt) if err != nil { return nil, err diff --git a/tooling/extension-bindings/orchestrator/catalog.go b/tooling/extension-bindings/orchestrator/catalog.go index 7ea54c1..b2235d9 100644 --- a/tooling/extension-bindings/orchestrator/catalog.go +++ b/tooling/extension-bindings/orchestrator/catalog.go @@ -178,7 +178,7 @@ func DiscoverProject(cwd, packagesPath, toolchainPath, toolingDirectory string) return nil, fmt.Errorf("%s:%s: name is required by the %s native emitter", key, language, language) } if p.Toolchain.Status == "released" { - if p.Toolchain.Tools.Emitters[language].SHA256 == "" || p.Toolchain.Tools.Profilers[language].SHA256 == "" { + if p.Toolchain.Tools.Emitters[language].SHA256 == "" || (language != "rust" && p.Toolchain.Tools.Profilers[language].SHA256 == "") { return nil, fmt.Errorf("%s: released lock lacks emitter or profiler", language) } native, ok := p.Toolchain.Languages[language] diff --git a/tooling/extension-bindings/orchestrator/cli.go b/tooling/extension-bindings/orchestrator/cli.go index 6bbc941..9e65541 100644 --- a/tooling/extension-bindings/orchestrator/cli.go +++ b/tooling/extension-bindings/orchestrator/cli.go @@ -18,10 +18,10 @@ func (r *repeated) String() string { return strings.Join(*r, ",") } func (r *repeated) Set(value string) error { *r = append(*r, value); return nil } type Options struct { - Packages, ToolchainLock, Cache, WorkDir, ToolingDir string - Output, Input, Report, ClosureOutput, DependencyLockOutput, DependencyLock string - CoreSchema, Go, Python, GoEmitter, Normalizer, GoProfiler, PythonProfiler, PythonProfilerArtifact string - Targets, ExtensionRoots, Overrides, CatalogRoots repeated + Packages, ToolchainLock, Cache, WorkDir, ToolingDir string + Output, Input, Report, ClosureOutput, DependencyLockOutput, DependencyLock string + CoreSchema, Go, Python, GoEmitter, Normalizer, GoProfiler, PythonProfiler, PythonProfilerArtifact, Cargo, RustEmitter string + Targets, ExtensionRoots, Overrides, CatalogRoots repeated } // Run is the reusable command boundary; the rc executable only supplies streams. @@ -80,6 +80,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { f.StringVar(&o.CoreSchema, "core-schema", "", "explicit core schema file for local development") f.StringVar(&o.Go, "go", envOr("RC_BINDINGS_GO", "go"), "native Go command") f.StringVar(&o.Python, "python", envOr("RC_BINDINGS_PYTHON", "python3"), "native Python interpreter") + f.StringVar(&o.Cargo, "cargo", envOr("RC_BINDINGS_CARGO", "cargo"), "native Cargo command") + f.StringVar(&o.RustEmitter, "rust-emitter", os.Getenv("RC_BINDINGS_RUST_EMITTER"), "installed locked Rust emitter; development defaults to workspace build") f.StringVar(&o.Normalizer, "normalizer", os.Getenv("RC_BINDINGS_NORMALIZER"), "installed locked normalizer; development defaults to workspace build") f.StringVar(&o.GoEmitter, "go-emitter", os.Getenv("RC_BINDINGS_GO_EMITTER"), "installed locked Go emitter; development defaults to workspace build") f.StringVar(&o.GoProfiler, "go-profiler", envOr("RC_GO_PROFILER_BIN", "go-rc-profiler"), "independently installed Go profiler CLI") diff --git a/tooling/extension-bindings/orchestrator/pipeline.go b/tooling/extension-bindings/orchestrator/pipeline.go index 00139cd..2df5c04 100644 --- a/tooling/extension-bindings/orchestrator/pipeline.go +++ b/tooling/extension-bindings/orchestrator/pipeline.go @@ -19,6 +19,8 @@ import ( "gopkg.in/yaml.v3" ) +const rustEmitterName = "runtimeconditions-binding-emitter-rust" + type Document struct { Closure normalizer.ResolvedClosure Lock normalizer.DependencyLock @@ -184,6 +186,9 @@ func (p *Pipeline) tool(key string) (string, Tool, error) { case "go-emitter": path = p.Options.GoEmitter expected = p.Project.Toolchain.Tools.Emitters["go"] + case "rust-emitter": + path = p.Options.RustEmitter + expected = p.Project.Toolchain.Tools.Emitters["rust"] case "go-profiler": path = p.Options.GoProfiler expected = p.Project.Toolchain.Tools.Profilers["go"] @@ -204,12 +209,26 @@ func (p *Pipeline) tool(key string) (string, Tool, error) { if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil { return "", Tool{}, err } - module, command := "normalizer", "rc-binding-model" - if key == "go-emitter" { - module, command = "emitters/go", "rc-go-bindings" - } - if _, err = p.run(filepath.Join(p.Project.ToolingDirectory, module), nil, p.Options.Go, "build", "-trimpath", "-buildvcs=false", "-o", path, "./cmd/"+command); err != nil { - return "", Tool{}, err + if key == "rust-emitter" { + target := path + "-target" + if _, err = p.run(filepath.Join(p.Project.ToolingDirectory, "emitters", "rust"), nil, p.Options.Cargo, "build", "--release", "--locked", "--target-dir", target); err != nil { + return "", Tool{}, err + } + binary, err := os.ReadFile(filepath.Join(target, "release", rustEmitterName)) + if err != nil { + return "", Tool{}, err + } + if err = os.WriteFile(path, binary, 0755); err != nil { + return "", Tool{}, err + } + } else { + module, command := "normalizer", "rc-binding-model" + if key == "go-emitter" { + module, command = "emitters/go", "rc-go-bindings" + } + if _, err = p.run(filepath.Join(p.Project.ToolingDirectory, module), nil, p.Options.Go, "build", "-trimpath", "-buildvcs=false", "-o", path, "./cmd/"+command); err != nil { + return "", Tool{}, err + } } p.tools[key] = path } else { @@ -236,6 +255,17 @@ func (p *Pipeline) tool(key string) (string, Tool, error) { case "go-emitter": identity.Name = goemitter.EmitterName identity.Version = goemitter.EmitterVersion + case "rust-emitter": + identity.Name = rustEmitterName + reported, err := p.run(p.Project.Root, nil, path, "--version") + if err != nil { + return "", Tool{}, err + } + identity.Version = strings.TrimSpace(string(reported)) + identity.SHA256, err = rustEmitterSourceDigest(filepath.Join(p.Project.ToolingDirectory, "emitters", "rust")) + if err != nil { + return "", Tool{}, err + } case "go-profiler": identity.Name = "go-rc-profiler" info, err := buildinfo.ReadFile(path) @@ -319,6 +349,9 @@ func (p *Pipeline) profiler(language string) (Tool, error) { _, identity, err := p.tool("go-profiler") return identity, err } + if language == "rust" { + return Tool{}, nil + } if language != "python" { return Tool{}, fmt.Errorf("unsupported native adapter %s", language) } @@ -668,6 +701,8 @@ func (p *Pipeline) nativeConfig(target Target, plan BuildPlan) (map[string]any, } if target.Language == "go" { dependencies = append(dependencies, map[string]any{"extension": provider.Set.RootExtension, "modulePath": provider.Config.Coordinate, "packageName": provider.Config.Name, "version": "v" + provider.Config.Version}) + } else if target.Language == "rust" { + dependencies = append(dependencies, map[string]any{"extension": provider.Set.RootExtension, "crateName": provider.Config.Coordinate, "version": provider.Config.Version}) } else { dependencies = append(dependencies, map[string]any{"extension": provider.Set.RootExtension, "distributionName": provider.Config.Coordinate, "importPackage": provider.Config.Name, "version": provider.Config.Version}) } @@ -686,6 +721,21 @@ func (p *Pipeline) nativeConfig(target Target, plan BuildPlan) (map[string]any, config["minimumGoVersion"] = majorMinor(target.Config.LanguageVersion) config["version"] = "v" + target.Config.Version config["emitterSha256"] = identity.SHA256 + case "rust": + _, identity, err := p.tool("rust-emitter") + if err != nil { + return nil, err + } + config["apiVersion"] = "runtimeconditions.io/rust-package-target/v1alpha1" + config["kind"] = "RuntimeConditionsRustPackageTarget" + config["crateName"] = target.Config.Coordinate + config["minimumRustVersion"] = majorMinor(target.Config.LanguageVersion) + config["sourceDirectory"] = target.Config.SourceDirectory + config["repositoryUrl"] = p.Project.Catalog.RepositoryURL + config["emitterSha256"] = identity.SHA256 + if target.Config.RegistryID != "" { + config["registryId"] = target.Config.RegistryID + } case "python": identity, err := p.pythonEmitterIdentity() if err != nil { @@ -745,6 +795,14 @@ func (p *Pipeline) emit(bt *BuiltTarget) error { _, err = p.run(p.Project.Root, nil, tool, "--model", modelPath, "--package-config", targetPath, "--output", bt.Directory) return err } + if bt.Target.Language == "rust" { + tool, _, err := p.tool("rust-emitter") + if err != nil { + return err + } + _, err = p.run(p.Project.Root, nil, tool, "emit", "--model", modelPath, "--package-config", targetPath, "--output", bt.Directory) + return err + } _, err = p.python("emit", "--model", modelPath, "--package-config", targetPath, "--output", bt.Directory, "--assembled") return err } @@ -752,6 +810,9 @@ func resourceDir(bt *BuiltTarget) string { if bt.Target.Language == "python" { return filepath.Join(bt.Directory, "src", bt.Target.Config.Name) } + if bt.Target.Language == "rust" { + return filepath.Join(bt.Directory, "runtimeconditions") + } return bt.Directory } func (p *Pipeline) assemble(bt *BuiltTarget, build *Build) error { @@ -921,3 +982,27 @@ func (p *Pipeline) LoadBuild(plan BuildPlan, input string) (*Build, error) { } return build, nil } + +func rustEmitterSourceDigest(directory string) (string, error) { + names := []string{"Cargo.toml", "Cargo.lock", "package-target.schema.yaml"} + sources, err := filepath.Glob(filepath.Join(directory, "src", "*.rs")) + if err != nil { + return "", err + } + for _, source := range sources { + names = append(names, "src/"+filepath.Base(source)) + } + digests := map[string]string{} + for _, name := range names { + data, err := os.ReadFile(filepath.Join(directory, filepath.FromSlash(name))) + if err != nil { + return "", err + } + digests[name] = normalizer.SHA256Hex(data) + } + encoded, err := json.Marshal(digests) + if err != nil { + return "", err + } + return normalizer.SHA256Hex(encoded), nil +} diff --git a/tooling/extension-bindings/orchestrator/release.go b/tooling/extension-bindings/orchestrator/release.go index 9ea7ce1..de880e5 100644 --- a/tooling/extension-bindings/orchestrator/release.go +++ b/tooling/extension-bindings/orchestrator/release.go @@ -84,6 +84,12 @@ func (p *Pipeline) releaseManifest(bt *BuiltTarget, build *Build) (map[string]an minimum = version next = nextBreaking(version) } + if bt.Target.Language == "rust" { + kind = "rust-source-zip" + version = dependency.Target.Config.Version + minimum = version + next = nextBreaking(version) + } artifact := dependency.Artifacts[kind] data, err := os.ReadFile(artifact) if err != nil { @@ -93,6 +99,9 @@ func (p *Pipeline) releaseManifest(bt *BuiltTarget, build *Build) (map[string]an } identity := bt.Document.Model.RootExtension result := map[string]any{"apiVersion": "runtimeconditions.io/binding-release/v1alpha1", "kind": "RuntimeConditionsBindingRelease", "package": packageIdentity, "model": map[string]any{"apiVersion": bt.Document.Model.APIVersion, "semanticSha256": bt.Document.Model.Metadata.SemanticSHA256}, "rootExtension": identity, "dependencyLock": bt.Document.Lock, "packageDependencies": dependencies, "provenance": map[string]any{"mode": "production", "orchestrator": orchestrator, "profiler": profiler, "sourceDirectory": bt.Target.Config.SourceDirectory, "targetReleaseTag": bt.Target.Config.SourceDirectory + "/v" + bt.Target.Config.Version}} + if profiler.SHA256 == "" { + delete(mapValue(result["provenance"]), "profiler") + } schema, err := readMapping(filepath.Join(p.Project.ToolingDirectory, "model", "runtimeconditions.binding-release.schema.yaml")) if err != nil { return nil, err @@ -455,7 +464,16 @@ func (p *Pipeline) nativeAPI(bt *BuiltTarget) ([]string, error) { if bt.Target.Language == "go" { return goemitter.SourceAPISurface(filepath.Join(bt.Directory, "bindings.go")) } - data, err := p.python("api", "--input", resourceDir(bt)) + var data []byte + var err error + if bt.Target.Language == "rust" { + var tool string + if tool, _, err = p.tool("rust-emitter"); err == nil { + data, err = p.run(p.Project.Root, nil, tool, "api", "--input", bt.Directory) + } + } else { + data, err = p.python("api", "--input", resourceDir(bt)) + } if err != nil { return nil, err } diff --git a/tooling/extension-bindings/orchestrator/verify.go b/tooling/extension-bindings/orchestrator/verify.go index 5f8da46..8c8f5b2 100644 --- a/tooling/extension-bindings/orchestrator/verify.go +++ b/tooling/extension-bindings/orchestrator/verify.go @@ -38,6 +38,7 @@ type nativeEnvironment struct { Python string PythonEnv map[string]string PythonSite string + Coordinates map[string]string Directories map[string]string } @@ -114,6 +115,14 @@ func (p *Pipeline) Verify(build *Build) (VerificationSummary, error) { if _, err = goemitter.VerifyAPI(bt.Document.Model, nativeTarget, bt.Directory); err != nil { return fail(bt.Target.Key, 16, err) } + } else if bt.Target.Language == "rust" { + tool, _, err := p.tool("rust-emitter") + if err == nil { + _, err = p.run(p.Project.Root, nil, tool, "verify", "--model", model, "--package-config", target, "--input", bt.Directory) + } + if err != nil { + return fail(bt.Target.Key, 16, err) + } } else { if _, err = p.python("verify", "--model", model, "--package-config", target, "--input", resourceDir(bt)); err != nil { return fail(bt.Target.Key, 16, err) @@ -204,7 +213,11 @@ func (p *Pipeline) verifyIdentities(bt *BuiltTarget, build *Build) error { return err } declared := mapValue(provenance["profiler"]) - if declared["name"] != profiler.Name || declared["version"] != profiler.Version || declared["sha256"] != profiler.SHA256 { + if bt.Target.Language == "rust" { + if len(declared) != 0 { + return fmt.Errorf("Rust release provenance must not name a profiler") + } + } else if declared["name"] != profiler.Name || declared["version"] != profiler.Version || declared["sha256"] != profiler.SHA256 { return fmt.Errorf("profiler provenance differs from installed artifact") } _, tool, err := p.tool("normalizer") @@ -222,9 +235,12 @@ func (p *Pipeline) verifyIdentities(bt *BuiltTarget, build *Build) error { if declared["sha256"] != tool.SHA256 || declared["name"] != tool.Name || declared["version"] != tool.Version { return fmt.Errorf("orchestrator provenance differs from selected artifact") } - if bt.Target.Language == "go" { + switch bt.Target.Language { + case "go": _, tool, err = p.tool("go-emitter") - } else { + case "rust": + _, tool, err = p.tool("rust-emitter") + default: tool, err = p.pythonEmitterIdentity() } if err != nil { @@ -256,7 +272,7 @@ func escapeModule(value string) string { return result.String() } func (p *Pipeline) installBuild(build *Build) (*nativeEnvironment, error) { - environment := &nativeEnvironment{Directories: map[string]string{}} + environment := &nativeEnvironment{Directories: map[string]string{}, Coordinates: map[string]string{}} work, err := p.workspace() if err != nil { return nil, err @@ -371,6 +387,7 @@ copy_dependencies("runtimeconditions-profiler") return nil, err } environment.Directories[bt.Target.Key] = stage + environment.Coordinates[bt.Target.Key] = bt.Target.Config.Coordinate } return environment, nil } @@ -406,6 +423,9 @@ func (p *Pipeline) verifyNative(bt *BuiltTarget, environment *nativeEnvironment) } return nil } + if bt.Target.Language == "rust" { + return p.verifyRust(bt, environment) + } version, err := p.run(directory, environment.PythonEnv, environment.Python, "-c", "import platform;print(platform.python_version())") if err != nil { return err @@ -488,3 +508,58 @@ func (p *Pipeline) verifyRegeneration(bt *BuiltTarget, build *Build) error { } return compareTrees(regenerated.Directory, bt.Directory) } + +func withPathDependencies(cargo string, paths map[string]string) string { + lines := strings.Split(cargo, "\n") + for i, line := range lines { + name, requirement, ok := strings.Cut(line, " = ") + if path, found := paths[name]; ok && found { + lines[i] = fmt.Sprintf("%s = { version = %s, path = %q }", name, requirement, path) + } + } + return strings.Join(lines, "\n") +} + +func (p *Pipeline) verifyRust(bt *BuiltTarget, environment *nativeEnvironment) error { + directory := environment.Directories[bt.Target.Key] + version, err := p.run(directory, nil, "rustc", "--version") + if err != nil { + return err + } + if !strings.HasPrefix(strings.TrimSpace(string(version)), "rustc "+bt.Target.Config.LanguageVersion+" ") { + return fmt.Errorf("gate 5: Rust compiler must be exactly %s", bt.Target.Config.LanguageVersion) + } + files, err := readTree(directory) + if err != nil { + return err + } + for name := range files { + if strings.HasPrefix(name, "src/") && strings.HasSuffix(name, ".rs") { + if _, err = p.run(directory, nil, "rustfmt", "--check", "--edition", "2021", name); err != nil { + return fmt.Errorf("gate 4: %w", err) + } + } + } + paths := map[string]string{} + for _, key := range bt.Target.Dependencies { + if stage, ok := environment.Directories[key]; ok { + paths[environment.Coordinates[key]] = stage + } + } + manifest := filepath.Join(directory, "Cargo.toml") + data, err := os.ReadFile(manifest) + if err != nil { + return err + } + if err = os.WriteFile(manifest, []byte(withPathDependencies(string(data), paths)), 0644); err != nil { + return err + } + target, err := p.workspacePath("native-rust", "target") + if err != nil { + return err + } + if _, err = p.run(directory, map[string]string{"CARGO_TARGET_DIR": target}, p.Options.Cargo, "check"); err != nil { + return fmt.Errorf("gate 5: %w", err) + } + return nil +} diff --git a/tooling/extension-bindings/orchestrator/verify_test.go b/tooling/extension-bindings/orchestrator/verify_test.go index 0e071f4..86b63d7 100644 --- a/tooling/extension-bindings/orchestrator/verify_test.go +++ b/tooling/extension-bindings/orchestrator/verify_test.go @@ -114,3 +114,46 @@ func TestTemporaryWorkspaceIsLazyAndCleaned(t *testing.T) { t.Fatal("explicit workspace not retained") } } + +func TestWithPathDependenciesRewritesOnlyNamedCrates(t *testing.T) { + input := "[package]\nname = \"consumer\"\n\n[dependencies]\nprovider = \">=0.1.0, <0.2.0\"\nserde_json = \"1\"\n" + want := "[package]\nname = \"consumer\"\n\n[dependencies]\nprovider = { version = \">=0.1.0, <0.2.0\", path = \"/stage/provider\" }\nserde_json = \"1\"\n" + if got := withPathDependencies(input, map[string]string{"provider": "/stage/provider"}); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestRustResourcesLiveBesideTheManifest(t *testing.T) { + bt := &BuiltTarget{Directory: "/package", Target: Target{Language: "rust"}} + if got := resourceDir(bt); got != "/package/runtimeconditions" { + t.Fatalf("resource directory %q", got) + } +} + +func TestRustEmitterSourceDigestTracksSourceNotBuilds(t *testing.T) { + directory := t.TempDir() + write := func(name, content string) { + path := filepath.Join(directory, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + for name, content := range map[string]string{"Cargo.toml": "a", "Cargo.lock": "b", "package-target.schema.yaml": "c", "src/main.rs": "d", "src/plan.rs": "e"} { + write(name, content) + } + first, err := rustEmitterSourceDigest(directory) + if err != nil { + t.Fatal(err) + } + write("target/release/runtimeconditions-binding-emitter-rust", "a binary") + if again, _ := rustEmitterSourceDigest(directory); again != first { + t.Fatal("build output must not change the digest") + } + write("src/plan.rs", "changed") + if changed, _ := rustEmitterSourceDigest(directory); changed == first { + t.Fatal("a source change must change the digest") + } +} diff --git a/tooling/extension-bindings/packages.yaml b/tooling/extension-bindings/packages.yaml index 7943e4e..4e3a09d 100644 --- a/tooling/extension-bindings/packages.yaml +++ b/tooling/extension-bindings/packages.yaml @@ -49,6 +49,13 @@ packages: languageVersion: 20.19.6 publicationMode: registry registryId: npm + rust: + coordinate: runtimeconditions-common-integrations + name: runtimeconditions_common_integrations + version: 0.1.0 + sourceDirectory: bindings/common-integrations/rust + languageVersion: 1.92.0 + publicationMode: github-tag env-configuration: rootExtension: https://runtimeconditions.io/extensions/env-configuration/v1alpha1/runtimeconditions.extension.yaml coreProfileSchemaVersion: 0.2.0 @@ -76,6 +83,13 @@ packages: languageVersion: 20.19.6 publicationMode: registry registryId: npm + rust: + coordinate: runtimeconditions-env-configuration + name: runtimeconditions_env_configuration + version: 0.1.0 + sourceDirectory: bindings/env-configuration/rust + languageVersion: 1.92.0 + publicationMode: github-tag google-analytics: rootExtension: https://runtimeconditions.io/extensions/google/analytics/0.1.0/runtimeconditions.extension.yaml coreProfileSchemaVersion: 0.2.0 diff --git a/tooling/extension-bindings/toolchain.lock.yaml b/tooling/extension-bindings/toolchain.lock.yaml index 4d5e93b..6585a2e 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' + rust: + languageVersion: '1.92.0' + packageManager: cargo + buildBackend: cargo + tools: + serde_json: '1.0' + serde_yaml: '0.9' + sha2: '0.10' + syn: '2.0' + jsonschema: '0.30' + rustfmt: '1.92.0' + unicode-normalization: '0.1'