diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml index e2f610dba..33b5ac4ac 100644 --- a/.github/actions/setup-workspace/action.yml +++ b/.github/actions/setup-workspace/action.yml @@ -5,7 +5,7 @@ inputs: node-version: description: Node.js version to install. required: false - default: '25' + default: '26' runs: using: composite diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 000000000..cdd0b53c4 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,89 @@ +name: Rust + +on: + pull_request: + branches: [main, feat/*] + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'rustfmt.toml' + - 'src/**' + - 'tests/**' + - 'benches/**' + - '.github/workflows/rust.yml' + push: + branches: [main, feat/*] + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'rustfmt.toml' + - 'src/**' + - 'tests/**' + - 'benches/**' + - '.github/workflows/rust.yml' + +permissions: + contents: read + +concurrency: + group: rust-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7.0.1 + + - name: Install Rust + run: rustup toolchain install stable --profile minimal --component rustfmt && rustup default stable + + - name: Check formatting + run: cargo fmt --check + + # The wasm guest is its own workspace, so the root `cargo fmt` skips it. + - name: Check formatting of the wasm guest + run: cargo fmt --check --manifest-path tests/wasm/ts/Cargo.toml + + clippy: + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v7.0.1 + + - name: Install Rust + run: rustup toolchain install stable --profile minimal --component clippy && rustup default stable + + - uses: Swatinem/rust-cache@v2.9.2 + + - name: Clippy (all features) + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + # The library on its own, as users with `default-features = false` build it. + - name: Clippy (no default features) + run: cargo clippy --locked --all-targets --no-default-features -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7.0.1 + + # tests/wasm.rs builds the guest in tests/wasm/ts for wasm32-wasip2. + - name: Install Rust + run: rustup toolchain install stable --profile minimal --target wasm32-wasip2 && rustup default stable + + - uses: Swatinem/rust-cache@v2.9.2 + + - name: Test + run: cargo test --locked --all-features diff --git a/.gitignore b/.gitignore index e80569f5e..815935c18 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,20 @@ dist/ openapi.json openapi.*.json **/openapi.forge.json + +# Build output. Every member of the workspace builds into the target +# directory at its root. +/target + +# Backups left by rustfmt. +**/*.rs.bk + +# Debug information from MSVC builds on Windows. +*.pdb + +# Coverage profiles from -C instrument-coverage. +*.profraw +*.profdata + +# Mutation testing results from cargo-mutants. +/mutants.out*/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 09814f57a..12f51d3e9 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,5 +7,5 @@ "tabWidth": 2, "useTabs": false, "endOfLine": "lf", - "ignorePatterns": ["**/_generated/**", "dist", ".wrangler"] + "ignorePatterns": ["**/_generated/**", "dist", ".wrangler", "Cargo.toml"] } diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..3a35495b1 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,187 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "import"], + "rules": { + "curly": ["error", "all"], + "no-unused-vars": "warn", + "no-console": "off", + "eqeqeq": "error", + "typescript/no-floating-promises": "warn", + "typescript/no-misused-promises": "warn" + }, + "ignorePatterns": ["node_modules", "dist", "**/_generated/**", ".wrangler"], + "overrides": [ + { + "files": ["packages/astro-fern/**", "packages/docs-site/**", "packages/fern-forge/**"], + "rules": { + "curly": "off" + } + }, + { + "files": ["src/bin/forge/ts/lib/**"], + "plugins": ["typescript", "unicorn", "oxc", "import", "node"], + "rules": { + "block-scoped-var": "error", + "constructor-super": "error", + "for-direction": "error", + "getter-return": "error", + "import/default": "error", + "import/namespace": "error", + "import/no-absolute-path": "error", + "import/no-empty-named-blocks": "error", + "import/no-named-as-default": "error", + "import/no-named-as-default-member": "error", + "import/no-self-import": "error", + "import/no-unassigned-import": "error", + "no-async-promise-executor": "error", + "no-caller": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-class-members": "error", + "no-dupe-else-if": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-implied-eval": "error", + "no-import-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-iterator": "error", + "no-loss-of-precision": "error", + "no-misleading-character-class": "error", + "no-new": "error", + "no-new-native-nonconstructor": "error", + "no-nonoctal-decimal-escape": "error", + "no-obj-calls": "error", + "no-self-assign": "error", + "no-setter-return": "error", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-unassigned-vars": "error", + "no-underscore-dangle": "error", + "no-unexpected-multiline": "error", + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-unused-vars": "error", + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-concat": "error", + "no-useless-constructor": "error", + "no-useless-escape": "error", + "no-useless-rename": "error", + "no-with": "error", + "node/no-exports-assign": "error", + "oxc/approx-constant": "error", + "oxc/bad-array-method-on-arguments": "error", + "oxc/bad-char-at-comparison": "error", + "oxc/bad-comparison-sequence": "error", + "oxc/bad-match-all-arg": "error", + "oxc/bad-min-max-func": "error", + "oxc/bad-object-literal-comparison": "error", + "oxc/bad-replace-all-arg": "error", + "oxc/const-comparisons": "error", + "oxc/double-comparisons": "error", + "oxc/erasing-op": "error", + "oxc/misrefactored-assign-op": "error", + "oxc/missing-throw": "error", + "oxc/no-async-endpoint-handlers": "error", + "oxc/no-this-in-exported-function": "error", + "oxc/number-arg-out-of-range": "error", + "oxc/only-used-in-recursion": "error", + "oxc/uninvoked-array-callback": "error", + "preserve-caught-error": "error", + "require-yield": "error", + "typescript/await-thenable": "error", + "typescript/consistent-return": "error", + "typescript/no-array-delete": "error", + "typescript/no-base-to-string": "error", + "typescript/no-confusing-non-null-assertion": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-duplicate-type-constituents": "error", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-extraneous-class": "error", + "typescript/no-floating-promises": "error", + "typescript/no-for-in-array": "error", + "typescript/no-generated-empty-object-type": "error", + "typescript/no-implied-eval": "error", + "typescript/no-meaningless-void-operator": "error", + "typescript/no-misused-new": "error", + "typescript/no-misused-spread": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-redundant-type-constituents": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-boolean-literal-compare": "error", + "typescript/no-unnecessary-parameter-property-assignment": "error", + "typescript/no-unnecessary-template-expression": "error", + "typescript/no-unnecessary-type-arguments": "error", + "typescript/no-unnecessary-type-assertion": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unnecessary-type-conversion": "error", + "typescript/no-unnecessary-type-parameters": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-enum-comparison": "error", + "typescript/no-unsafe-type-assertion": "error", + "typescript/no-unsafe-unary-minus": "error", + "typescript/no-useless-default-assignment": "error", + "typescript/no-useless-empty-export": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/require-array-sort-compare": "error", + "typescript/restrict-template-expressions": "error", + "typescript/triple-slash-reference": "error", + "typescript/unbound-method": "error", + "unicorn/consistent-function-scoping": "error", + "unicorn/no-accessor-recursion": "error", + "unicorn/no-array-fill-with-reference-type": "error", + "unicorn/no-array-reverse": "error", + "unicorn/no-array-sort": "error", + "unicorn/no-await-in-promise-methods": "error", + "unicorn/no-confusing-array-with": "error", + "unicorn/no-empty-file": "error", + "unicorn/no-instanceof-builtins": "error", + "unicorn/no-invalid-fetch-options": "error", + "unicorn/no-invalid-remove-event-listener": "error", + "unicorn/no-new-array": "error", + "unicorn/no-single-promise-in-promise-methods": "error", + "unicorn/no-thenable": "error", + "unicorn/no-unnecessary-await": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/no-useless-length-check": "error", + "unicorn/no-useless-spread": "error", + "unicorn/prefer-add-event-listener": "error", + "unicorn/prefer-set-size": "error", + "unicorn/prefer-string-starts-ends-with": "error", + "unicorn/require-module-specifiers": "error", + "unicorn/require-post-message-target-origin": "error", + "use-isnan": "error", + "valid-typeof": "error" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 3ec121934..6aa5bdb9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ API documentation site for Forge OpenAPI specs, built on Astro. ## Conventions - **Open Source Separation**: Generic packages (`astro-fern`, `fern-forge`) must remain free of Cloudflare-specific dependencies. -- **Formatting & Linting**: `oxfmt` and `oxlint` via `pnpm check`. +- **Formatting & Linting**: `oxfmt` and `oxlint`, configured only at the root (`.oxfmtrc.json`, `.oxlintrc.json`). `pnpm check` verifies; `pnpm format` rewrites. - **Type Checking**: Strict TypeScript compiler settings across all packages via `pnpm typecheck`. - **Tests**: Node-native test runner via `pnpm test` (or `pnpm -r run test`). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..db4f44398 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2071 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658e8623bea025d142fb11c299b8c6572ffe9fd663b52887c4dc124735cc130b" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b12b8c55c31c3d8598c2de9a657c3b978f905f6886c43e02216e0b15fe45a3" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "894b984f82c5a36f05fdd4973fbf4d37d69cd5cdf8c16e8ffe3af4a9cdfe193c" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1e4c34abe94193ada09f821f474be87ac8a1fbbacd87058571ef04c1bf4009" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63826cef0bf70e61d56cc930aa4ee423638f66dc3cfc10d683e73138a6d177b8" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26de7849112293681a19f7525c689e1d73dc6eb2687355170fb7458c4da8d353" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df315a8b1cfd6e620971e3d4249bd2c4528167717e8f48a6317a87c55891bcb8" + +[[package]] +name = "cranelift-control" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3fff7610fb17f228f16598e1a044db79a12806ece7496ff2c69cf1a27a79a5" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6158b1ac381792ad11e670abbcd15db8b1737e84bfa8a7f2bd7eb41388a92530" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099361a0919067086dcde624a281ce719834cebf8544d6c24646f5e924107234" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f30ea3fd5a11f7d3473976d3b62b5b3f2b62fa943c85e0331d88bf505082c" + +[[package]] +name = "cranelift-native" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397508a8d817dc74019603ac5a4c4e40b4df627e5aed4f2f8088345d4c01d401" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4944ef05513e1c1a85a945cf8f29d95ddea8bcbb19e0bfd4e71063450967420" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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.6", +] + +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "forge" +version = "0.1.0" +dependencies = [ + "clap", + "divan", + "rayon", + "serde", + "serde_json", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[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-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[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 = "pulley-interpreter" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b66b7448cb92d709f2924de98b5ba5b5ce4ea08e6d6b9c3b403fcd88d122ca2" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185b9b4cd91f85110f48f8c0314757dfb2bb48c40f5d91055ea666cbbcbf317b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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.6", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-encoder" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1801b9c9f91e16e352ed77656824e7c4556a7538dbfc1a50b37243f0e12ea045" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "encoding_rs", + "futures", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f397969b8daf208e45120036be74956e99364ce916d5b5d67f9e9f0fe40e5477" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47c428b868b59d51a9f77e24bff02cc321f8ec0faeba5c40f7e4c1cddd43a64" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fe14bd5ea6444a09f6baa3865559c93c91158930e7fe6d127429a9372f6165" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2053fd1d90080e502ca4ea39d86c3528587a40220b9572a874c8b742c219a00c" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e96d6c55401e68e40e3f74078ec96f92fe9b8a7e90e885fb87265e69eea559" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b913875bab1b78fada489e55d5e0a10b53286fb4bb09f5657212f549c9af36c" +dependencies = [ + "cc", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0356a1a09dee1239904e09e962456d9f6edfa5da61c5e3e1b0e4669710f48fba" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dead9a2d7b5d835698a2e1a32006e7d0b5711f4697b24bab1564bb2488e3d7a" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ffac8fc893de2958b263d9d7274cf4c531ba9ff582558348d4e28d97b8a0f2" +dependencies = [ + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a20f0dcb6c6a006363d3f85b5c633675730487b0dc23726b2d40495872407fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80758f73f5f5daacd8a6d1a8513336a4f9a58d26c4f1c73eb80502cb143e2626" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-component", + "wit-parser", +] + +[[package]] +name = "wasmtime-wasi" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac947c8478d8ea84ef4120d04f984012a514e493fe16fa5bb72906d7e55addb" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-primitives", + "futures", + "rand", + "rustix", + "rustix-linux-procfs", + "thiserror", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "49.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65d70efbe70631acbb4c6cf46eea543c75908f01e2fd09550e5a89631693304" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-component" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[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.6", + "synstructure", +] + +[[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.6", + "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.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..359d22756 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,50 @@ +[workspace] +resolver = "3" + +[package] +name = "forge" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.6.7", features = ["derive"], optional = true } +rayon = "1.12.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = { version = "1.0.151", optional = true } +wasmtime = { version = "49.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "parallel-compilation", "std"], optional = true } +wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"], optional = true } + +[dev-dependencies] +divan = "0.1.21" +serde_json = "1.0.151" + +[[bench]] +name = "parse" +harness = false + +[features] +default = ["cli"] +# The `forge` command. Library users can turn it off with +# `default-features = false`. +cli = ["dep:clap", "dep:serde_json"] +# `forge::wasm`, which runs plugins compiled to WebAssembly components. +wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] + +[profile.release] +strip = true # Drop symbols and debug info from the binary, which the npm packages ship. +opt-level = 3 # Enable aggressive performance optimizations (default for release) +lto = "fat" # Maximum Link-Time Optimization across all crates +codegen-units = 1 # Allow global LLVM optimizations across the entire binary +panic = "abort" # Slightly reduces binary bloat and can give minor speed boosts + +[[bin]] +name = "forge" +required-features = ["cli"] + +[[test]] +name = "cli" +required-features = ["cli"] + +[[test]] +name = "wasm" +required-features = ["cli", "wasm"] diff --git a/README.md b/README.md index 69ee890e6..ce33e48aa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Forge is a schema-first OpenAPI code generation and surface tooling framework. It provides a plugin-based architecture for resolving OpenAPI 3.x specifications, applying JSONPath-based overlays, and generating typed SDKs, CLI interfaces, runtime helpers, and documentation. -It's [still early-days](https://blog.cloudflare.com/forge-open-source-generation-pipeline) for Forge and we're laser focused on the first Forge output, the [cf CLI](https://blog.cloudflare.com/cloudflare-cf-cli-launch). Rest assured, in short order we'll be making it easier and easier for other targets (namely, TypeScript, Go, Python, Terraform, etc. - and docs for all of the above), but that will track in parallel to us releasing those targets for our own SDKs. That's just how we do things at Cloudflare: we often open source as soon as we can (often, sooner than others might) and always test our products on ourselves first. +It's [still early-days](https://blog.cloudflare.com/forge-open-source-generation-pipeline) for Forge and we're laser focused on the first Forge output, the [cf CLI](https://blog.cloudflare.com/cloudflare-cf-cli-launch). Rest assured, in short order we'll be making it easier and easier for other targets (namely, TypeScript, Go, Python, Terraform, etc. - and docs for all of the above), but that will track in parallel to us releasing those targets for our own SDKs. That's just how we do things at Cloudflare: we often open source as soon as we can (often, sooner than others might) and always test our products on ourselves first. ## License diff --git a/benches/parse.rs b/benches/parse.rs new file mode 100644 index 000000000..302bf87bd --- /dev/null +++ b/benches/parse.rs @@ -0,0 +1,90 @@ +//! Benchmarks parsing OpenAPI JSON documents into a `Forge`. +//! +//! The documents come from `FORGE_SPEC`, one path or several separated +//! like `PATH` (`:` on Unix): +//! +//! ```sh +//! FORGE_SPEC=/path/to/openapi.json cargo bench --bench parse +//! FORGE_SPEC=a.json:b.json cargo bench --bench parse -- forge +//! ``` +//! +//! Each benchmark gets every document, named by its file name. + +use std::fmt; +use std::path::PathBuf; +use std::sync::OnceLock; + +use divan::counter::BytesCount; +use divan::{AllocProfiler, Bencher, black_box}; +use forge::Forge; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + divan::main(); +} + +/// A document read from a file named in `FORGE_SPEC`. +struct Spec { + path: PathBuf, + json: Vec, +} + +impl fmt::Display for Spec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.path.file_name() { + Some(name) => write!(f, "{}", name.to_string_lossy()), + None => write!(f, "{}", self.path.display()), + } + } +} + +/// Returns the documents in `FORGE_SPEC`, read once and checked to parse, +/// so a bad document fails before anything is timed. +fn specs() -> &'static [Spec] { + static SPECS: OnceLock> = OnceLock::new(); + SPECS.get_or_init(|| { + let Some(paths) = std::env::var_os("FORGE_SPEC") else { + panic!("missing FORGE_SPEC: set it to the OpenAPI JSON document(s) to benchmark"); + }; + let specs: Vec = std::env::split_paths(&paths) + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| { + let json = std::fs::read(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display())); + if let Err(err) = serde_json::from_slice::(&json) { + panic!("parse {}: {err}", path.display()); + } + Spec { path, json } + }) + .collect(); + assert!(!specs.is_empty(), "FORGE_SPEC names no files"); + specs + }) +} + +/// Parses the document into a `Forge`: reading the JSON, then linking it. +#[divan::bench(args = specs())] +fn forge(bencher: Bencher, spec: &Spec) { + bencher + .counter(BytesCount::of_slice(&spec.json)) + .bench(|| -> Forge { serde_json::from_slice(black_box(&spec.json)).unwrap() }); +} + +/// Parses the document into a `serde_json::Value`, as a baseline for how +/// much of [`forge`] is reading JSON at all. +#[divan::bench(args = specs())] +fn json_value(bencher: Bencher, spec: &Spec) { + bencher + .counter(BytesCount::of_slice(&spec.json)) + .bench(|| -> serde_json::Value { serde_json::from_slice(black_box(&spec.json)).unwrap() }); +} + +/// Skips the JSON entirely and reads nothing but whether it is well +/// formed, as a floor for any parse of the document. +#[divan::bench(args = specs())] +fn json_ignored(bencher: Bencher, spec: &Spec) { + bencher + .counter(BytesCount::of_slice(&spec.json)) + .bench(|| serde_json::from_slice::(black_box(&spec.json)).unwrap()); +} diff --git a/oxlintrc.json b/oxlintrc.json deleted file mode 100644 index 3050a5e46..000000000 --- a/oxlintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "rules": { - "curly": ["error", "all"], - "no-unused-vars": "warn", - "no-console": "off", - "eqeqeq": "error", - "typescript/no-floating-promises": "warn", - "typescript/no-misused-promises": "warn" - }, - "ignorePatterns": ["node_modules", "dist", "**/_generated/**", ".wrangler"] -} diff --git a/package.json b/package.json index 095fef59b..1703a2221 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "format": "oxfmt --write .", + "format:check": "oxfmt --check .", "lint": "oxlint .", - "check": "pnpm format && pnpm lint", + "check": "pnpm format:check && pnpm lint", "typecheck": "pnpm -r run typecheck", "test": "pnpm -r run test", "test:e2e": "pnpm --filter astro-fern test:e2e", @@ -14,8 +15,8 @@ }, "devDependencies": { "@types/node": "26.0.1", - "oxfmt": "0.57.0", - "oxlint": "1.72.0" + "oxfmt": "0.71.0", + "oxlint": "1.86.0" }, "engines": { "node": ">=22.0.0" diff --git a/packages/forge/.gitignore b/packages/forge/.gitignore new file mode 100644 index 000000000..5f49ab89d --- /dev/null +++ b/packages/forge/.gitignore @@ -0,0 +1,13 @@ +# Tarballs that scripts/archive.mjs makes. +/dist + +# Tarballs from npm pack. +*.tgz + +# Dev dependencies, which build the plugin library. +/node_modules + +# What scripts/build.mjs generates: the WIT's bindings, and the library. +/src/wit +/plugin/*.js +/plugin/*.d.ts diff --git a/packages/forge/README.md b/packages/forge/README.md new file mode 100644 index 000000000..2f6757ec9 --- /dev/null +++ b/packages/forge/README.md @@ -0,0 +1,349 @@ +# @cloudflare/forge + +Generates SDKs from OpenAPI documents. + +```sh +npm install --save-dev @cloudflare/forge +npx forge openapi.json --target ts --out sdk +``` + +Or run it once without installing: + +```sh +npx @cloudflare/forge openapi.json --target ts --out sdk +``` + +Pass `-` instead of a path to read the document from standard input. Run +`forge --help` for every option. + +## Plugins + +A plugin generates an SDK, or anything else, from the document: forge parses +it, and hands the plugin its groups, methods, models and types. Plugins are +WebAssembly components, so they run anywhere forge does, sandboxed: a plugin +reads nothing but the document, and its only output is the files it returns. + +```sh +npx forge openapi.json --target wasm://plugin.wasm --out sdk +``` + +### Writing one in TypeScript + +```sh +npm install --save-dev @cloudflare/forge @bytecodealliance/jco +``` + +`plugin.ts`: + +```ts +import { definePlugin, type Type } from '@cloudflare/forge/plugin'; + +function ts(type: Type): string { + switch (type.kind) { + case 'scalar': + return type.scalar === 'number' ? 'number' : type.scalar === 'boolean' ? 'boolean' : 'string'; + case 'array': + return `${ts(type.elem)}[]`; + case 'model': + return type.model.name.pascal(); + default: + return 'unknown'; + } +} + +export const { generate, generateGroup } = definePlugin({ + // Called once, for the whole document. + generate(forge) { + return [{ path: 'README.md', content: `# API ${forge.version}\n` }]; + }, + + // Called once for each group, in parallel. + generateGroup(group) { + const dir = group.path.map((name) => name.camel()).join('/'); + const lines = group.models.map((m) => `export type ${m.name.pascal()} = ${ts(m.type)};`); + return [{ path: `${dir}/models.ts`, content: lines.join('\n') }]; + }, +}); +``` + +Build it, and run it: + +```sh +npx forge-plugin build plugin.ts -o plugin.wasm +npx forge openapi.json --target wasm://plugin.wasm --out sdk +``` + +`@cloudflare/forge/plugin` is typed throughout. A type is a union you narrow +by `kind`, so a `never` check in a `switch` turns a kind you missed into a +compile error. The same group, method or model is always the same object, +so you can compare them with `===` and keep them in a `Map`. + +A plugin fails by throwing; forge stops and prints the message. Its files +go through the same checks as forge's own: a path must be relative, use `/`, +have no `.` or `..` parts, and no two files may share one. + +Things to know: + +- **Each group runs in an instance of its own.** `generateGroup` sees + nothing that `generate` or another group kept in module state. +- **There is no Node.** Plugins have no `fs`, `process`, network or clock. + Libraries in plain JavaScript bundle in and work. +- **QuickJS runs the plugin.** `forge-plugin build` compiles it in, for a + plugin of about 2 MB that starts in about 0.1 s. QuickJS has no + `TextEncoder`; give a file its content as a string, and the library + encodes it. +- **jco** compiles the plugin. It is large, and only plugin authors need + it, so it is an optional peer dependency you install yourself. + +### Generating TypeScript + +`@cloudflare/forge/ts` writes TypeScript files and works out their imports. +Print a line by calling the file, `` f`…` ``, with identifiers in it: it imports +each one from its module when it renders, with `import type` for types, and +gives clashing names aliases. + +A `Module` is a file a line can import from: a generated file, by its path in +the output, or a package. An `Ident` is a name and the module that declares +it. Print them in a `File`, and it writes the imports: + +```ts +import { file, ident, join, module } from '@cloudflare/forge/ts'; + +// Modules: a generated file, by its path in the output, and a package. +const models = module('pets/models.ts'); +const zod = module('zod'); + +// Identifiers: a name and the module that declares it. +const pet = models.ident('Pet'); +const owner = ident('Owner', models); +const z = zod.ident('z'); +const instant = module('temporal').ident('Temporal.Instant'); + +const f = file('pets/client.ts'); +const client = f.ident('Client'); // declared in this file, so not imported + +f.doc('A client of pets.'); +f`export interface ${client} {`; +f.indent(() => { + f`get(id: string): Promise<${pet.ty()}>;`; + f`owners(): Promise<${join(' | ', [owner.ty(), 'null'])}>;`; + f`seen(): ${instant.ty()};`; +}); +f`}`; +f(); +// A name used as a value and as a type is imported once, as a value. +f`export const schema = ${z}.object({ pet: ${pet}.schema });`; +// An alias, and a name that clashes with one the file declares. +f`export type Herd = ${models.ident('Pet').ty().as('Animal')}[];`; +f('export type Rival = ', module('other/models.ts').ident('Client').ty(), ';'); +``` + +`f.content` is then: + +```ts +// Code generated by forge. DO NOT EDIT. +/* eslint-disable */ + +import type { Temporal } from 'temporal'; +import { z } from 'zod'; + +import type { Client as Client$ } from '../other/models.js'; +import { type Owner, Pet, type Pet as Animal } from './models.js'; + +/** + * A client of pets. + */ +export interface Client { + get(id: string): Promise; + owners(): Promise; + seen(): Temporal.Instant; +} + +export const schema = z.object({ pet: Pet.schema }); +export type Herd = Animal[]; +export type Rival = Client$; +``` + +Packages come first, then generated files, each imported by a path relative +to the file. `Client`, which the file declares, isn't imported, so the other +`Client` gets a `$`. `Pet` is used as both a type and a value, so it's +imported once, as a value; `Temporal.Instant` is imported as `Temporal`. + +A plugin puts these together. This one writes a `models.ts` for each group, +importing the models it refers to from the groups they're in: + +```ts +import { definePlugin, type Model, type Type } from '@cloudflare/forge/plugin'; +import { type File, type Module, type Part, file, frag, ident, join, module } from '@cloudflare/forge/ts'; + +function ts(type: Type): Part { + switch (type.kind) { + case 'array': + return frag`Array<${ts(type.elem)}>`; + case 'union': + return join(' | ', type.members.map(ts)); + case 'model': + // Imported from the models.ts of the model's group. + return frag`${ident.model(type.model).ty()}`; + default: + return frag`unknown`; + } +} + +/** The models.ts in `dir`, declaring `models`. */ +function models(dir: Module, models: readonly Model[]): File { + const f = file(dir.sub('models.ts')); + for (const model of models) { + f.doc(model.description); + f`export type ${ident.model(model).name} = ${ts(model.type)};`; + } + return f; +} + +export const { generate, generateGroup } = definePlugin({ + // The models in no group, which more than one top-level group uses. + generate: (forge) => [models(module.ROOT, forge.models)], + generateGroup: (group) => [models(module.group(group), group.models)], +}); +``` + +- `` f`…` `` prints a line at the file's indentation. `f(...parts)` prints + one of parts made at run time, and `f()` a blank one. `f.indent(() => …)` + indents what it prints, and `f.doc(text)` prints a JSDoc comment, wrapped + at 80 columns. +- A line's parts are strings, numbers, booleans, identifiers, models, which + print as `ident.model`, and arrays of parts, which print each part in + turn, with nothing between them. `join(sep, parts)` puts a separator + between parts, and `` frag`…` `` makes part of a line to print later; + both return an array of parts. Anything else, such as `undefined`, throws + rather than printing `undefined`. +- `module("pets/models.ts")` is a generated file, which other files import + as `./models.js`, and `module("zod")` a package. `m.ident(name)`, or + `ident(name, m)`, is an identifier it declares; `.ty()` marks it as a + type, and `.as(alias)` imports it under another name. `module.group(group)` + is a group's directory, `module.ROOT` the output directory's, and + `ident.model(model)` a model's identifier. +- `file(path)` returns a `File`, which is a plugin's file as it is: one with + nothing printed isn't written, and its text starts with a preamble marking + it as generated. +- `File`, `Module` and `Ident` are types, and only `file`, `module` and + `ident` make them: they're immutable, and an object that merely has the + same properties isn't one. + +This is forge's own TypeScript printer, `forge::ts` in Rust, and prints the +same text. + +### Writing one in another language + +A plugin is a component of the `plugin` world in the WIT that +`npx forge-plugin wit` prints, which is also at +`@cloudflare/forge/plugin/forge.wit`. Any language with component tooling +works: in Rust, `wit_bindgen::generate!` and +`cargo build --target wasm32-wasip2`. + +## Platforms + +forge is a native binary, and this package carries one for each of: + +- macOS, Apple silicon and Intel +- Linux, arm64 and x64 +- Windows, arm64 and x64 + +The `forge` command is a small Node launcher that runs the right one. The +Linux binaries are statically linked, so they run on both glibc and musl +distributions, Alpine included. The Windows ones don't need the Visual C++ +Redistributable. + +To run some other forge binary through this package, such as a local build, +set `FORGE_BINARY` to its path. + +## Installing from a tarball + +The package is a single tarball, so you can install it without the +registry. Install it by path: + +```sh +npm install --save-dev /path/to/cloudflare-forge-0.1.0.tgz +``` + +or list it in `package.json`. A path or URL to the tarball both work: + +```json +"devDependencies": { + "@cloudflare/forge": "file:../cloudflare-forge-0.1.0.tgz" +} +``` + +## Releasing + +From `packages/forge` in the forge repository, on a Mac with Docker running: + +```sh +pnpm install # the dev dependencies, which build the plugin library +node scripts/version.mjs # after bumping the version in Cargo.toml +node scripts/wit.mjs # after changing the WIT in src/plugin/wasm.rs +node scripts/compile.mjs # builds all six binaries into target/ +node scripts/archive.mjs # packs them into dist/cloudflare-forge-.tgz +node scripts/publish.mjs # publishes that tarball; takes npm publish options +``` + +The tarball that `archive.mjs` makes is the one that gets published, so a +tarball you hand around and the package on the registry are the same. +`npm pack` and `npm publish` refuse to run in this directory, which has no +binaries of its own. + +The binaries are built with the `wasm` feature, which runs plugins. The +package carries forge's copy of the WIT in `plugin/forge.wit`, and +`archive.mjs` refuses to pack it if it isn't the WIT in +`src/plugin/wasm.rs`. + +The library plugins are written with, `@cloudflare/forge/plugin`, is +`src/index.ts`. `archive.mjs` builds it into the package, and +`node scripts/build.mjs` builds it into `plugin/` to try it out: jco +generates the TypeScript bindings of `plugin/forge.wit` into `src/wit`, and +`tsc` compiles the library against them into `plugin/index.js` and +`plugin/index.d.ts`. So when the WIT changes in a way the library doesn't +follow, such as a new kind of type, a new scalar or a renamed field, the +library fails to compile, and the package can't be packed. Its exports are +interfaces and the classes behind them are private, so the bindings never +appear in `index.d.ts`. + +`@cloudflare/forge/ts` is `src/ts.ts`, built the same way. It reads the +forge only through the library's types, so it needs no host, and `npm test` +runs its tests in Node: the tests of `forge::ts` in +`src/plugin/ts/tests.rs`, expecting the same text. + +### Building the binaries + +`scripts/compile.mjs` builds each platform with the tool that can: + +| Platform | Tool | Needs | +| -------- | -------------------------------------------------------------- | --------------------------------------- | +| macOS | `cargo build` | a Mac, Xcode's command line tools | +| Linux | [`cross build`](https://github.com/cross-rs/cross) | Docker | +| Windows | [`cargo xwin build`](https://github.com/rust-cross/cargo-xwin) | nothing; it downloads the MSVC CRT once | + +One-time setup: + +```sh +rustup target add x86_64-apple-darwin aarch64-apple-darwin \ + aarch64-pc-windows-msvc x86_64-pc-windows-msvc +cargo install cross --git https://github.com/cross-rs/cross +cargo install --locked cargo-xwin +``` + +cross can't build macOS or Windows MSVC binaries, because it has no prebuilt +images for them. The Windows binaries link the C runtime statically. + +### Trying it out locally + +`compile.mjs` and `archive.mjs` take `--platform -`, repeated, to +build or pack only some platforms. For a package that runs on this machine: + +```sh +cargo build --release --features wasm +node scripts/archive.mjs --platform darwin-arm64 +``` + +A tarball that leaves platforms out lists the ones it has in its name, as in +`cloudflare-forge-0.1.0-darwin-arm64.tgz`, and `publish.mjs` won't publish it. diff --git a/packages/forge/bin/forge-plugin.mjs b/packages/forge/bin/forge-plugin.mjs new file mode 100644 index 000000000..8adfe0c1b --- /dev/null +++ b/packages/forge/bin/forge-plugin.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Builds forge plugins written in JavaScript or TypeScript. +// +// forge-plugin build [-o plugin.wasm] +// forge-plugin wit +// +// `build` bundles the entry module and its dependencies, and compiles them +// with QuickJS into a WebAssembly component that +// `forge --target wasm://plugin.wasm` runs. It uses jco, which projects +// install themselves, since it is large and only plugin authors need it: +// +// npm install --save-dev @bytecodealliance/jco +// +// `wit` prints the WIT plugins are built against, for plugins in other +// languages. + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const pluginDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'plugin'); +const wit = join(pluginDir, 'forge.wit'); + +const usage = `usage: forge-plugin build [-o plugin.wasm] + forge-plugin wit + +build compiles the plugin whose entry module is , a .ts or .js file + that exports definePlugin's generate and generateGroup, into a + WebAssembly component with QuickJS. Run it with: + forge openapi.json --target wasm://plugin.wasm --out sdk + -o, --out the component to write (default: plugin.wasm) +wit prints the WIT plugins are built against`; + +/** Prints `message` and exits with failure. */ +function fail(message) { + process.stderr.write(`forge-plugin: ${message}\n`); + process.exit(1); +} + +const [command, ...rest] = process.argv.slice(2); +switch (command) { + case 'build': + build(rest); + break; + case 'wit': + process.stdout.write(readFileSync(wit, 'utf8')); + break; + case '-h': + case '--help': + case 'help': + console.log(usage); + break; + default: + fail(command ? `unknown command ${command}\n${usage}` : usage); +} + +function build(args) { + let parsed; + try { + parsed = parseArgs({ + args, + allowPositionals: true, + options: { + out: { type: 'string', short: 'o', default: 'plugin.wasm' }, + }, + }); + } catch (err) { + fail(`${err.message}\n${usage}`); + } + const { values, positionals } = parsed; + if (positionals.length !== 1) { + fail(`build takes one entry module\n${usage}`); + } + const entry = resolve(positionals[0]); + if (!existsSync(entry)) { + fail(`${positionals[0]} does not exist`); + } + + const jco = findJco(dirname(entry)); + const result = spawnSync( + process.execPath, + [ + jco, + 'componentize', + entry, + '--wit', + wit, + '--world-name', + 'plugin', + '--bundle', + '--bundle-config', + join(pluginDir, 'bundle.config.mjs'), + '--backend', + 'quickjs', + // The host runs components without the async ABI. + '--backend-qjs-disable-async', + '--out', + resolve(values.out), + ], + { stdio: 'inherit' }, + ); + if (result.error) { + fail(`cannot run jco: ${result.error.message}`); + } + process.exitCode = result.status ?? 1; +} + +/** + * Returns the path to jco's command, installed in the project of `dir` or + * beside this package. + */ +function findJco(dir) { + for (const from of [dir, dirname(fileURLToPath(import.meta.url))]) { + // jco's exports leave out its package.json, so its main module is + // resolved, as Node would, and its package found above that. + let main; + try { + main = createRequire(join(from, 'noop.js')).resolve('@bytecodealliance/jco'); + } catch { + continue; // Not installed there. + } + for (let pkgDir = dirname(main); pkgDir !== dirname(pkgDir); pkgDir = dirname(pkgDir)) { + const pkgPath = join(pkgDir, 'package.json'); + if (!existsSync(pkgPath)) { + continue; + } + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (pkg.name !== '@bytecodealliance/jco') { + continue; + } + const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.jco; + if (bin) { + return join(pkgDir, bin); + } + } + } + fail('building a plugin needs jco; run npm install --save-dev @bytecodealliance/jco'); +} diff --git a/packages/forge/bin/forge.cjs b/packages/forge/bin/forge.cjs new file mode 100644 index 000000000..20d20f51b --- /dev/null +++ b/packages/forge/bin/forge.cjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Runs the forge binary for this platform. The package carries a binary for +// every platform it supports, at native/-/forge. + +'use strict'; + +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const exe = process.platform === 'win32' ? 'forge.exe' : 'forge'; + +/** Prints `message` and exits with failure. */ +function fail(message) { + process.stderr.write(`forge: ${message}\n`); + process.exit(1); +} + +/** Returns the path to the forge binary for this platform. */ +function binary() { + // Lets you run a local build, such as target/debug/forge, through npm. + if (process.env.FORGE_BINARY) { + return process.env.FORGE_BINARY; + } + + const platform = `${process.platform}-${process.arch}`; + const native = path.join(__dirname, '..', 'native'); + const bin = path.join(native, platform, exe); + if (!fs.existsSync(bin)) { + const bundled = fs.existsSync(native) ? fs.readdirSync(native) : []; + fail( + bundled.length > 0 + ? `there is no forge binary for ${platform}; this package has them for ${bundled.join(', ')}` + : 'this package has no forge binaries; build it with scripts/archive.mjs', + ); + } + return bin; +} + +const bin = binary(); +const result = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' }); +if (result.error) { + fail(`cannot run ${bin}: ${result.error.message}`); +} +if (result.signal) { + // End this process the same way the binary ended. + process.kill(process.pid, result.signal); + // Reached only when the signal does not end the process; Node ignores + // SIGPIPE, for one. + process.exitCode = 128 + (os.constants.signals[result.signal] ?? 0); +} else { + process.exitCode = result.status; +} diff --git a/packages/forge/package.json b/packages/forge/package.json new file mode 100644 index 000000000..0093b8258 --- /dev/null +++ b/packages/forge/package.json @@ -0,0 +1,63 @@ +{ + "name": "@cloudflare/forge", + "version": "0.1.0", + "description": "Generates SDKs from OpenAPI documents.", + "keywords": [ + "codegen", + "openapi", + "plugin", + "sdk", + "typescript", + "wasm" + ], + "bin": { + "forge": "bin/forge.cjs", + "forge-plugin": "bin/forge-plugin.mjs" + }, + "files": [ + "bin", + "native", + "plugin" + ], + "type": "module", + "exports": { + "./plugin": { + "types": "./plugin/index.d.ts", + "default": "./plugin/index.js" + }, + "./ts": { + "types": "./plugin/ts.d.ts", + "default": "./plugin/ts.js" + }, + "./plugin/forge.wit": "./plugin/forge.wit", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "compile": "node scripts/compile.mjs", + "wit": "node scripts/wit.mjs", + "build": "node scripts/build.mjs", + "test": "node scripts/build.mjs && tsc -p test && node --test \"test/*.test.js\"", + "archive": "node scripts/archive.mjs", + "release": "node scripts/publish.mjs", + "sync-version": "node scripts/version.mjs", + "prepack": "node -e \"console.error('error: this directory has no binaries; pack with npm run archive, publish with npm run release'); process.exit(1)\"" + }, + "devDependencies": { + "@bytecodealliance/jco": "1.37.0", + "typescript": "7.0.2" + }, + "peerDependencies": { + "@bytecodealliance/jco": "^1.37.0" + }, + "peerDependenciesMeta": { + "@bytecodealliance/jco": { + "optional": true + } + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/forge/plugin/bundle.config.mjs b/packages/forge/plugin/bundle.config.mjs new file mode 100644 index 000000000..57f8e6bd8 --- /dev/null +++ b/packages/forge/plugin/bundle.config.mjs @@ -0,0 +1,6 @@ +// The Rolldown configuration `forge-plugin build` bundles a plugin with. The +// host provides the `forge:` interfaces, so the bundle leaves them as +// imports, which componentizing links to the WIT. +export default { + external: [/^forge:/], +}; diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit new file mode 100644 index 000000000..fc75f5614 --- /dev/null +++ b/packages/forge/plugin/forge.wit @@ -0,0 +1,222 @@ + +package forge:plugin@0.2.0; + +/// Read-only access to the forge a plugin runs on, which the host keeps. +/// Each id is an index the host gave out. An id the host never gave out +/// traps. +/// +/// Everything a part has is returned in one call, and orders and meanings +/// are as the Rust API documents them. +interface forge { + type group-id = u32; + type method-id = u32; + type model-id = u32; + type type-id = u32; + + /// The lower-case words of a `Name`. + type name = list; + + /// A JSON value, which forge keeps without reading, as JSON text that + /// `JSON.parse` reads back: an object's members in the order they are + /// written, each key once. WIT has no recursive types, so a value is + /// its text. + type json = string; + + record forge-info { + version: string, + description: string, + base-url: string, + } + + /// How many of each part the forge has. Ids run from 0 to the count. + record forge-counts { + groups: u32, + methods: u32, + models: u32, + types: u32, + } + + record group-info { + name: name, + description: string, + /// The groups from the top level down to this one, ending with it. + path: list, + /// The groups directly inside, sorted by name. + children: list, + /// The methods directly inside, sorted by name. + methods: list, + models: list, + } + + variant http-method { + get, put, post, delete, options, head, patch, trace, query, connect, + other(string), + } + + variant location { + path, + /// Whether the parameter is exploded. + query(bool), + } + + record content-info { + media-type: string, + ty: type-id, + /// The description of the content's schema. + description: string, + } + + record body-info { + required: bool, + description: string, + contents: list, + /// The index in `contents` of the JSON content. + json: option, + } + + record response-info { + status: string, + description: string, + contents: list, + /// The index in `contents` of the JSON content. + json: option, + } + + /// The stage of the API a method is in, from `x-fern-availability`. + enum availability { + alpha, beta, preview, generally-available, deprecated, + } + + record method-info { + name: name, + group: group-id, + http-method: http-method, + path: string, + summary: string, + description: string, + deprecated: bool, + availability: option, + /// Whether to leave the method out of listings, from `x-forge-hidden`. + hidden: bool, + /// The sentence to show before calling the method, from + /// `x-forge-require-confirmation`, or none if it needs no + /// confirmation. + confirmation: option, + request: option, + /// The header parameters: an object type, with a field for each. + /// Its fields have no location. + headers: option, + body: option, + responses: list, + errors: list, + /// The extensions of the operation, or alias, that forge does not + /// read itself, in the order they are written. + extensions: list>, + } + + record model-info { + schema-name: string, + name: name, + group: option, + description: string, + deprecated: bool, + ty: type-id, + } + + enum number { + int, int8, int16, int32, int64, uint8, uint16, uint32, uint64, float, double, + } + + variant scalar { + unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, + } + + variant enum-value { + %string(string), + number(f64), + boolean(bool), + } + + record field-info { + wire-name: string, + name: name, + required: bool, + ty: type-id, + description: string, + deprecated: bool, + /// Whether the server sets the property, so a client doesn't send it. + read-only: bool, + /// Whether the value is a secret, such as a password or a token. + sensitive: bool, + /// The `default` of the property's schema. + default: option, + } + + record array-info { + elem: type-id, + /// The `maxItems`, or none if the array has no limit. + max-items: option, + } + + record object-info { + fields: list, + extra: option, + } + + record discriminator-info { + wire-name: string, + name: name, + cases: list>, + default: option, + } + + record union-info { + members: list, + /// Whether a value is at most one of the members. + exclusive: bool, + discriminator: option, + } + + variant type-info { + scalar(scalar), + %enum(list), + array(array-info), + object(object-info), + union(union-info), + intersection(list), + model(model-id), + } + + info: func() -> forge-info; + counts: func() -> forge-counts; + /// Every group, depth first. + groups: func() -> list; + /// The models at the top level, in no group. + models: func() -> list; + get-group: func(id: group-id) -> group-info; + get-method: func(id: method-id) -> method-info; + get-model: func(id: model-id) -> model-info; + get-type: func(id: type-id) -> type-info; + /// Where method `id` sends field `index` of the object type `object`, + /// or none if it is not one of its parameters. + get-location: func(id: method-id, object: type-id, index: u32) -> option; +} + +world plugin { + import forge; + use forge.{group-id}; + + /// A generated file: its path relative to the output directory, using + /// `/` as a separator, and its content. A file with no content is not + /// written. + record file { + path: string, + content: list, + } + + /// Generates files for the forge. Called exactly once. + export generate: func() -> result, string>; + /// Generates files for a group. Called exactly once for each group, in + /// parallel with other groups, each in an instance of its own. + export generate-group: func(group: group-id) -> result, string>; +} diff --git a/packages/forge/scripts/archive.mjs b/packages/forge/scripts/archive.mjs new file mode 100644 index 000000000..08f42daf0 --- /dev/null +++ b/packages/forge/scripts/archive.mjs @@ -0,0 +1,133 @@ +// Packs @cloudflare/forge, with the forge binary for every platform, into a +// tarball. That tarball is the package: scripts/publish.mjs publishes it, and +// another package can install it straight from the file: +// +// node scripts/archive.mjs [--artifacts ] [--platform ]... [--out ] +// +// npm install /path/to/cloudflare-forge-0.1.0.tgz +// # or, in package.json: "@cloudflare/forge": "file:../cloudflare-forge-0.1.0.tgz" +// +// The binaries come from , the crate's target directory unless you say +// otherwise (see findBinary in platforms.mjs); scripts/compile.mjs builds +// them all. Every platform needs one. +// +// --platform packs only the platforms it names, such as darwin-arm64, which +// is enough to try the package out locally; `cargo build --release +// --features wasm` builds the binary for this machine. The tarball's name +// then lists them, and scripts/publish.mjs won't publish it. +// +// It builds @cloudflare/forge/plugin into the package, as scripts/build.mjs +// does, which needs this directory's dev dependencies: pnpm install here. +// +// The tarball goes in dist/ unless --out says otherwise. + +import { execFileSync } from 'node:child_process'; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { + buildPlugin, + checkVersion, + checkWit, + display, + distDir, + fail, + findBinary, + npmDir, + platforms, + readJson, + repoDir, + tarball, +} from './platforms.mjs'; + +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +try { + const { values } = parseArgs({ + options: { + artifacts: { type: 'string', default: join(repoDir, 'target') }, + platform: { type: 'string', multiple: true }, + out: { type: 'string', default: distDir }, + }, + }); + archive(values.artifacts, values.platform, resolve(values.out)); +} catch (err) { + fail(err); +} + +/** + * Packs the binaries in `artifacts` for the platforms in `only`, or for all + * of them, into a tarball in `out`. + */ +function archive(artifacts, only, out) { + const version = checkVersion(); + checkWit(); + + const known = platforms.map((platform) => platform.id); + const unknown = only?.filter((id) => !known.includes(id)) ?? []; + if (unknown.length > 0) { + throw new Error(`unknown platform ${unknown.join(', ')}; forge supports ${known.join(', ')}`); + } + + const chosen = platforms.filter((platform) => !only || only.includes(platform.id)); + const found = chosen.map((platform) => ({ platform, bin: findBinary(artifacts, platform) })); + const missing = found.filter(({ bin }) => !bin).map(({ platform }) => platform.id); + if (missing.length > 0) { + throw new Error( + `no forge binary in ${artifacts} for ${missing.join(', ')}; ` + + `run node scripts/compile.mjs${only ? '' : ', or pack only some platforms with --platform'}`, + ); + } + + const stage = mkdtempSync(join(tmpdir(), 'forge-archive-')); + try { + mkdirSync(join(stage, 'bin')); + for (const name of ['forge.cjs', 'forge-plugin.mjs']) { + copyFileSync(join(npmDir, 'bin', name), join(stage, 'bin', name)); + chmodSync(join(stage, 'bin', name), 0o755); + } + // What plugins are built with: the WIT, and the library over it, built + // afresh so that a stale build in plugin/ can't get in. + mkdirSync(join(stage, 'plugin')); + for (const name of ['forge.wit', 'bundle.config.mjs']) { + copyFileSync(join(npmDir, 'plugin', name), join(stage, 'plugin', name)); + } + buildPlugin(join(stage, 'plugin')); + copyFileSync(join(npmDir, 'README.md'), join(stage, 'README.md')); + for (const { platform, bin } of found) { + const dest = join(stage, 'native', platform.id, platform.exe); + mkdirSync(join(dest, '..'), { recursive: true }); + copyFileSync(bin, dest); + chmodSync(dest, 0o755); + console.log(`added ${platform.id} from ${display(bin)}`); + } + + // The scripts and dev dependencies build the package; they have no use + // inside it, and the prepack guard would stop npm from packing it. + const pkg = readJson(join(npmDir, 'package.json')); + delete pkg.scripts; + delete pkg.devDependencies; + writeFileSync(join(stage, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`); + + const [{ filename }] = JSON.parse( + execFileSync(npm, ['pack', '--json', '--pack-destination', stage], { + cwd: stage, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + shell: npm.endsWith('.cmd'), + }), + ); + mkdirSync(out, { recursive: true }); + const dest = tarball( + out, + version, + chosen.map((platform) => platform.id), + ); + copyFileSync(join(stage, filename), dest); + console.log(`packed ${pkg.name}@${version} into ${display(dest)}`); + } finally { + rmSync(stage, { recursive: true, force: true }); + } +} diff --git a/packages/forge/scripts/build.mjs b/packages/forge/scripts/build.mjs new file mode 100644 index 000000000..b2d6d77ae --- /dev/null +++ b/packages/forge/scripts/build.mjs @@ -0,0 +1,23 @@ +// Builds the library plugins are written with, @cloudflare/forge/plugin: +// +// node scripts/build.mjs [--out ] +// +// It generates the TypeScript bindings of plugin/forge.wit into src/wit +// with jco, then compiles src/index.mts against them into index.mjs and +// index.d.mts in , plugin/ unless --out says otherwise. A WIT the +// library doesn't follow fails to compile. +// +// scripts/archive.mjs runs it too. It needs this directory's dev +// dependencies: run pnpm install here first. + +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { buildPlugin, fail } from './platforms.mjs'; + +try { + const { values } = parseArgs({ options: { out: { type: 'string' } } }); + buildPlugin(values.out && resolve(values.out)); +} catch (err) { + fail(err); +} diff --git a/packages/forge/scripts/compile.mjs b/packages/forge/scripts/compile.mjs new file mode 100644 index 000000000..43cf9844c --- /dev/null +++ b/packages/forge/scripts/compile.mjs @@ -0,0 +1,123 @@ +// Builds the release forge binary for every platform, or for the ones you +// name, into the crate's target directory: +// +// node scripts/compile.mjs [--platform ]... +// +// Each platform builds with its own tool (see `builder` in platforms.mjs): +// +// macOS cargo build needs a Mac and Xcode's command line tools +// Linux cross build needs Docker running; see github.com/cross-rs/cross +// Windows cargo xwin build downloads Microsoft's CRT and SDK the first time +// +// Install the tools with: +// +// rustup target add ... # the targets in platforms.mjs +// cargo install cross --git https://github.com/cross-rs/cross +// cargo install --locked cargo-xwin +// +// The binaries are built with the `wasm` feature, so they run plugins. +// +// Then pack them into the package with scripts/archive.mjs. + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { fail, platforms, repoDir } from './platforms.mjs'; + +try { + const { values } = parseArgs({ + options: { platform: { type: 'string', multiple: true } }, + }); + compile(values.platform); +} catch (err) { + fail(err); +} + +/** Builds the binaries for the platforms in `only`, or for all of them. */ +function compile(only) { + const known = platforms.map((platform) => platform.id); + const unknown = only?.filter((id) => !known.includes(id)) ?? []; + if (unknown.length > 0) { + throw new Error(`unknown platform ${unknown.join(', ')}; forge supports ${known.join(', ')}`); + } + const chosen = platforms.filter((platform) => !only || only.includes(platform.id)); + if (process.platform !== 'darwin' && chosen.some((platform) => platform.os === 'darwin')) { + throw new Error('macOS binaries build only on a Mac; leave them out with --platform'); + } + + const installed = new Set(run('rustup', ['target', 'list', '--installed'], { capture: true }).split('\n')); + const missing = chosen + .filter((platform) => platform.builder !== 'cross' && !installed.has(platform.target)) + .map((platform) => platform.target); + if (missing.length > 0) { + // cross brings its own toolchain for its targets. + throw new Error(`run rustup target add ${missing.join(' ')}`); + } + + for (const platform of chosen) { + console.log(`\n==> ${platform.id} (${platform.target}) with ${platform.builder}`); + // `wasm` gives `--target wasm://`, which runs plugins. + const args = ['build', '--release', '--locked', '--features', 'wasm', '--target', platform.target]; + switch (platform.builder) { + case 'cargo': + run('cargo', args); + break; + case 'cross': + run(tool('cross', 'cargo install cross --git https://github.com/cross-rs/cross'), args); + break; + case 'xwin': { + tool('cargo-xwin', 'cargo install --locked cargo-xwin'); + // Link the C runtime in, or the binary needs VCRUNTIME140.dll from + // the Visual C++ Redistributable, which many machines don't have. + const flags = `CARGO_TARGET_${platform.target.toUpperCase().replaceAll('-', '_')}_RUSTFLAGS`; + const env = { [flags]: `${process.env[flags] ?? ''} -C target-feature=+crt-static`.trim() }; + run('cargo', ['xwin', ...args], { env }); + break; + } + default: + throw new Error(`${platform.id} has no builder named ${platform.builder}`); + } + } + console.log(`\nbuilt ${chosen.map((platform) => platform.id).join(', ')}`); +} + +/** + * Returns the path to the program `name`, looking on PATH and then in + * cargo's bin directory, which `cargo install` puts things in but which + * isn't always on PATH. Throws with `install` as the fix if there is none. + */ +function tool(name, install) { + const cargoBin = join(process.env.CARGO_HOME ?? join(homedir(), '.cargo'), 'bin'); + const exe = process.platform === 'win32' ? `${name}.exe` : name; + const found = [...(process.env.PATH ?? '').split(delimiter), cargoBin] + .map((dir) => join(dir, exe)) + .find((path) => existsSync(path)); + if (!found) { + throw new Error(`${name} is not installed; run ${install}`); + } + return found; +} + +/** + * Runs `cmd` with `args` in the crate's root, with `env` added to the + * environment, and throws if it fails. Returns its standard output if + * `capture` is set. + */ +function run(cmd, args, { capture = false, env = {} } = {}) { + const result = spawnSync(cmd, args, { + cwd: repoDir, + env: { ...process.env, ...env }, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', + }); + if (result.error) { + throw new Error(`cannot run ${cmd}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(' ')} failed`); + } + return result.stdout; +} diff --git a/packages/forge/scripts/platforms.mjs b/packages/forge/scripts/platforms.mjs new file mode 100644 index 000000000..deff6a98c --- /dev/null +++ b/packages/forge/scripts/platforms.mjs @@ -0,0 +1,191 @@ +// What the release scripts share: the platforms forge ships for, and where +// things live. + +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** The directory of the @cloudflare/forge package. */ +export const npmDir = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** The root of the Rust crate. */ +export const repoDir = join(npmDir, '..', '..'); + +/** Where scripts/archive.mjs puts the tarball. */ +export const distDir = join(npmDir, 'dist'); + +/** + * The platforms forge ships for, each a binary in the package at + * native/-/. `os` and `cpu` take the values of Node's + * process.platform and process.arch, which the launcher looks the binary up + * by, and `target` is the Rust target that builds it. + * + * `builder` says how scripts/compile.mjs builds it: with cargo itself for + * macOS, which needs a Mac; with cross, in Docker, for Linux; and with + * cargo-xwin for Windows, since cross has no image for MSVC. + * + * Linux builds against musl so the binary is static and runs on any distro. + */ +export const platforms = [ + { os: 'darwin', cpu: 'arm64', target: 'aarch64-apple-darwin', builder: 'cargo' }, + { os: 'darwin', cpu: 'x64', target: 'x86_64-apple-darwin', builder: 'cargo' }, + { os: 'linux', cpu: 'arm64', target: 'aarch64-unknown-linux-musl', builder: 'cross' }, + { os: 'linux', cpu: 'x64', target: 'x86_64-unknown-linux-musl', builder: 'cross' }, + { os: 'win32', cpu: 'arm64', target: 'aarch64-pc-windows-msvc', builder: 'xwin' }, + { os: 'win32', cpu: 'x64', target: 'x86_64-pc-windows-msvc', builder: 'xwin' }, +].map((platform) => ({ + ...platform, + id: `${platform.os}-${platform.cpu}`, + exe: platform.os === 'win32' ? 'forge.exe' : 'forge', +})); + +/** + * Returns the path to the forge binary for `platform` in `artifacts`, or + * undefined if there is none. The binary is at //release/forge, + * where `cargo build --release --target ` puts it, or at + * //forge. The binary for this machine can also be at + * /release/forge, where plain `cargo build --release` puts it. + * When there are several, the newest wins. + */ +export function findBinary(artifacts, platform) { + const paths = [ + join(artifacts, platform.target, 'release', platform.exe), + join(artifacts, platform.target, platform.exe), + ]; + if (platform.os === process.platform && platform.cpu === process.arch) { + paths.push(join(artifacts, 'release', platform.exe)); + } + return paths.filter((path) => existsSync(path)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]; +} + +/** The package's copy of the WIT that wasm plugins are built against. */ +export const witPath = join(npmDir, 'plugin', 'forge.wit'); + +/** + * Returns the WIT in src/plugin/wasm.rs, which `forge::wasm::WIT` holds: + * the raw string passed to its `wit!` macro. + */ +export function witSource() { + const rs = readFileSync(join(repoDir, 'src', 'plugin', 'wasm.rs'), 'utf8'); + const wit = rs.match(/^wit!\(r#"([\s\S]*?)"#\);$/m); + if (!wit) { + throw new Error('cannot find the WIT in src/plugin/wasm.rs'); + } + return wit[1]; +} + +/** Throws unless plugin/forge.wit is the WIT in src/plugin/wasm.rs. */ +export function checkWit() { + let have; + try { + have = readFileSync(witPath, 'utf8'); + } catch { + // Missing counts as stale. + } + if (have !== witSource()) { + throw new Error(`${display(witPath)} is not the WIT in src/plugin/wasm.rs; run node scripts/wit.mjs`); + } +} + +/** + * Generates the bindings of plugin/forge.wit into src/wit, then compiles + * src/index.mts against them into index.mjs and index.d.mts in `out`, or in + * plugin/. Throws if either fails. + */ +export function buildPlugin(out = join(npmDir, 'plugin')) { + const bindings = join(npmDir, 'src', 'wit'); + rmSync(bindings, { recursive: true, force: true }); + node(devBin('@bytecodealliance/jco', 'jco'), [ + 'guest-types', + witPath, + '--world-name', + 'plugin', + '--quiet', + '-o', + bindings, + ]); + node(devBin('typescript', 'tsc'), ['-p', join(npmDir, 'tsconfig.json'), '--outDir', out]); + console.log(`built @cloudflare/forge/plugin into ${display(out)}`); +} + +/** + * Returns the path to the command `name` of the dev dependency `pkg`, which + * `pnpm install` in this directory installs. + */ +function devBin(pkg, name) { + const dir = join(npmDir, 'node_modules', ...pkg.split('/')); + let manifest; + try { + manifest = readJson(join(dir, 'package.json')); + } catch { + throw new Error(`${pkg} is not installed; run pnpm install in ${display(npmDir)}`); + } + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[name]; + if (!bin) { + throw new Error(`${pkg} has no command ${name}`); + } + return join(dir, bin); +} + +/** Runs the Node script `script` with `args`, and throws if it fails. */ +function node(script, args) { + const result = spawnSync(process.execPath, [script, ...args], { cwd: npmDir, stdio: 'inherit' }); + if (result.error) { + throw new Error(`cannot run ${script}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`${display(script)} ${args.join(' ')} failed`); + } +} + +/** Reads the JSON file at `path`. */ +export function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +/** Returns the version in Cargo.toml, which the package takes. */ +export function cargoVersion() { + const toml = readFileSync(join(repoDir, 'Cargo.toml'), 'utf8'); + const pkg = toml.match(/^\[package\]$([\s\S]*?)(?=^\[|(?![\s\S]))/m); + const version = pkg?.[1].match(/^version\s*=\s*"([^"]+)"/m); + if (!version) { + throw new Error('cannot find the package version in Cargo.toml'); + } + return version[1]; +} + +/** + * Throws unless package.json has the version in Cargo.toml. Returns the + * version. + */ +export function checkVersion() { + const version = cargoVersion(); + const { version: have } = readJson(join(npmDir, 'package.json')); + if (have !== version) { + throw new Error(`package.json is at ${have} but Cargo.toml is at ${version}; run node scripts/version.mjs`); + } + return version; +} + +/** + * Returns the path of the tarball scripts/archive.mjs makes in `out` for + * `version`, holding the binaries for `ids`. Only a tarball with every + * platform goes without a suffix, so only that name can be published. + */ +export function tarball(out, version, ids = platforms.map((platform) => platform.id)) { + const suffix = ids.length === platforms.length ? '' : `-${ids.join('-')}`; + return join(out, `cloudflare-forge-${version}${suffix}.tgz`); +} + +/** Returns `path` relative to the working directory if it's inside it. */ +export function display(path) { + const rel = relative(process.cwd(), path); + return rel.startsWith('..') || isAbsolute(rel) ? path : rel; +} + +/** Prints `err` and exits with failure. */ +export function fail(err) { + console.error(`error: ${err.message ?? err}`); + process.exit(1); +} diff --git a/packages/forge/scripts/publish.mjs b/packages/forge/scripts/publish.mjs new file mode 100644 index 000000000..5728caf17 --- /dev/null +++ b/packages/forge/scripts/publish.mjs @@ -0,0 +1,50 @@ +// Publishes the tarball scripts/archive.mjs made, which has the binary for +// every platform: +// +// node scripts/publish.mjs [npm publish options] +// +// Any options go to `npm publish`, such as --dry-run, --tag next, or +// --provenance. If the registry has this version already, it does nothing. + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { checkVersion, display, distDir, fail, tarball } from './platforms.mjs'; + +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const name = '@cloudflare/forge'; + +try { + const version = checkVersion(); + const file = tarball(distDir, version); + if (!existsSync(file)) { + throw new Error(`${display(file)} does not exist; run node scripts/compile.mjs, then node scripts/archive.mjs`); + } + if (published(version)) { + console.log(`${name}@${version} is published already`); + } else { + // Run from dist/ so that npm ignores this directory's package.json. + execFileSync(npm, ['publish', file, ...process.argv.slice(2)], { + cwd: distDir, + stdio: 'inherit', + shell: npm.endsWith('.cmd'), + }); + } +} catch (err) { + fail(err); +} + +/** Reports whether the registry has @cloudflare/forge at `version`. */ +function published(version) { + try { + const out = execFileSync(npm, ['view', `${name}@${version}`, 'version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: npm.endsWith('.cmd'), + }); + return out.trim() === version; + } catch { + // npm view fails when the package doesn't exist at all. + return false; + } +} diff --git a/packages/forge/scripts/version.mjs b/packages/forge/scripts/version.mjs new file mode 100644 index 000000000..0b92727ea --- /dev/null +++ b/packages/forge/scripts/version.mjs @@ -0,0 +1,14 @@ +// Sets the version of @cloudflare/forge to the version in Cargo.toml. +// +// node scripts/version.mjs + +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { cargoVersion, npmDir, readJson } from './platforms.mjs'; + +const path = join(npmDir, 'package.json'); +const pkg = readJson(path); +pkg.version = cargoVersion(); +writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`); +console.log(`@cloudflare/forge is now at ${pkg.version}`); diff --git a/packages/forge/scripts/wit.mjs b/packages/forge/scripts/wit.mjs new file mode 100644 index 000000000..76fc6380e --- /dev/null +++ b/packages/forge/scripts/wit.mjs @@ -0,0 +1,30 @@ +// Copies the WIT that wasm plugins are built against, from +// src/plugin/wasm.rs, into plugin/forge.wit: +// +// node scripts/wit.mjs +// +// Run it whenever the WIT in src/plugin/wasm.rs changes. scripts/archive.mjs +// refuses to pack a stale copy. + +import { readFileSync, writeFileSync } from 'node:fs'; + +import { display, fail, witPath, witSource } from './platforms.mjs'; + +try { + const wit = witSource(); + const have = (() => { + try { + return readFileSync(witPath, 'utf8'); + } catch { + return undefined; + } + })(); + if (have === wit) { + console.log(`${display(witPath)} is current`); + } else { + writeFileSync(witPath, wit); + console.log(`updated ${display(witPath)}`); + } +} catch (err) { + fail(err); +} diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts new file mode 100644 index 000000000..0fd8da742 --- /dev/null +++ b/packages/forge/src/index.ts @@ -0,0 +1,1030 @@ +/** + * Write forge plugins in TypeScript or JavaScript. + * + * A plugin generates files from the forge of an OpenAPI document: its + * groups, methods, models and types, as forge's Rust API has them. Build it + * with `forge-plugin build`, and run it with + * `forge openapi.json --target wasm://plugin.wasm --out sdk`. + * + * ```ts + * import { definePlugin } from "@cloudflare/forge/plugin"; + * + * export const { generate, generateGroup } = definePlugin({ + * generateGroup(group) { + * const names = group.methods.map((m) => m.name.camel()); + * return [{ path: `${group.name.kebab()}.txt`, content: names.join("\n") }]; + * }, + * }); + * ``` + * + * The forge stays in forge's process. Each part is fetched the first time + * a plugin reads it, and kept until the call ends, so reading a part again + * is free, and the same part is always the same object: compare groups, + * methods and models with `===`. + * + * @module + */ + +// This file is the source of plugin/index.js and plugin/index.d.ts, which +// scripts/build.mjs compiles it to. It is typed against the bindings jco +// generates from plugin/forge.wit, so a change to the WIT that this library +// doesn't follow fails to compile. Its exports are interfaces, and the +// classes behind them stay private, so the bindings never show in +// index.d.ts. + +import * as host from 'forge:plugin/forge@0.2.0'; +import type * as hostPlugin from 'forge:plugin/plugin@0.2.0'; + +// --------------------------------------------------------------------------- +// The API. + +/** A plugin: what {@link definePlugin} takes. */ +export interface Plugin { + /** Generates files for the forge. Called exactly once. */ + generate?(forge: Forge): Iterable; + /** + * Generates files for a group. Called exactly once for each group, in + * parallel with other groups, each in an instance of its own. Nothing a + * call keeps in module state is seen by the calls for other groups. + */ + generateGroup?(group: Group): Iterable; +} + +/** + * A file a plugin generates. + * + * forge writes it to the output directory, creating directories it needs, + * and skips a file with no content. It rejects a path that is absolute, + * has `.` or `..` parts, or that two files share. + */ +export interface File { + /** The path relative to the output directory, using `/` as a separator. */ + path: string; + /** The content: text, which is written as UTF-8, or bytes. */ + content: string | Uint8Array; +} + +/** What the entry module of a plugin exports, under these names. */ +export interface Exports { + generate(): Array<{ path: string; content: Uint8Array }>; + generateGroup(group: number): Array<{ path: string; content: Uint8Array }>; +} + +/** + * A name from the document, as lower-case words, which a plugin spells in + * the casing of its language. `XMLDocument` is "xml document", `getByID` is + * "get by id", and `ipv4_cidr` is "ipv4 cidr". + */ +export interface Name { + /** The lower-case words. */ + readonly words: readonly string[]; + /** Whether the name has no words: what was written has no letters or digits. */ + readonly isEmpty: boolean; + /** `petStore` */ + camel(): string; + /** `PetStore` */ + pascal(): string; + /** `pet_store` */ + snake(): string; + /** `pet-store` */ + kebab(): string; + /** Whether the names have the same words, however they were written. */ + equals(other: Name): boolean; + /** The words, separated by spaces. */ + toString(): string; +} + +/** The OpenAPI document, as an SDK sees it. */ +export interface Forge { + /** `info.version`. */ + readonly version: string; + /** `info.title`, `info.summary` and `info.description`, as paragraphs. */ + readonly description: string; + /** The URL of the first server, with its variables substituted. */ + readonly baseUrl: string; + /** Every group, depth first. Siblings are sorted by name. */ + readonly groups: readonly Group[]; + /** The models in no group: those methods in more than one top-level group use. */ + readonly models: readonly Model[]; +} + +/** A named collection of subgroups, methods and models. */ +export interface Group { + readonly name: Name; + /** A short description, or an empty string. */ + readonly description: string; + /** The names of the groups from the top level down to this one, ending with its own. */ + readonly path: readonly Name[]; + /** The group this one is in, or undefined at the top level. */ + readonly parent: Group | undefined; + /** The groups directly inside, sorted by name. */ + readonly children: readonly Group[]; + /** Every group below this one, depth first. */ + readonly descendants: readonly Group[]; + /** The methods directly inside, sorted by name. */ + readonly methods: readonly Method[]; + /** The models placed directly in this group: the deepest one every method using them shares. */ + readonly models: readonly Model[]; +} + +/** An operation an SDK exposes as a function. */ +export interface Method { + /** Unique within its group. */ + readonly name: Name; + readonly group: Group; + /** As it is sent, such as `GET`. */ + readonly httpMethod: string; + /** The path template, such as `/accounts/{account_id}`. */ + readonly path: string; + readonly summary: string; + readonly description: string; + /** + * Whether the operation is `deprecated`. It is apart from `availability`: + * a method can be deprecated without being `"deprecated"`, and the other + * way around. + */ + readonly deprecated: boolean; + /** The stage of the API the method is in, from `x-fern-availability`, or undefined if it doesn't say. */ + readonly availability: Availability | undefined; + /** + * Whether to leave the method out of listings of methods, such as a CLI's + * help, although it can still be called: `x-forge-hidden`. + */ + readonly hidden: boolean; + /** + * A sentence to show before calling the method, which changes or deletes + * something irreversibly, from `x-forge-require-confirmation`, or + * undefined if it needs no confirmation. An empty string needs + * confirmation, with no sentence of its own. + */ + readonly confirmation: string | undefined; + /** + * What a caller passes, or undefined for no parameters and no body: the + * body alone, an object of the path and query parameters, or an + * intersection of the parameters and the body. The `location` of a field + * says which parameter it is. + */ + readonly request: Type | undefined; + /** + * The header parameters, as an object with a field for each, sent as the + * header its wire name names, or undefined if there are none. They are + * apart from `request`, and their fields have no `location`. + */ + readonly headers: ObjectType | undefined; + readonly body: Body | undefined; + /** The `2XX` responses, sorted by status. */ + readonly responses: readonly Response[]; + /** Every other response, sorted by status with `default` last. */ + readonly errors: readonly Response[]; + /** + * The extensions of the operation, or of the alias the method comes + * from, that forge doesn't read itself, in the order they are written: + * keys starting with `x-`, for plugins to read as they need. + */ + readonly extensions: readonly (readonly [string, Json])[]; + /** The value of the extension `name`, such as `x-forge-params`, or undefined if there is none. */ + extension(name: string): Json | undefined; + /** + * Where `field` is sent, or undefined if it is not one of the method's + * parameters, as for a field sent in the body. + */ + location(field: Field): Location | undefined; +} + +/** + * Where a parameter is sent. For the query, `exploded` says whether each + * item of an array is its own parameter, as in `ids=1&ids=2`. + */ +export type Location = { readonly in: 'path' } | { readonly in: 'query'; readonly exploded: boolean }; + +/** The stage of the API a method is in, as `x-fern-availability` writes it. */ +export type Availability = 'alpha' | 'beta' | 'preview' | 'generally-available' | 'deprecated'; + +/** + * A JSON value that forge keeps without reading, as `JSON.parse` reads it. + * Numbers are 64-bit floats, as in forge. A JavaScript object lists keys + * that are integers first, so an object's keys may not be in the order + * they are written. + */ +export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }; + +export interface Body { + readonly required: boolean; + readonly description: string; + /** Each media type it can be sent as, sorted. */ + readonly contents: readonly Content[]; + /** The JSON content a client sends: `application/json`, or the first JSON media type. */ + readonly json: Content | undefined; +} + +export interface Response { + /** A code such as `200`, a range such as `4XX`, or `default`. */ + readonly status: string; + readonly description: string; + readonly contents: readonly Content[]; + readonly json: Content | undefined; +} + +/** A body in one media type. */ +export interface Content { + /** Such as `application/json`. */ + readonly mediaType: string; + /** Whether the media type is `application/json` or ends in `+json`. */ + readonly isJson: boolean; + /** + * The description of the content's schema, as a field's is read, or an + * empty string. The body's or response's own describes it as a whole. + */ + readonly description: string; + /** The type of the body; `unknown` for media types other than JSON. */ + readonly type: Type; +} + +/** A named schema from `components.schemas`, which an SDK declares as a type. */ +export interface Model { + /** Its key in `components.schemas`, such as `Account`, or a path such as `Tree/$defs/Node`. */ + readonly schemaName: string; + readonly name: Name; + /** The group it is placed in, or undefined at the top level. */ + readonly group: Group | undefined; + readonly description: string; + readonly deprecated: boolean; + readonly type: Type; + /** The schema name. */ + toString(): string; +} + +/** A property of an object type. */ +export interface Field { + /** The property's name as it is sent. */ + readonly wireName: string; + readonly name: Name; + readonly required: boolean; + readonly description: string; + readonly deprecated: boolean; + /** + * Whether the server sets the property, so a client doesn't send it, + * though the same model's field appears in responses: `readOnly`. + */ + readonly readOnly: boolean; + /** Whether the value is a secret, such as a password or a token, to keep out of logs: `x-sensitive`. */ + readonly sensitive: boolean; + /** The `default` of the property's schema, or undefined if it has none. */ + readonly default: Json | undefined; + readonly type: Type; +} + +/** + * The type of a value. Switch on `kind` to narrow it: + * + * ```ts + * function ts(type: Type): string { + * switch (type.kind) { + * case "array": return `${ts(type.elem)}[]`; + * // ... + * } + * } + * ``` + * + * A type that allows null is a union with a `null` scalar member. + */ +export type Type = ScalarType | EnumType | ArrayType | ObjectType | UnionType | IntersectionType | ModelType; + +/** A primitive, or any value (`unknown`). */ +export type ScalarType = + | { + readonly kind: 'scalar'; + readonly scalar: 'unknown' | 'null' | 'boolean' | 'string' | 'date-time' | 'date' | 'bytes' | 'binary'; + } + | { readonly kind: 'scalar'; readonly scalar: 'number'; readonly number: NumberFormat }; + +/** The size of a number, from its `format`. `int` and `double` have none. */ +export type NumberFormat = + | 'int' + | 'int8' + | 'int16' + | 'int32' + | 'int64' + | 'uint8' + | 'uint16' + | 'uint32' + | 'uint64' + | 'float' + | 'double'; + +/** One of a list of values: from `enum` or `const`. */ +export interface EnumType { + readonly kind: 'enum'; + /** At least one, all different, in order; never null. */ + readonly values: readonly (string | number | boolean)[]; +} + +export interface ArrayType { + readonly kind: 'array'; + readonly elem: Type; + /** The most elements the array can have, `maxItems`, or undefined if it has no limit. */ + readonly maxItems: number | undefined; +} + +/** An object: fields, other keys, both or neither. A map has only `extra`. */ +export interface ObjectType { + readonly kind: 'object'; + readonly fields: readonly Field[]; + /** The type of values under other keys, or undefined if it allows none. */ + readonly extra: Type | undefined; +} + +/** Any of the members: from `anyOf`, `oneOf`, or a list of types. */ +export interface UnionType { + readonly kind: 'union'; + /** At least two, all different. */ + readonly members: readonly Type[]; + /** + * Whether a value is at most one of the members: true for `oneOf`, a + * list of types, or null and another type; false for `anyOf`. + */ + readonly exclusive: boolean; + readonly discriminator: Discriminator | undefined; +} + +/** The property whose value says which member of a union a value is. */ +export interface Discriminator { + readonly wireName: string; + readonly name: Name; + /** Each value of the property, and the member it says a value is. */ + readonly cases: readonly (readonly [string, Type])[]; + /** The model of a value that matches no case. */ + readonly default: Model | undefined; +} + +/** All of the members: from `allOf`. */ +export interface IntersectionType { + readonly kind: 'intersection'; + readonly members: readonly Type[]; +} + +/** A `$ref` to a model. Read `model.type` to write it in place. */ +export interface ModelType { + readonly kind: 'model'; + readonly model: Model; +} + +/** + * Returns the exports of a plugin that calls `plugin`. Export them from the + * plugin's entry module: + * + * ```ts + * export const { generate, generateGroup } = definePlugin({ ... }); + * ``` + * + * Throw to fail: forge stops and prints the error's message. + */ +export function definePlugin(plugin: Plugin): Exports { + const exports = { + generate() { + return call(() => files(plugin.generate?.(new ForgeImpl()) ?? [])); + }, + generateGroup(id: host.GroupId) { + return call(() => files(plugin.generateGroup?.(new ForgeImpl().group(id)) ?? [])); + }, + } satisfies Pick; + return exports; +} + +// --------------------------------------------------------------------------- +// The implementation. + +// The API spells out what it shares with the WIT, so the bindings stay out +// of index.d.ts. These fail to compile if the two come apart. +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Check = T; +type _ScalarsMatch = Check>; +type _NumbersMatch = Check>; +type _AvailabilitiesMatch = Check>; +type _EnumValuesMatch = Check>; + +// The bindings' types say a missing option is undefined, but QuickJS gives +// null, so options are read through `opt`. + +/** Reads the JSON text the host sends for a value. */ +function parseJson(text: host.Json): Json { + return JSON.parse(text) as Json; +} + +/** Returns `value`, or undefined for null. */ +function opt(value: T | null | undefined): T | undefined { + return value ?? undefined; +} + +/** + * Runs `f`, turning whatever it throws into the string the WIT's error + * case carries. + */ +function call(f: () => T): T { + try { + return f(); + } catch (err) { + throw err instanceof Error ? err.message : String(err); + } +} + +/** Returns `files` as the host takes them, with their content in bytes. */ +function files(files: Iterable): hostPlugin.File[] { + return Array.from(files, (file) => { + if (typeof file?.path !== 'string') { + throw new Error(`a file needs a path, not ${JSON.stringify(file?.path)}`); + } + const content = file.content ?? ''; + return { path: file.path, content: typeof content === 'string' ? encode(content) : content }; + }); +} + +type Encoder = { encode(s: string): Uint8Array }; +const NativeEncoder = (globalThis as { TextEncoder?: new () => Encoder }).TextEncoder; + +/** + * Encodes `s` in UTF-8. QuickJS has no TextEncoder, so `s` is encoded here: + * a code unit at a time, into one buffer as large as UTF-8 can need, which + * is several times faster than building an array of bytes. A lone surrogate + * is written as its own three bytes. + */ +const encode: (s: string) => Uint8Array = NativeEncoder + ? (s) => new NativeEncoder().encode(s) + : (s) => { + const out = new Uint8Array(s.length * 3); + let n = 0; + for (let i = 0; i < s.length; i++) { + let c = s.charCodeAt(i); + if (c < 0x80) { + out[n++] = c; + } else if (c < 0x800) { + out[n++] = 0xc0 | (c >> 6); + out[n++] = 0x80 | (c & 63); + } else { + const next = c >= 0xd800 && c < 0xdc00 ? s.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next < 0xe000) { + c = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00); + i++; + out[n++] = 0xf0 | (c >> 18); + out[n++] = 0x80 | ((c >> 12) & 63); + } else { + out[n++] = 0xe0 | (c >> 12); + } + out[n++] = 0x80 | ((c >> 6) & 63); + out[n++] = 0x80 | (c & 63); + } + } + return out.slice(0, n); + }; + +/** Returns a list of ids, which comes as a Uint32Array, as an array. */ +function ids(list: Uint32Array | ArrayLike): number[] { + return Array.from(list); +} + +/** Returns a function that returns what `f` makes, made once on first use. */ +function lazy(f: () => T): () => T { + let done = false; + let value: T | undefined; + return () => { + if (!done) { + value = f(); + done = true; + } + return value as T; + }; +} + +/** Returns what `cache` has under `id`, making it with `make` if it has nothing. */ +function cached(cache: Map, id: number, make: () => T): T { + let value = cache.get(id); + if (value === undefined) { + value = make(); + cache.set(id, value); + } + return value; +} + +class NameImpl implements Name { + readonly words: readonly string[]; + + constructor(words: host.Name) { + this.words = Object.freeze(Array.from(words)); + } + + get isEmpty(): boolean { + return this.words.length === 0; + } + + camel(): string { + return this.words.map((word, i) => (i === 0 ? word : upperFirst(word))).join(''); + } + + pascal(): string { + return this.words.map(upperFirst).join(''); + } + + snake(): string { + return this.words.join('_'); + } + + kebab(): string { + return this.words.join('-'); + } + + equals(other: Name): boolean { + return this.words.length === other.words.length && this.words.every((word, i) => word === other.words[i]); + } + + toString(): string { + return this.words.join(' '); + } +} + +/** Upper-cases the first character of `word`, as Rust's `char::to_uppercase` does. */ +function upperFirst(word: string): string { + const first = word.codePointAt(0); + if (first === undefined) { + return word; + } + const ch = String.fromCodePoint(first); + return ch.toUpperCase() + word.slice(ch.length); +} + +class ForgeImpl implements Forge { + readonly version: string; + readonly description: string; + readonly baseUrl: string; + private readonly groupCache = new Map(); + private readonly methodCache = new Map(); + private readonly modelCache = new Map(); + private readonly typeCache = new Map(); + private readonly allGroups = lazy(() => ids(host.groups()).map((id) => this.group(id))); + private readonly topModels = lazy(() => ids(host.models()).map((id) => this.model(id))); + + constructor() { + const info = host.info(); + this.version = info.version; + this.description = info.description; + this.baseUrl = info.baseUrl; + } + + get groups(): readonly Group[] { + return this.allGroups(); + } + + get models(): readonly Model[] { + return this.topModels(); + } + + group(id: host.GroupId): GroupImpl { + return cached(this.groupCache, id, () => new GroupImpl(this, id)); + } + + method(id: host.MethodId): MethodImpl { + return cached(this.methodCache, id, () => new MethodImpl(this, id)); + } + + model(id: host.ModelId): ModelImpl { + return cached(this.modelCache, id, () => new ModelImpl(this, id)); + } + + type(id: host.TypeId): Type { + return cached(this.typeCache, id, () => makeType(this, id)); + } +} + +class GroupImpl implements Group { + private readonly info: () => { + name: Name; + description: string; + path: host.GroupId[]; + children: host.GroupId[]; + methods: host.MethodId[]; + models: host.ModelId[]; + }; + + constructor( + private readonly forge: ForgeImpl, + id: host.GroupId, + ) { + this.info = lazy(() => { + const g = host.getGroup(id); + return { + name: new NameImpl(g.name), + description: g.description, + path: ids(g.path), + children: ids(g.children), + methods: ids(g.methods), + models: ids(g.models), + }; + }); + } + + get name(): Name { + return this.info().name; + } + + get description(): string { + return this.info().description; + } + + get path(): readonly Name[] { + return this.info().path.map((id) => this.forge.group(id).name); + } + + get parent(): Group | undefined { + const path = this.info().path; + const parent = path[path.length - 2]; + return parent === undefined ? undefined : this.forge.group(parent); + } + + get children(): readonly Group[] { + return this.info().children.map((id) => this.forge.group(id)); + } + + get descendants(): readonly Group[] { + const out: Group[] = []; + const stack = [...this.children].reverse(); + for (let group = stack.pop(); group !== undefined; group = stack.pop()) { + out.push(group); + stack.push(...[...group.children].reverse()); + } + return out; + } + + get methods(): readonly Method[] { + return this.info().methods.map((id) => this.forge.method(id)); + } + + get models(): readonly Model[] { + return this.info().models.map((id) => this.forge.model(id)); + } +} + +/** How each HTTP method is sent, but `other`, which carries its own. */ +const httpMethods: Record, string> = { + get: 'GET', + put: 'PUT', + post: 'POST', + delete: 'DELETE', + options: 'OPTIONS', + head: 'HEAD', + patch: 'PATCH', + trace: 'TRACE', + query: 'QUERY', + connect: 'CONNECT', +}; + +class MethodImpl implements Method { + private readonly info: () => { + name: Name; + group: host.GroupId; + httpMethod: string; + path: string; + summary: string; + description: string; + deprecated: boolean; + availability: Availability | undefined; + hidden: boolean; + confirmation: string | undefined; + request: host.TypeId | undefined; + headers: host.TypeId | undefined; + body: Body | undefined; + responses: Response[]; + errors: Response[]; + extensions: (readonly [string, Json])[]; + }; + + constructor( + private readonly forge: ForgeImpl, + private readonly id: host.MethodId, + ) { + this.info = lazy(() => { + const m = host.getMethod(id); + const body = opt(m.body); + return { + name: new NameImpl(m.name), + group: m.group, + httpMethod: m.httpMethod.tag === 'other' ? m.httpMethod.val : httpMethods[m.httpMethod.tag], + path: m.path, + summary: m.summary, + description: m.description, + deprecated: m.deprecated, + availability: opt(m.availability), + hidden: m.hidden, + confirmation: opt(m.confirmation), + request: opt(m.request), + headers: opt(m.headers), + body: body && { + required: body.required, + description: body.description, + ...contents(forge, body.contents, body.json), + }, + responses: m.responses.map((r) => response(forge, r)), + errors: m.errors.map((r) => response(forge, r)), + extensions: m.extensions.map(([name, value]) => [name, parseJson(value)] as const), + }; + }); + } + + get name(): Name { + return this.info().name; + } + + get group(): Group { + return this.forge.group(this.info().group); + } + + get httpMethod(): string { + return this.info().httpMethod; + } + + get path(): string { + return this.info().path; + } + + get summary(): string { + return this.info().summary; + } + + get description(): string { + return this.info().description; + } + + get deprecated(): boolean { + return this.info().deprecated; + } + + get availability(): Availability | undefined { + return this.info().availability; + } + + get hidden(): boolean { + return this.info().hidden; + } + + get confirmation(): string | undefined { + return this.info().confirmation; + } + + get request(): Type | undefined { + const request = this.info().request; + return request === undefined ? undefined : this.forge.type(request); + } + + get headers(): ObjectType | undefined { + const headers = this.info().headers; + if (headers === undefined) { + return undefined; + } + const type = this.forge.type(headers); + return type.kind === 'object' ? type : undefined; + } + + get body(): Body | undefined { + return this.info().body; + } + + get responses(): readonly Response[] { + return this.info().responses; + } + + get errors(): readonly Response[] { + return this.info().errors; + } + + get extensions(): readonly (readonly [string, Json])[] { + return this.info().extensions; + } + + extension(name: string): Json | undefined { + return this.info().extensions.find(([n]) => n === name)?.[1]; + } + + location(field: Field): Location | undefined { + if (!(field instanceof FieldImpl) || field.forge !== this.forge) { + return undefined; + } + const location = opt(host.getLocation(this.id, field.object, field.index)); + switch (location?.tag) { + case undefined: + return undefined; + case 'path': + return { in: 'path' }; + case 'query': + return { in: 'query', exploded: location.val }; + default: { + const unknown: never = location; + throw new Error(`a location this library doesn't know: ${JSON.stringify(unknown)}`); + } + } + } +} + +function response(forge: ForgeImpl, r: host.ResponseInfo): Response { + return { status: r.status, description: r.description, ...contents(forge, r.contents, r.json) }; +} + +/** Returns `list` as `Content`s, and the one at index `json`. */ +function contents( + forge: ForgeImpl, + list: host.ContentInfo[], + json: number | undefined, +): { contents: Content[]; json: Content | undefined } { + const all = list.map((c) => new ContentImpl(forge, c)); + const index = opt(json); + return { contents: all, json: index === undefined ? undefined : all[index] }; +} + +class ContentImpl implements Content { + readonly mediaType: string; + readonly description: string; + private readonly ty: host.TypeId; + + constructor( + private readonly forge: ForgeImpl, + info: host.ContentInfo, + ) { + this.mediaType = info.mediaType; + this.description = info.description; + this.ty = info.ty; + } + + get isJson(): boolean { + const base = (this.mediaType.split(';')[0] ?? '').trim().toLowerCase(); + return base === 'application/json' || base.endsWith('+json'); + } + + get type(): Type { + return this.forge.type(this.ty); + } +} + +class ModelImpl implements Model { + private readonly info: () => { + schemaName: string; + name: Name; + group: host.GroupId | undefined; + description: string; + deprecated: boolean; + ty: host.TypeId; + }; + + constructor( + private readonly forge: ForgeImpl, + id: host.ModelId, + ) { + this.info = lazy(() => { + const m = host.getModel(id); + return { + schemaName: m.schemaName, + name: new NameImpl(m.name), + group: opt(m.group), + description: m.description, + deprecated: m.deprecated, + ty: m.ty, + }; + }); + } + + get schemaName(): string { + return this.info().schemaName; + } + + get name(): Name { + return this.info().name; + } + + get group(): Group | undefined { + const group = this.info().group; + return group === undefined ? undefined : this.forge.group(group); + } + + get description(): string { + return this.info().description; + } + + get deprecated(): boolean { + return this.info().deprecated; + } + + get type(): Type { + return this.forge.type(this.info().ty); + } + + toString(): string { + return this.schemaName; + } +} + +class FieldImpl implements Field { + readonly wireName: string; + readonly name: Name; + readonly required: boolean; + readonly description: string; + readonly deprecated: boolean; + readonly readOnly: boolean; + readonly sensitive: boolean; + readonly default: Json | undefined; + private readonly ty: host.TypeId; + + /** The object type the field is in, and its index there, which the host knows it by. */ + constructor( + readonly forge: ForgeImpl, + readonly object: host.TypeId, + readonly index: number, + info: host.FieldInfo, + ) { + this.wireName = info.wireName; + this.name = new NameImpl(info.name); + this.required = info.required; + this.description = info.description; + this.deprecated = info.deprecated; + this.readOnly = info.readOnly; + this.sensitive = info.sensitive; + const value = opt(info.default); + this.default = value === undefined ? undefined : parseJson(value); + this.ty = info.ty; + } + + get type(): Type { + return this.forge.type(this.ty); + } +} + +/** Returns the type `id`. Types it refers to are made when first read. */ +function makeType(forge: ForgeImpl, id: host.TypeId): Type { + const t = host.getType(id); + const type = (ty: host.TypeId) => forge.type(ty); + switch (t.tag) { + case 'scalar': + return t.val.tag === 'number' + ? { kind: 'scalar', scalar: 'number', number: t.val.val } + : { kind: 'scalar', scalar: t.val.tag }; + case 'enum': + return { kind: 'enum', values: t.val.map((value) => value.val) }; + case 'array': { + const { elem } = t.val; + const maxItems = opt(t.val.maxItems); + return { + kind: 'array', + get elem() { + return type(elem); + }, + // A u64 comes as a bigint; no array's limit needs more than a number. + maxItems: maxItems === undefined ? undefined : Number(maxItems), + }; + } + case 'object': { + const fields = t.val.fields.map((field, i) => new FieldImpl(forge, id, i, field)); + const extra = opt(t.val.extra); + return { + kind: 'object', + fields, + get extra() { + return extra === undefined ? undefined : type(extra); + }, + }; + } + case 'union': { + const members = ids(t.val.members); + const d = opt(t.val.discriminator); + const discriminator: Discriminator | undefined = d && { + wireName: d.wireName, + name: new NameImpl(d.name), + get cases() { + return d.cases.map(([value, ty]) => [value, type(ty)] as const); + }, + get default() { + const model = opt(d.default); + return model === undefined ? undefined : forge.model(model); + }, + }; + return { + kind: 'union', + get members() { + return members.map(type); + }, + exclusive: t.val.exclusive, + discriminator, + }; + } + case 'intersection': { + const members = ids(t.val); + return { + kind: 'intersection', + get members() { + return members.map(type); + }, + }; + } + case 'model': + return { kind: 'model', model: forge.model(t.val) }; + default: { + // A kind the WIT gains fails to compile here until it is handled. + const unknown: never = t; + throw new Error(`type ${id} is of a kind this library doesn't know: ${JSON.stringify(unknown)}`); + } + } +} diff --git a/packages/forge/src/ts.ts b/packages/forge/src/ts.ts new file mode 100644 index 000000000..9115509e8 --- /dev/null +++ b/packages/forge/src/ts.ts @@ -0,0 +1,779 @@ +/** + * Generates TypeScript files. Print lines of text and identifiers to a + * {@link File}, and it works out their imports when it renders. + * + * ```ts + * import { file, module } from "@cloudflare/forge/ts"; + * + * const models = module("pets/models.ts"); + * const f = file("pets/client.ts"); + * f.doc("A client of pets."); + * f`export interface ${f.ident("Client")} {`; + * f.indent(() => { + * f`get(): Promise<${models.ident("Pet").ty()}>;`; + * }); + * f`}`; + * ``` + * + * renders as: + * + * ```ts + * // Code generated by forge. DO NOT EDIT. + * /* eslint-disable *\/ + * + * import type { Pet } from "./models.js"; + * + * /** + * * A client of pets. + * *\/ + * export interface Client { + * get(): Promise; + * } + * ``` + * + * A `File` is a plugin's file as it is: return it from `generate` or + * `generateGroup`. One with nothing printed renders as nothing, so forge + * doesn't write it. + * + * This is forge's Rust `forge::ts`, and renders the same text, with tagged + * templates in place of its macros: `` f`…` ``, calling the file, for `p!`, `` frag`…` `` + * for `frag!`. + * + * @module + */ + +// This file is the source of plugin/ts.js and plugin/ts.d.ts. It reads +// the forge only through the types of index.ts, so it needs no host, and +// its tests run in Node. + +import type { Group, Model, Name } from './index.js'; + +/** One level of indentation. */ +const INDENT = ' '; + +/** The number of columns comments and imports are wrapped at. */ +const WIDTH = 80; + +/** + * The first lines of every file. They mark it as generated and turn off + * ESLint. + */ +export const PREAMBLE = '// Code generated by forge. DO NOT EDIT.\n/* eslint-disable */\n\n'; + +/** + * Something a line can print. Strings print as they are, and numbers, + * booleans and bigints as `String` writes them. An {@link Ident} prints + * under the name the file uses for it, and is imported if it comes from + * another module. A {@link Model} prints as its identifier, + * {@link ident.model}. An array prints each of its parts in turn, with + * nothing between them: use {@link join} for a list with separators. + * + * Anything else, such as `undefined` or an object of a plugin's own, is an + * error, rather than printing as `undefined` or `[object Object]`: use + * `String(x)` for a value of your own. + */ +export type Part = string | number | boolean | bigint | Ident | Model | readonly Part[]; + +/** The key that only modules have, which {@link isModule} looks for. */ +const MODULE: unique symbol = Symbol('forge.ts.module'); + +/** The key that only identifiers have, which {@link isIdent} looks for. */ +const IDENT: unique symbol = Symbol('forge.ts.ident'); + +/** + * A TypeScript module that files can import: a generated file or a + * package. Make one with {@link module}. + * + * A module ending in `.ts` is a generated file, named by its path from the + * output directory, such as `pets/models.ts`. Other generated files import + * it by a relative specifier ending in `.js`, such as `./models.js`, which + * is how TypeScript resolves it. Any other module is a package, such as + * `zod` or `node:fs`, and files import it by that specifier unchanged. + */ +export interface Module { + readonly [MODULE]: true; + /** The path, for a generated file, or the specifier, for a package. */ + readonly path: string; + /** Whether the module is a generated file. */ + readonly generated: boolean; + /** Returns the identifier called `name` declared in this module. */ + ident(name: string): Ident; + /** + * Returns the module `name` inside the directory `this`: + * `module("pets").sub("models.ts")` is `pets/models.ts`. + */ + sub(name: string): Module; + /** Whether `other` is the same module. */ + equals(other: Module): boolean; + /** The path. */ + toString(): string; +} + +/** + * A TypeScript identifier: a name and the module that declares it. A file + * that prints an identifier from another module imports it. Make one with + * {@link ident}, or a module's `ident`. + * + * It is immutable: `ty` and `as` return new identifiers. + */ +export interface Ident { + readonly [IDENT]: true; + /** The name, such as `Pet`, or a dotted one such as `Temporal.Instant`. */ + readonly name: string; + readonly module: Module; + /** + * Whether the identifier is only a type. Files import types with + * `import type`, or with `type` in front of the name when the same + * declaration also imports values. + */ + readonly typeOnly: boolean; + /** + * The name a file imports the identifier under, as in + * `import { Name as Alias }`, and uses for it. If the alias is already + * taken in the file, it still gets `$` appended. A file ignores the alias + * of its own identifiers, since it doesn't import them. + */ + readonly alias: string | undefined; + /** Returns the identifier marked as only a type. */ + ty(): Ident; + /** Returns the identifier imported under `alias`. */ + as(alias: string): Ident; + /** The name unchanged, such as `Temporal.Instant`; a file may print an alias instead. */ + toString(): string; +} + +/** + * A TypeScript file: its module and the lines printed to it. Make one with + * {@link file}. + * + * Print a line by calling the file, as in `` f`let pet: ${pet};` ``, and a + * comment with `doc`. The file renders them only once it knows every + * import, so it can give conflicting names aliases. + */ +export interface File { + /** + * Prints a line: the file's indentation, the template's parts, then a + * line break. + * + * ```ts + * f`let pet: ${pet};`; + * ``` + * + * If the parts print nothing but empty text, the line is blank, with no + * indentation. Only the start of the line is indented, so the template + * shouldn't contain line breaks. + */ + (strings: TemplateStringsArray, ...parts: Part[]): void; + /** + * Prints a line of `parts`, in turn, for parts made at run time: + * + * ```ts + * f("let pet: ", pet, ";"); + * f(); // a blank line + * ``` + */ + (...parts: Part[]): void; + /** The module of the file. */ + readonly module: Module; + /** The path of the file, relative to the output directory. */ + readonly path: string; + /** + * The rendered file: the preamble, the imports, then the lines printed to + * the file, or nothing if nothing was printed. + * + * The imports list packages first, then generated files, each sorted by + * specifier, with one declaration per module. A module whose names are + * all only types is imported with `import type`, and a name used both as + * a type and as a value is imported as a value. If an imported name + * clashes with one the file declares or an earlier import, it gets an + * alias with `$` appended. + */ + readonly content: string; + /** Returns an identifier declared in this file. */ + ident(name: string): Ident; + /** + * Calls `f` with the file indented one more level, so the lines and + * comments it prints get two more spaces. + */ + indent(f: (file: File) => void): void; + /** + * Prints `text` as a JSDoc comment at the file's indentation. It trims + * the text, prints each line after ` * `, and wraps words to fit in 80 + * columns, giving a word longer than a line a line of its own. It escapes + * `*\/` so the text can't end the comment early, and prints nothing for + * empty text. + */ + doc(text: string): void; + /** Returns the rendered file, as `content` describes it. */ + render(): string; +} + +/** What a module's and an identifier's methods share, so each is made once. */ +const moduleMethods: ThisType & Pick = { + ident(name) { + return ident(name, this); + }, + sub(name) { + return module(this.path === '' ? name : `${this.path}/${name}`); + }, + equals(other) { + return this.path === other.path; + }, + toString() { + return this.path; + }, +}; + +const identMethods: ThisType & Pick = { + ty() { + return ident(this.name, this.module, { typeOnly: true, alias: this.alias }); + }, + as(alias) { + return ident(this.name, this.module, { typeOnly: this.typeOnly, alias }); + }, + toString() { + return this.name; + }, +}; + +/** Returns a new module at `path`. */ +function makeModule(path: string): Module { + return Object.freeze( + Object.assign(Object.create(moduleMethods) as Module, { + [MODULE]: true as const, + path, + generated: path.endsWith('.ts'), + }), + ); +} + +/** Returns the module at `path`: a generated file if it ends in `.ts`, and a package otherwise. */ +export const module: { + (path: string): Module; + /** The output directory. Its `sub` modules are the top-level files. */ + readonly ROOT: Module; + /** + * Returns the directory that holds `group`'s files: the group's path, + * with each name in camelCase and prefixed with `_` if it starts with a + * digit. A group `list_all` inside `pet-store` gets `petStore/listAll`. + * Top-level files go in `module.ROOT` instead. + */ + group(group: Pick): Module; +} = Object.freeze( + Object.assign((path: string) => makeModule(path), { + ROOT: makeModule(''), + group(group: Pick): Module { + return makeModule(group.path.map((name) => identifier(name.camel())).join('/')); + }, + }), +); + +/** Returns the identifier `name`, declared in `module`. */ +export const ident: { + (name: string, module: Module, options?: { readonly typeOnly?: boolean; readonly alias?: string | undefined }): Ident; + /** + * Returns the identifier of `model`: its name in PascalCase, prefixed + * with `_` if it starts with a digit, declared in the `models.ts` of its + * group's directory (`module.group`), or of the root for a top-level + * model. Call `ty` on it to import it with `import type`. + * + * A plugin that names models differently makes its own identifier, and + * prints that one everywhere it refers to the model. + */ + model(model: Pick): Ident; +} = Object.freeze( + Object.assign( + ( + name: string, + module: Module, + options: { readonly typeOnly?: boolean; readonly alias?: string | undefined } = {}, + ): Ident => { + if (!isModule(module)) { + throw new TypeError(`${JSON.stringify(name)} needs a module, made with module()`); + } + return Object.freeze( + Object.assign(Object.create(identMethods) as Ident, { + [IDENT]: true as const, + name, + module, + typeOnly: options.typeOnly ?? false, + alias: options.alias, + }), + ); + }, + { + model(model: Pick): Ident { + const dir = model.group ? module.group(model.group) : module.ROOT; + return dir.sub('models.ts').ident(identifier(model.name.pascal())); + }, + }, + ), +); + +/** Whether `value` is a {@link Module}: one {@link module} made. */ +function isModule(value: unknown): value is Module { + return typeof value === 'object' && value !== null && (value as Partial)[MODULE] === true; +} + +/** Whether `value` is an {@link Ident}: one {@link ident} made. */ +function isIdent(value: unknown): value is Ident { + return typeof value === 'object' && value !== null && (value as Partial)[IDENT] === true; +} + +/** + * Returns the parts of a template, in order, to print as part of a line + * later: + * + * ```ts + * const list = frag`Promise<${pet}[]>`; + * f`list(): ${list};`; + * ``` + */ +export function frag(strings: TemplateStringsArray, ...parts: Part[]): Part[] { + return template(strings, parts); +} + +/** + * Returns `parts` with `sep` between each two of them, leaving out the + * parts that print nothing: + * + * ```ts + * f`const all = [${join(", ", ["a", "", "c"])}];`; // const all = [a, c]; + * ``` + */ +export function join(sep: string, parts: Iterable): Part[] { + const out: Element[] = []; + for (const part of parts) { + const printed = elements(part); + if (printed.length === 0) { + continue; + } + if (out.length > 0) { + appendText(out, sep); + } + append(out, printed); + } + return out; +} + +/** What a file keeps: its module, and what has been printed to it. */ +interface FileState { + readonly module: Module; + /** Text and identifiers, as {@link Element}s. */ + readonly body: Element[]; + /** How many levels to indent the lines printed next. */ + depth: number; +} + +/** + * Returns an empty file at `module`, or at the module with that path. + * + * ```ts + * const f = file("pets/client.ts"); + * f`export interface ${f.ident("Client")} {}`; + * ``` + */ +export function file(at: Module | string): File { + const state: FileState = { + module: typeof at === 'string' ? module(at) : isModule(at) ? at : badModule(at), + body: [], + depth: 0, + }; + // A file is a function that prints a line, with the rest of a File as + // its properties. It is frozen, so none of them can be replaced. + const print = (...args: Part[]): void => line(state, args); + const self = print as unknown as File; + const members: Omit = { + module: state.module, + get path() { + return state.module.path; + }, + get content() { + return render(state); + }, + ident: (name) => state.module.ident(name), + indent: (f) => { + state.depth++; + try { + f(self); + } finally { + state.depth--; + } + }, + doc: (text) => doc(state, text), + render: () => render(state), + }; + Object.defineProperties(print, Object.getOwnPropertyDescriptors(members)); + return Object.freeze(self); +} + +function badModule(at: unknown): never { + throw new TypeError(`a file needs a path or a module made with module(), not ${typeof at}`); +} + +/** Prints a line of what a file was called with. */ +function line(state: FileState, args: Part[]): void { + // A tagged template passes its strings as an array with `raw`, which no + // other array has. + const [first, ...rest] = args; + const printed = isTemplate(first) ? template(first, rest) : elements(args); + if (printed.length > 0) { + appendText(state.body, INDENT.repeat(state.depth)); + append(state.body, printed); + } + appendText(state.body, '\n'); +} + +/** Prints `text` as a JSDoc comment, as {@link File.doc} describes. */ +function doc(state: FileState, text: string): void { + const body = text.replaceAll('*/', '*\\/').trim(); + if (body === '') { + return; + } + const tab = INDENT.repeat(state.depth); + const prefix = `${tab} * `; + appendText(state.body, `${tab}/**\n`); + for (const line of body.replaceAll('\r\n', '\n').split('\n')) { + for (const wrapped of wrap(prefix, line)) { + appendText(state.body, wrapped); + appendText(state.body, '\n'); + } + } + appendText(state.body, `${tab} */\n`); +} + +/** Returns the rendered file, as {@link File.content} describes it. */ +function render(state: FileState): string { + if (state.body.length === 0) { + return ''; + } + const imports = importsOf(state); + const aliases = aliasesOf(state, imports); + let b = PREAMBLE; + const declarations = declarationsOf(state, imports, aliases); + if (declarations !== '') { + b += `${declarations}\n`; + } + for (const element of state.body) { + b += typeof element === 'string' ? element : nameOf(state, element, aliases); + } + return `${b.replace(/\n+$/, '')}\n`; +} + +/** Whether `id` is declared in the file or in no module, so the file doesn't import it. */ +function isLocal(state: FileState, id: Ident): boolean { + return id.module.equals(state.module) || id.module.path === ''; +} + +/** + * Returns the identifiers the file imports, by key, sorted with packages + * first, then generated files, each by specifier, then by name and alias, + * and the keys of those used as values. + */ +function importsOf(state: FileState): Imports { + const values = new Map(); + const types = new Map(); + for (const element of state.body) { + if (typeof element === 'string' || isLocal(state, element)) { + continue; + } + const imported = importOf(element); + (element.typeOnly ? types : values).set(imported.key, imported); + } + const sorted = [...values.values(), ...[...types.values()].filter((t) => !values.has(t.key))]; + const sortKey = (i: Import) => [rank(i.module), specifier(state, i.module), i.name, i.alias] as const; + sorted.sort((a, b) => compare(sortKey(a), sortKey(b))); + return { sorted, values: new Set(values.keys()) }; +} + +/** + * Picks the name the file uses for each import: its alias if it has one, + * otherwise its own name. It appends `$` until the name clashes with + * neither a name the file declares nor an earlier import, going through the + * imports in order. + */ +function aliasesOf(state: FileState, imports: Imports): Map { + const taken = new Set(); + for (const element of state.body) { + if (typeof element !== 'string' && isLocal(state, element)) { + taken.add(rootName(element.name)); + } + } + const aliases = new Map(); + for (const i of imports.sorted) { + let name = i.alias ?? i.name; + while (taken.has(name)) { + name += '$'; + } + taken.add(name); + aliases.set(i.key, name); + } + return aliases; +} + +/** Returns the name the file refers to `id` by. */ +function nameOf(state: FileState, id: Ident, aliases: Map): string { + if (isLocal(state, id)) { + return id.name; + } + const name = aliases.get(importOf(id).key)!; + const dot = id.name.indexOf('.'); + return dot === -1 ? name : `${name}${id.name.slice(dot)}`; +} + +/** Returns the import declarations of the file. */ +function declarationsOf(state: FileState, imports: Imports, aliases: Map): string { + let b = ''; + let previous: Module | undefined; + for (const group of chunkBy(imports.sorted, (a, b) => a.module.equals(b.module))) { + const module = group[0]!.module; + if (previous && rank(previous) !== rank(module)) { + b += '\n'; + } + previous = module; + const allTypes = group.every((i) => !imports.values.has(i.key)); + const names = group.map((i) => { + let name = i.name; + const alias = aliases.get(i.key)!; + if (alias !== name) { + name = `${name} as ${alias}`; + } + if (!allTypes && !imports.values.has(i.key)) { + name = `type ${name}`; + } + return name; + }); + const head = allTypes ? 'import type ' : 'import '; + b += importDeclaration(head, names, ` from "${specifier(state, module)}";`); + } + return b; +} + +/** + * Returns how the file imports `module`: a package by its specifier, and a + * generated file by its path relative to the file, ending with `.js`. + */ +function specifier(state: FileState, module: Module): string { + if (!module.generated) { + return module.path; + } + const slash = state.module.path.lastIndexOf('/'); + const from = slash === -1 ? [] : state.module.path.slice(0, slash).split('/'); + const to = module.path.split('/'); + let shared = 0; + while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) { + shared++; + } + const joined = [...Array(from.length - shared).fill('..'), ...to.slice(shared)].join('/'); + const spec = `${joined.endsWith('.ts') ? joined.slice(0, -3) : joined}.js`; + return spec.startsWith('../') ? spec : `./${spec}`; +} + +/** An identifier a file imports: the root of its name, without whether it is only a type. */ +interface Import { + readonly key: string; + readonly name: string; + readonly module: Module; + readonly alias: string | undefined; +} + +/** The identifiers a file imports. */ +interface Imports { + /** Each import once, sorted. */ + readonly sorted: Import[]; + /** The keys of those used as values, and not only as types. */ + readonly values: Set; +} + +/** Returns what `id` is imported as. */ +function importOf(id: Ident): Import { + const name = rootName(id.name); + const key = JSON.stringify([id.module.path, name, id.alias ?? null]); + return { key, name, module: id.module, alias: id.alias }; +} + +/** Returns the name a dotted name such as `Temporal.Instant` is imported by. */ +function rootName(name: string): string { + const dot = name.indexOf('.'); + return dot === -1 ? name : name.slice(0, dot); +} + +/** Puts packages before generated files. */ +function rank(module: Module): number { + return module.generated ? 1 : 0; +} + +/** + * Compares sort keys element by element, as Rust compares tuples: strings + * by code unit, and an absent alias before any alias. + */ +function compare(a: readonly unknown[], b: readonly unknown[]): number { + for (let i = 0; i < a.length; i++) { + const [x, y] = [a[i], b[i]]; + if (x === y) { + continue; + } + if (x === undefined) { + return -1; + } + if (y === undefined) { + return 1; + } + return (x as string | number) < (y as string | number) ? -1 : 1; + } + return 0; +} + +/** Splits `items` into runs of neighbours that `same` says go together. */ +function chunkBy(items: readonly T[], same: (a: T, b: T) => boolean): T[][] { + const chunks: T[][] = []; + for (const item of items) { + const last = chunks[chunks.length - 1]; + if (last && same(last[last.length - 1]!, item)) { + last.push(item); + } else { + chunks.push([item]); + } + } + return chunks; +} + +/** + * Returns an import declaration: on one line when it fits in 80 columns or + * has one name, and with a name on each line otherwise. + */ +function importDeclaration(head: string, names: string[], tail: string): string { + const line = `${head}{ ${names.join(', ')} }${tail}`; + if (names.length === 1 || line.length <= WIDTH) { + return `${line}\n`; + } + return `${head}{\n${names.map((name) => `${INDENT}${name},\n`).join('')}}${tail}\n`; +} + +/** + * Returns `line` after `prefix`, with words wrapped to fit in 80 columns. + * Leading spaces, such as a list item's indent, are repeated on each + * wrapped line. + */ +function wrap(prefix: string, line: string): string[] { + line = line.replace(/[ \t]+$/, ''); + const lead = /^[ \t]*/.exec(line)![0]; + const words = line.split(/\s+/).filter((word) => word !== ''); + const [first, ...rest] = words; + if (first === undefined) { + return [prefix.replace(/ +$/, '')]; + } + const lines: string[] = []; + let current = `${prefix}${lead}${first}`; + let columns = width(current); + for (const word of rest) { + const w = width(word); + if (columns + 1 + w <= WIDTH) { + current += ` ${word}`; + columns += 1 + w; + continue; + } + lines.push(current); + current = `${prefix}${lead}${word}`; + columns = width(current); + } + lines.push(current); + return lines; +} + +/** The number of characters in `s`, counting each code point once, as Rust's `chars` does. */ +function width(s: string): number { + let n = 0; + for (const _ of s) { + n++; + } + return n; +} + +/** Prefixes `s` with `_` if it starts with a digit, so it's a valid identifier. */ +function identifier(s: string): string { + return /^[0-9]/.test(s) ? `_${s}` : s; +} + +/** + * What parts print as: text, or an identifier the file resolves when it + * renders. Adjacent text is always joined into one element, and empty text + * never kept, so nothing printed is no elements. + */ +type Element = string | Ident; + +/** Returns a template's strings and parts, interleaved, as elements. */ +function template(strings: TemplateStringsArray, parts: Part[]): Element[] { + const out: Element[] = []; + strings.forEach((s, i) => { + appendText(out, s); + if (i < parts.length) { + append(out, parts[i]); + } + }); + return out; +} + +/** Whether `part` is the strings of a tagged template, rather than a part. */ +function isTemplate(part: Part | undefined): part is TemplateStringsArray { + return Array.isArray(part) && Object.prototype.hasOwnProperty.call(part, 'raw'); +} + +/** Returns what `part` prints as. */ +function elements(part: Part): Element[] { + const out: Element[] = []; + append(out, part); + return out; +} + +/** Appends text to `out`. */ +function appendText(out: Element[], s: string): void { + if (s === '') { + return; + } + const last = out.length - 1; + if (typeof out[last] === 'string') { + out[last] += s; + } else { + out.push(s); + } +} + +/** Appends what `part` prints as to `out`, or throws if it is not a {@link Part}. */ +function append(out: Element[], part: Part | undefined): void { + switch (typeof part) { + case 'string': + appendText(out, part); + return; + case 'number': + case 'boolean': + case 'bigint': + appendText(out, String(part)); + return; + case 'object': + if (Array.isArray(part)) { + for (const p of part as readonly Part[]) { + append(out, p); + } + return; + } + if (isIdent(part)) { + out.push(part); + return; + } + if (part !== null && isModel(part)) { + out.push(ident.model(part)); + return; + } + break; + } + throw new TypeError(`cannot print ${part === null ? 'null' : typeof part}`); +} + +/** Whether `part` is a {@link Model}, which has no `print` of its own. */ +function isModel(part: object): part is Model { + const model = part as Partial; + return ( + typeof model.schemaName === 'string' && typeof (model.name as Partial | undefined)?.pascal === 'function' + ); +} diff --git a/packages/forge/test/ts.test.js b/packages/forge/test/ts.test.js new file mode 100644 index 000000000..4a4d1c8c0 --- /dev/null +++ b/packages/forge/test/ts.test.js @@ -0,0 +1,412 @@ +// The tests of forge's Rust `forge::ts`, in src/plugin/ts/tests.rs, run +// against @cloudflare/forge/ts. They expect the same text, so the two print +// TypeScript the same way. +// +// npm test + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { PREAMBLE, file, frag, ident, join, module } from '../plugin/ts.js'; + +/** + * Returns the text of parts that frag or join made, with each identifier's + * name unchanged, as Rust's Display of a Fragment writes it. + */ +function text(parts) { + return parts.map(String).join(''); +} + +/** Returns the content of `f` without the preamble, which it must start with. */ +function content(f) { + const rendered = f.content; + assert.ok(rendered.startsWith(PREAMBLE), `${f.path} does not start with the preamble:\n${rendered}`); + return rendered.slice(PREAMBLE.length); +} + +function lines(...lines) { + return `${lines.join('\n')}\n`; +} + +test('file', () => { + const models = module('pets/models.ts'); + const runtime = module('_/index.ts'); + const f = file(module('pets/client.ts')); + assert.equal(f.path, 'pets/client.ts'); + + f.doc('A client of pets.'); + f`export interface ${f.ident('Client')} {`; + f` get(): Promise<${models.ident('Pet').ty()}>;`; + f` list(): ${frag`Promise<${models.ident('Pet').ty()}[]>`};`; + f`}`; + f``; + f`const codec = ${models.ident('PetCodec')};`; + f`const transport = new ${runtime.ident('Transport')}();`; + f`const read = ${module('node:fs/promises').ident('readFile')};`; + f`type S = ${module('@scope/schema').ident('Schema').ty()};`; + // A name used as both a value and a type is imported as a value. + f`type T = ${runtime.ident('Transport').ty()};`; + f`let now: ${module('temporal').ident('Temporal.Instant').ty()};`; + + assert.equal( + content(f), + lines( + `import type { Schema } from "@scope/schema";`, + `import { readFile } from "node:fs/promises";`, + `import type { Temporal } from "temporal";`, + '', + // Generated files are imported with .js. + `import { Transport } from "../_/index.js";`, + `import { type Pet, PetCodec } from "./models.js";`, + '', + '/**', + ' * A client of pets.', + ' */', + 'export interface Client {', + ' get(): Promise;', + ' list(): Promise;', + '}', + '', + 'const codec = PetCodec;', + 'const transport = new Transport();', + 'const read = readFile;', + 'type S = Schema;', + 'type T = Transport;', + 'let now: Temporal.Instant;', + ), + ); +}); + +test('a file is a function that prints a line', () => { + const f = file('pets/client.ts'); + assert.equal(typeof f, 'function'); + // It has a File's module, getters and methods. + assert.equal(f.module.path, 'pets/client.ts'); + assert.equal(f.path, 'pets/client.ts'); + assert.equal(f.ident('Client').module, f.module); + // And it is a whole function, with what its type says it has from + // Function. + assert.ok(f instanceof Function); + assert.equal(typeof f.bind, 'function'); + const print = f.bind(null, 'let bound = '); + print(1, ';'); + f.call(null, 'let called = ', 2, ';'); + // A function's own `name` and `length` don't get in the way of rendering, + // which resolves names. + f`let pet: ${module('pets/models.ts').ident('Pet').ty()};`; + assert.equal( + content(f), + lines(`import type { Pet } from "./models.js";`, '', 'let bound = 1;', 'let called = 2;', 'let pet: Pet;'), + ); + // Files don't share what they print. + assert.equal(file('a.ts').content, ''); + // A file is frozen: nothing it has can be replaced. + assert.throws(() => { + 'use strict'; + f.module = module('b.ts'); + }, TypeError); + assert.ok(Object.isFrozen(f)); +}); + +test('only modules and identifiers made here are ones', () => { + // Something shaped like a module or an identifier, but not made by + // module or ident, has no brand, so it is neither. + const fake = { name: 'Pet', module: module('m.ts'), typeOnly: false, alias: undefined }; + assert.throws(() => file('a.ts')`let x: ${fake};`, /cannot print object/); + assert.throws(() => file({ path: 'a.ts' }), /needs a path or a module made with module\(\)/); + assert.throws(() => ident('Pet', { path: 'm.ts' }), /needs a module, made with module\(\)/); + // Ones made here are immutable. + const pet = module('m.ts').ident('Pet'); + assert.ok(Object.isFrozen(pet) && Object.isFrozen(pet.module)); + assert.equal(module.ROOT.path, ''); + assert.ok(Object.isFrozen(module) && Object.isFrozen(ident)); +}); + +test('aliases', () => { + const f = file('a/b.ts'); + // The file's own `Pet` keeps the name, and each later `Pet` gets another `$`. + f`interface ${f.ident('Pet')} {}`; + f`let x: ${module('c.ts').ident('Pet').ty()};`; + f`let y: ${module('d.ts').ident('Pet').ty()};`; + f`let z: ${module('d.ts').ident('Owner').ty()};`; + assert.equal( + content(f), + lines( + `import type { Pet as Pet$ } from "../c.js";`, + `import type { Owner, Pet as Pet$$ } from "../d.js";`, + '', + 'interface Pet {}', + 'let x: Pet$;', + 'let y: Pet$$;', + 'let z: Owner;', + ), + ); +}); + +test('ident helpers', () => { + const pet = module('pets/models.ts').ident('Pet'); + const fields = (id) => ({ name: id.name, module: id.module.path, typeOnly: id.typeOnly, alias: id.alias }); + const plain = { name: 'Pet', module: 'pets/models.ts', typeOnly: false, alias: undefined }; + assert.deepEqual(fields(pet.as('PetModel')), { ...plain, alias: 'PetModel' }); + assert.deepEqual(fields(pet.ty()), { ...plain, typeOnly: true }); + assert.deepEqual(fields(pet.ty().as('PetModel')), { ...plain, typeOnly: true, alias: 'PetModel' }); + // They return new identifiers, and leave this one alone. + assert.deepEqual(fields(pet), plain); + + const f = file('pets/client.ts'); + f`let a: ${pet.ty()};`; + assert.equal(content(f), lines(`import type { Pet } from "./models.js";`, '', 'let a: Pet;')); +}); + +test('custom aliases', () => { + const f = file('pets/client.ts'); + const models = module('pets/models.ts'); + f`interface ${f.ident('Client').as('Ignored')} {}`; + f`let a: ${models.ident('Pet').ty().as('PetModel')};`; + // The same name can be imported under several aliases. + f`let b: ${models.ident('Pet').ty().as('Animal')};`; + f`let c: ${models.ident('Pet').ty()};`; + // An alias that is taken still gets "$" added. + f`let d: ${module('zod').ident('z').as('Client')}.string();`; + f`let e: ${module('temporal').ident('Temporal.Instant').ty().as('T')};`; + assert.equal( + content(f), + lines( + `import type { Temporal as T } from "temporal";`, + `import { z as Client$ } from "zod";`, + '', + `import type { Pet, Pet as Animal, Pet as PetModel } from "./models.js";`, + '', + 'interface Client {}', + 'let a: PetModel;', + 'let b: Animal;', + 'let c: Pet;', + 'let d: Client$.string();', + 'let e: T.Instant;', + ), + ); +}); + +test('doc', () => { + const f = file('a.ts'); + f.doc('Returns a pet.\n\nIt is looked up by its ID, and */ is escaped.\n - an indented item\n'); + f`function get(): void {}`; + f`interface Client {`; + f.indent(() => { + f.doc(' Only one line. '); + f`list(): void;`; + f.doc('Is the second.'); + f`get(): void;`; + }); + f`}`; + // A comment with nothing in it is not printed. + f.indent((f) => f.indent((f) => f.doc(' \n '))); + assert.equal( + content(f), + lines( + '/**', + ' * Returns a pet.', + ' *', + String.raw` * It is looked up by its ID, and *\/ is escaped.`, + ' * - an indented item', + ' */', + 'function get(): void {}', + 'interface Client {', + ' /**', + ' * Only one line.', + ' */', + ' list(): void;', + ' /**', + ' * Is the second.', + ' */', + ' get(): void;', + '}', + ), + ); +}); + +test('doc wraps', () => { + const f = file('a.ts'); + f.indent(() => + f.indent(() => + f.doc( + 'The URL the paths of the API are relative to, which defaults to the first server of the description.\n' + + ' - an item of a list whose words are wrapped under it when they do not fit on the line\n' + + 'See https://developers.cloudflare.com/fundamentals/api/reference/a-very-long-path-that-does-not-fit/ for more.', + ), + ), + ); + const got = content(f); + assert.equal( + got, + lines( + ' /**', + ' * The URL the paths of the API are relative to, which defaults to the first', + ' * server of the description.', + ' * - an item of a list whose words are wrapped under it when they do not', + ' * fit on the line', + ' * See', + ' * https://developers.cloudflare.com/fundamentals/api/reference/a-very-long-path-that-does-not-fit/', + ' * for more.', + ' */', + ), + ); + for (const line of got.split('\n').filter((line) => !line.includes('https://'))) { + assert.ok(line.length <= 80, `${JSON.stringify(line)} is longer than 80 columns`); + } +}); + +test('indent', () => { + const f = file('a.ts'); + f`a {`; + f.indent(() => { + f`b {`; + f.indent(() => { + f`c;`; + // A blank line has no indentation. + f``; + f(); + f('', ''); + }); + f`}`; + }); + f`}`; + assert.equal(content(f), lines('a {', ' b {', ' c;', '', '', '', ' }', '}')); +}); + +test('imports break', () => { + const f = file('a.ts'); + const m = module('some/long/module/path.ts'); + for (const name of ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf']) { + f`let ${name.toLowerCase()} = ${m.ident(name)};`; + } + assert.ok( + content(f).startsWith( + lines( + 'import {', + ' Alpha,', + ' Bravo,', + ' Charlie,', + ' Delta,', + ' Echo,', + ' Foxtrot,', + ' Golf,', + `} from "./some/long/module/path.js";`, + ), + ), + ); + + // A file with nothing printed renders as nothing, so forge doesn't write it. + assert.equal(file('empty.ts').content, ''); +}); + +test('parts', () => { + const models = module('models.ts'); + const pet = models.ident('Pet').ty(); + const f = file('a.ts'); + f`let a: ${pet};`; + // Numbers, booleans, bigints and joins print as String writes them. + f`let b = [${join(', ', [1, 2, 3])}];`; + f`let c = ${1.5}, d = ${true}, e = ${10n};`; + // A value of a plugin's own prints as its string. + const version = { toString: () => 'v2' }; + f`const version = "${String(version)}";`; + // A fragment made before the file carries its imports. + const owner = frag`${models.ident('Owner')} | null`; + f`let g: ${owner};`; + // A join of identifiers imports each. + f`type H = ${join(' | ', [models.ident('A'), models.ident('B').ty()])};`; + // p also takes parts made at run time, and an array prints its parts in + // turn, even first, where it isn't taken for a template's strings. + f('let i: ', [pet, '[]'], ';'); + f(['let k = ', 1], ';'); + f`let j: ${['Array<', [models.ident('Owner').ty()], '>']};`; + assert.equal( + content(f), + lines( + `import { A, type B, Owner, type Pet } from "./models.js";`, + '', + 'let a: Pet;', + 'let b = [1, 2, 3];', + 'let c = 1.5, d = true, e = 10;', + `const version = "v2";`, + 'let g: Owner | null;', + 'type H = A | B;', + 'let i: Pet[];', + 'let k = 1;', + 'let j: Array;', + ), + ); +}); + +test('only parts print', () => { + const f = file('a.ts'); + // These would print as "undefined", "null" or "[object Object]", even + // inside an array. + for (const [part, message] of [ + [undefined, /cannot print undefined/], + [null, /cannot print null/], + [{ name: 'x' }, /cannot print object/], + [['a', undefined], /cannot print undefined/], + ]) { + assert.throws(() => f`let x = ${part};`, message); + } + assert.equal(f.content, ''); +}); + +test('joins leave out empty parts', () => { + assert.equal(text(join(', ', ['a', '', 'b', ''])), 'a, b'); + assert.equal(text(join(', ', ['', ''])), ''); + const pet = module('models.ts').ident('Pet'); + // A part that prints nothing is left out, an empty array among them. + assert.equal(text(frag`[${join(', ', [1, '', pet, frag``, [['']]])}]`), '[1, Pet]'); + assert.deepEqual(join(', ', []), []); +}); + +test('display', () => { + // toString writes names unchanged, ignoring aliases. + const pet = module('models.ts').ident('Temporal.Instant').as('T'); + assert.equal(pet.toString(), 'Temporal.Instant'); + assert.equal(text(frag`Promise<${pet}[]>`), 'Promise'); +}); + +test('models', () => { + // Stand-ins for the forge's groups and models: what module.group and + // ident.model read of them. + const name = (...words) => ({ + words, + camel: () => words.map((w, i) => (i ? w[0].toUpperCase() + w.slice(1) : w)).join(''), + pascal: () => words.map((w) => w[0].toUpperCase() + w.slice(1)).join(''), + }); + const petStore = { path: [name('pet', 'store')] }; + const listAll = { path: [name('pet', 'store'), name('list', 'all')] }; + const owner = { schemaName: 'pet_owner', name: name('pet', 'owner'), group: listAll, toString: () => 'pet_owner' }; + const twoFa = { schemaName: '2fa', name: name('2fa'), group: undefined, toString: () => '2fa' }; + + assert.equal(module.ROOT.sub('models.ts').path, 'models.ts'); + assert.equal(module.group(petStore).path, 'petStore'); + assert.equal(module.group(listAll).sub('client.ts').path, 'petStore/listAll/client.ts'); + assert.equal(ident.model(owner).module.path, 'petStore/listAll/models.ts'); + assert.equal(ident.model(owner).name, 'PetOwner'); + assert.equal(ident.model(twoFa).module.path, 'models.ts'); + assert.equal(ident.model(twoFa).name, '_2fa'); + + const f = file('petStore/client.ts'); + // A model prints as its identifier. + f`let owner: ${owner};`; + f`let factor: ${ident.model(twoFa).ty()};`; + // A plugin that names a model differently prints its own identifier. + const errOwner = ident.model(owner).ty(); + f`let failure: ${ident(`Err${errOwner.name}`, errOwner.module, { typeOnly: true })};`; + assert.equal( + content(f), + lines( + `import type { _2fa } from "../models.js";`, + `import { type ErrPetOwner, PetOwner } from "./listAll/models.js";`, + '', + 'let owner: PetOwner;', + 'let factor: _2fa;', + 'let failure: ErrPetOwner;', + ), + ); +}); diff --git a/packages/forge/test/tsconfig.json b/packages/forge/test/tsconfig.json new file mode 100644 index 000000000..e084a92be --- /dev/null +++ b/packages/forge/test/tsconfig.json @@ -0,0 +1,13 @@ +{ + // Type-checks test/types.ts against the built plugin/ts.d.ts. + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "es2022", + "lib": ["es2022"], + "types": [], + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "files": ["types.ts"] +} diff --git a/packages/forge/test/types.ts b/packages/forge/test/types.ts new file mode 100644 index 000000000..ff2a57d17 --- /dev/null +++ b/packages/forge/test/types.ts @@ -0,0 +1,69 @@ +// Checks the types of @cloudflare/forge/ts against how it behaves. npm test +// compiles it, and fails on any error. Each expected error marks something +// the types must reject, and fails the test if they accept it. + +import { type File, type Ident, type Module, type Part, file, frag, ident, join, module } from '../plugin/ts.js'; + +const f: File = file('a.ts'); +const m: Module = module('m.ts'); +const pet: Ident = m.ident('Pet'); + +// A file prints a line when called, as a tagged template or with parts. +f`let a: ${pet};`; +f('let b: ', pet, ';'); +f(); +f([pet, '[]'], frag`${1}`, join(', ', [true, 2n])); + +// @ts-expect-error A call prints, and returns nothing. +const printed: string = f``; + +// Parts are strings, numbers, booleans, bigints, identifiers, models and +// arrays of them, and nothing else, at any depth. +const parts: Part[] = ['a', 1, true, 2n, pet, [pet, ['b']]]; +// @ts-expect-error +f`${undefined}`; +// @ts-expect-error +f('x', null); +// @ts-expect-error +f`${{ name: 'x' }}`; +// @ts-expect-error +f([1, undefined]); +// @ts-expect-error +f`${() => 'x'}`; + +// Modules and identifiers come only from module and ident: one shaped like +// them, without the key only they have, is neither. +const fakeModule = { path: 'm.ts', generated: true, ident: m.ident, sub: m.sub, equals: m.equals }; +// @ts-expect-error +const notModule: Module = fakeModule; +// @ts-expect-error +file(fakeModule); +// @ts-expect-error +ident('Pet', fakeModule); +const fakeIdent = { name: 'Pet', module: m, typeOnly: false, alias: undefined, ty: pet.ty, as: pet.as }; +// @ts-expect-error +const notIdent: Ident = fakeIdent; +// @ts-expect-error +f`let x: ${fakeIdent};`; + +// The ways to make them are typed. +const typeOnly: Ident = ident('Pet', m, { typeOnly: true, alias: 'P' }); +const dir: Module = module.ROOT.sub('models.ts'); +// @ts-expect-error The root can't be replaced. +module.ROOT = m; +// @ts-expect-error Nor can an identifier's name. +pet.name = 'Owner'; + +// A file is a function, with Function's members, and a File's own. +const fn: (...parts: Part[]) => void = f; +const bound: Function = f.bind(null); +const path: string = f.path; +const content: string = f.content; +f.indent((g) => g`nested;`); +f.doc('A comment.'); +// @ts-expect-error A file's module can't be replaced. +f.module = m; +// @ts-expect-error Nor can what it printed be reached. +f.body; + +export { printed, parts, notModule, notIdent, typeOnly, dir, fn, bound, path, content }; diff --git a/packages/forge/tsconfig.json b/packages/forge/tsconfig.json new file mode 100644 index 000000000..8936dbcca --- /dev/null +++ b/packages/forge/tsconfig.json @@ -0,0 +1,21 @@ +{ + // Compiles src/index.ts and src/ts.ts, the libraries plugins are written + // with, into .js and .d.ts files in plugin/. scripts/build.mjs runs it, after + // generating the bindings of plugin/forge.wit into src/wit. + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es2022", + "lib": ["es2022"], + "types": [], + "module": "nodenext", + "moduleResolution": "nodenext", + "declaration": true, + "rootDir": "src", + "outDir": "plugin", + "newLine": "lf" + }, + "include": ["src/index.ts", "src/ts.ts", "src/wit/**/*.d.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c89981fb..ed3932791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: 26.0.1 version: 26.0.1 oxfmt: - specifier: 0.57.0 - version: 0.57.0 + specifier: 0.71.0 + version: 0.71.0 oxlint: - specifier: 1.72.0 - version: 1.72.0 + specifier: 1.86.0 + version: 1.86.0 packages/astro-fern: devDependencies: @@ -31,7 +31,7 @@ importers: version: 25.3.5 astro: specifier: ^7.2.8 - version: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.3.5)(tsx@4.22.4)(yaml@2.9.0) + version: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.3.5)(tsx@4.22.4)(yaml@2.9.0) satteri: specifier: ^0.10.5 version: 0.10.5 @@ -43,13 +43,13 @@ importers: dependencies: '@astrojs/cloudflare': specifier: ^14.2.5 - version: 14.2.5(@types/node@26.0.1)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(esbuild@0.28.2)(tsx@4.22.4)(wrangler@4.127.0)(yaml@2.9.0) + version: 14.2.5(@types/node@26.0.1)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(esbuild@0.28.2)(tsx@4.22.4)(wrangler@4.127.0)(yaml@2.9.0) '@astrojs/prism': specifier: ^4.0.2 version: 4.0.2 '@astrojs/starlight': specifier: ^0.41.9 - version: 0.41.9(@astrojs/markdown-remark@7.2.4)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(typescript@5.9.3) + version: 0.41.9(@astrojs/markdown-remark@7.2.4)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(typescript@5.9.3) '@fontsource-variable/inter': specifier: 5.3.0 version: 5.3.0 @@ -58,7 +58,7 @@ importers: version: 5.3.0 astro: specifier: ^7.2.8 - version: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) + version: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) astro-fern: specifier: workspace:* version: link:../astro-fern @@ -83,7 +83,7 @@ importers: version: 25.3.5 astro: specifier: ^7.0.2 - version: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.3.5)(tsx@4.22.4)(yaml@2.9.0) + version: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.3.5)(tsx@4.22.4)(yaml@2.9.0) astro-fern: specifier: workspace:* version: link:../astro-fern @@ -91,8 +91,74 @@ importers: specifier: 5.9.3 version: 5.9.3 + packages/forge: + devDependencies: + '@bytecodealliance/jco': + specifier: 1.37.0 + version: 1.37.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + + src/bin/forge/ts/lib: + devDependencies: + '@types/node': + specifier: 24.19.0 + version: 24.19.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + packages: + '@andreiltd/componentize-qjs-binding-darwin-arm64@0.4.5': + resolution: {integrity: sha512-CW+ukVP778x2NAHAC1FvXb5KVSXmUlUQxhQTFtF0mAqqOVnYrQgy4DDNUa48EGthwtLdgLT1+eE71qmQiyMEqg==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [darwin] + + '@andreiltd/componentize-qjs-binding-darwin-x64@0.4.5': + resolution: {integrity: sha512-37uz97pCEuXNfoVuZMBbI6xUirSndxJyMzPa4UCQb9nJdZhzRujmrU79yiFl+5nDYQxcCjthhBVEKU3OxJNAdg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [darwin] + + '@andreiltd/componentize-qjs-binding-linux-arm64-gnu@0.4.5': + resolution: {integrity: sha512-L5S0C6qIx3f+DnwN42HnDuMhV8NgfqaY+J1PaJAw468Ip/JN+h67tVAp4wUvs8xgTqj6UnGSBbSZVGYifK2XtA==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@andreiltd/componentize-qjs-binding-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-bBaq8/tvQ6HweqBLKWkE5tsPEMf7g3l5EGtka3Z7AFDUOtFzkAkG67nW1OYiYT6/0pjwDYw7Mvya45f79PG28g==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [linux] + + '@andreiltd/componentize-qjs-binding-linux-x64-gnu@0.4.5': + resolution: {integrity: sha512-Xec4O4sy/xHlhPPhf7GAV41D+AT7f9vxxjBpE7lUIElZNhX/kc+Ty6AQG50xbHsT5Mi4XQvSmtJJqhnB6xi8Vw==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@andreiltd/componentize-qjs-binding-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-62+PgJ+tJOjgNvXzRuz3s76tPBaExCodeVhDIIXkGI/PNbulqTHiyYXRw4O3UX6/CO71Nux8onqnXxQkcXBlvg==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [linux] + + '@andreiltd/componentize-qjs-binding-win32-arm64-msvc@0.4.5': + resolution: {integrity: sha512-OZO/omsMQFpw3wJJs3XFN+iEu2ZQCWMK8BxkhmXVm1sddEHtgSTY2sT/zq69VZeXjC0zgN1HzlxHbNBq0OkcOQ==} + engines: {node: '>=18.0.0'} + cpu: [arm64] + os: [win32] + + '@andreiltd/componentize-qjs-binding-win32-x64-msvc@0.4.5': + resolution: {integrity: sha512-IlhlcNOEFky46ypfiD6l8MS45zosfhKWUclr10evQFw5Cpky+pji2RAGJRnBXdLQvH4Q3bZr6KYlIhxmtGRhkw==} + engines: {node: '>=18.0.0'} + cpu: [x64] + os: [win32] + '@astrojs/check@0.9.10': resolution: {integrity: sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ==} hasBin: true @@ -333,6 +399,132 @@ packages: cpu: [x64] os: [win32] + '@bytecodealliance/componentize-js@0.19.3': + resolution: {integrity: sha512-ju7Y4WeF0B9uMkSPHJgmT6ouEfSwbe9M1uR/YOnYZjBpxJjH9qzxIkJg/kf8NycVDyFJ2/lscmJ1E1uPiDQVRQ==} + hasBin: true + + '@bytecodealliance/componentize-js@0.23.0': + resolution: {integrity: sha512-Qc2+lrwJHU7lq4CBMyVVYE8ASyUFeegMYdRnMVG0wNpNiATzGDF/mWpjDNTeVZeT8L9chhK3vuLaQE9FVANvfw==} + hasBin: true + + '@bytecodealliance/jco-node-fs-darwin-arm64@0.3.2': + resolution: {integrity: sha512-WXQq1tRVUWhbK+ImH0G8Kni2AR0sdokLf/KyXRw5P6n4A9ZbgMHOuPfN/PEsYgw4eosuTAonutK0xILk/hA81A==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + '@bytecodealliance/jco-node-fs-darwin-x64@0.3.2': + resolution: {integrity: sha512-Fy/OjHnto7hNE5ksewVkmAMXG/uXyj+RAKFZWQFTzFkp73UyT5vuPjKq3nvx9KolUGWBG37BudEMb4ZAoD9TbQ==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + '@bytecodealliance/jco-node-fs-linux-arm64-gnu@0.3.2': + resolution: {integrity: sha512-OafRtRu3tfOFQARfkia/LDEqSo2R3yAL1xPwzf4RcRW6JqkOY7qKWsUS5+LPVo/aFaSkMX7F7bdFGpFPmcoZJg==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + + '@bytecodealliance/jco-node-fs-linux-arm64-musl@0.3.2': + resolution: {integrity: sha512-JblDiwD7+Y+w0d/tjaNDX4QdGvh5lyuDc+S/jDeT7sYA1caA9yiwIjLa8PC/lm2xudqSxD+Cuy4AosXvl+qI0g==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + + '@bytecodealliance/jco-node-fs-linux-x64-gnu@0.3.2': + resolution: {integrity: sha512-6FzmHoLOsa6b4X9AuFLsvlpNF66Dt4sRRftZHaT1I1pYzCWrJkkpgNJhRXxf67ZPmOwBZJT4dan/g752Rx4xRw==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + + '@bytecodealliance/jco-node-fs-linux-x64-musl@0.3.2': + resolution: {integrity: sha512-SbAJG2PDV8YAHBESHU9sVSckOitJyvlezdF4CGP5/mOp01JnijVkLVXzGK62yVSfa5TpAybbSd1rk+v36NAW/Q==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + + '@bytecodealliance/jco-node-fs-win32-arm64-msvc@0.3.2': + resolution: {integrity: sha512-nUWYdFysRQJGsIOCk0KAYN2smkNYiHG7rZ7m+0ZXqP+5wqWxKEjkDsORwXiBjdaWFLVww0anZ3SBxrju6P7hXQ==} + engines: {node: '>=20'} + cpu: [arm64] + os: [win32] + + '@bytecodealliance/jco-node-fs-win32-x64-msvc@0.3.2': + resolution: {integrity: sha512-RsPj2t8TvltsMWK7/Z7JcVC/IWPMi07/AJkNLiNTCuGTdT/sseqTThJJwhuz3sjFw7dJdxA+bSGT+23y/KT0fw==} + engines: {node: '>=20'} + cpu: [x64] + os: [win32] + + '@bytecodealliance/jco-node-fs@0.3.2': + resolution: {integrity: sha512-yxvqQreNcOQdMsEKZomkSqThtkxrAz3f1Cmy8BsV0UHi9saa2/D1KDB14IAXPYwDq/maS6lCzS+MuMQx6QkUgA==} + engines: {node: '>=20'} + + '@bytecodealliance/jco-std@0.7.0': + resolution: {integrity: sha512-QSiIRXS5eMUTe0gt5D45vZK4Pt7Hdf0S0Gr2Lt3+Q0h4YIq+n8EbEioQ2lem2edNWT/Zym+YvEXDZOhS3qZ2dw==} + peerDependencies: + '@bytecodealliance/preview2-shim': ^0.28.0 + peerDependenciesMeta: + '@bytecodealliance/preview2-shim': + optional: true + + '@bytecodealliance/jco-transpile@0.18.0': + resolution: {integrity: sha512-Ou4MWoxGC6HKBjRpTJ4SEUxsN8DOAv9LIi2RD22Kosq2OZTnUvKikFrOAGS2vCGNMzvXkeKBS1jFMLKzYBk4pg==} + + '@bytecodealliance/jco@1.37.0': + resolution: {integrity: sha512-z/crtP4L3wumdFA74K1cEsLfdZ8Ki0dE/7m1fJ9SVq5Z9VsAf0yQXWQzMb6ZrJmXvRN+4wHC2MNkFwSFdaoBzQ==} + hasBin: true + + '@bytecodealliance/preview2-shim@0.28.0': + resolution: {integrity: sha512-55y59uAzYMTA1bTepmgC7LmK8DUyhOU+y7cfKQRV7wRVABMYna4t7KYpBUkO8VhI+FbDn5cq0ghPLelaP24OpQ==} + + '@bytecodealliance/preview3-shim@0.9.0': + resolution: {integrity: sha512-/0dEMWyxFyTF/1tsxspU8p6dgfWnQ4SSY+ray8QBJi7KFJu1W6R3M3BFRL83PJ+PmMyuOBaRhhYH3rR6kE+pcA==} + + '@bytecodealliance/weval@0.5.0': + resolution: {integrity: sha512-lIR6+enn52gcdMQM8C3YaNoUlzm90TRkJrIgiVky/hED81ezDjhnq+6Z4w0eSFf9C6b2nmdKUFwbp6q1PhiuIQ==} + engines: {node: '>=16'} + + '@bytecodealliance/wizer-darwin-arm64@10.0.0': + resolution: {integrity: sha512-dhZTWel+xccGTKSJtI9A7oM4yyP20FWflsT+AoqkOqkCY7kCNrj4tmMtZ6GXZFRDkrPY5+EnOh62sfShEibAMA==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-x64@10.0.0': + resolution: {integrity: sha512-r/LUIZw6Q3Hf4htd46mD+EBxfwjBkxVIrTM1r+B2pTCddoBYQnKVdVsI4UFyy7NoBxzEg8F8BwmTNoSLmFRjpw==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-linux-arm64@10.0.0': + resolution: {integrity: sha512-pGSfFWXzeTqHm6z1PtVaEn+7Fm3QGC8YnHrzBV4sQDVS3N1NwmuHZAc8kslmlFPNdu61ycEvdOsSgCny8JPQvg==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-s390x@10.0.0': + resolution: {integrity: sha512-O8vHxRTAdb1lUnVXMIMTcp/9q4pq1D4iIKigJCipg2JN15taV9uFAWh0fO88wylXwuSlO7dOE1AwQl54fMKXQg==} + cpu: [s390x] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-x64@10.0.0': + resolution: {integrity: sha512-fJtM1sy43FBMnp+xpapFX6U1YdTBKA/1T4CYfG/qeE8jn0SXk2EuiYoY/EnC2uyNy9hjTrvfdYO5n4MXW0EIdQ==} + cpu: [x64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-win32-x64@10.0.0': + resolution: {integrity: sha512-55BPLfGT7iT7gH5M69NpTM16QknJZ7OxJ0z73VOEoeGA9CT8QPKMRzFKsPIvLs+W8G28fdudFA94nElrdkp3Kg==} + cpu: [x64] + os: [win32] + hasBin: true + + '@bytecodealliance/wizer@10.0.0': + resolution: {integrity: sha512-ziWmovyu1jQl9TsKlfC2bwuUZwxVPFHlX4fOqTzxhgS76jITIo45nzODEwPgU+jjmOr8F3YX2V2wAChC5NKujg==} + engines: {node: '>=16'} + hasBin: true + '@capsizecss/unpack@4.0.1': resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} @@ -457,9 +649,15 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -1084,6 +1282,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1097,6 +1299,114 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@napi-rs/lzma-android-arm-eabi@1.5.1': + resolution: {integrity: sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.5.1': + resolution: {integrity: sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.5.1': + resolution: {integrity: sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.5.1': + resolution: {integrity: sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.5.1': + resolution: {integrity: sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.5.1': + resolution: {integrity: sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.5.1': + resolution: {integrity: sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [linux] + + '@napi-rs/lzma-linux-arm64-musl@1.5.1': + resolution: {integrity: sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [linux] + + '@napi-rs/lzma-linux-ppc64-gnu@1.5.1': + resolution: {integrity: sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [ppc64] + os: [linux] + + '@napi-rs/lzma-linux-riscv64-gnu@1.5.1': + resolution: {integrity: sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [riscv64] + os: [linux] + + '@napi-rs/lzma-linux-s390x-gnu@1.5.1': + resolution: {integrity: sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [s390x] + os: [linux] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@napi-rs/lzma-linux-x64-musl@1.5.1': + resolution: {integrity: sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@napi-rs/lzma-wasm32-wasi@1.5.1': + resolution: {integrity: sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.5.1': + resolution: {integrity: sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.5.1': + resolution: {integrity: sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.5.1': + resolution: {integrity: sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.5.1': + resolution: {integrity: sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==} + engines: {node: ^22.20 || ^24.12 || >=25} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1107,233 +1417,439 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-minify/binding-android-arm-eabi@0.151.0': + resolution: {integrity: sha512-Y1kwFf+GCq62PWojA4u5ME99T1quxdM+VPB67kIC1x0wLmfGAWAhS9cwKIxe/bW95loAxGIruvkYDVMCrvOBvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-minify/binding-android-arm64@0.151.0': + resolution: {integrity: sha512-IuRgG5pZ5Axv88yMK5nIeQHOQqrWkGd2Wcu4yUlGhV9NMr2C6N/HpJ7crLWwpflZSHgWXW5s/5AsmEN2CBJw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-minify/binding-darwin-arm64@0.151.0': + resolution: {integrity: sha512-Uuo84npyREYIOJ8ohYdc7eA+QKVcZnX4524U81+6nhdmxkMp19AZHaBGmd/tN6c7KDfsF3ATXQUOxKkCOGGxUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-minify/binding-darwin-x64@0.151.0': + resolution: {integrity: sha512-XiWlF2PpwoiX6RcqWmFTvcxW5hH+C96l9km164vbM9wisSBoapo95dvZjEzG7lEmffHOGzb4BOAlAeeZ/WlRWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-minify/binding-freebsd-x64@0.151.0': + resolution: {integrity: sha512-fM6/wcqfGp1R+zxYxHXs+P+wBpTlhU1Nr7iiZyBoCg+mphrgyYS7drUiHWoWzAvUSpicnmFm6rag9bSSWTDUMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-minify/binding-linux-arm-gnueabihf@0.151.0': + resolution: {integrity: sha512-s/aBPTyIMW9uAD0mP+xSz7UbuxXfEIi/1rEpqTd1gGjVCz2Sl58sbc65FauI4K26S9nDpk7fGc7y9C9rjK9L+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-minify/binding-linux-arm-musleabihf@0.151.0': + resolution: {integrity: sha512-9t44xCPN7E93Pmxsov9qwEGcTc0UJa+z0ZJzvjCk0LqXdLNOQhd15k8KgVuzVbWitI19TvOX/fEC3ZVF6LB/kw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-minify/binding-linux-arm64-gnu@0.151.0': + resolution: {integrity: sha512-a77EK4y+3fyD1GZiMnOUSr8x52Hx6eKjPnfIzeh4C9UgnUZBRYMsdHBbBDHLLOX1adjyluxtn4Tze7yTVByCeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-minify/binding-linux-arm64-musl@0.151.0': + resolution: {integrity: sha512-2AjX1jJsj/guky9OuM1jKmuwRVvy+8UUChK3uvrRWV89I/FyrQj9wKPj8TkuL0Ay5yL2xvZj/Ti1Qg0qiRsP+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-minify/binding-linux-ppc64-gnu@0.151.0': + resolution: {integrity: sha512-l3iUa60pK2WenCy9jhO4UTK/yz9RjVIXPXqfzMdz7DA0QQZ9dxAk/p2p3ERf4KZq/MEIfvs9e4LuFVYg5PFYdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-minify/binding-linux-riscv64-gnu@0.151.0': + resolution: {integrity: sha512-v+sdB36Rp8BxmQoP5Jt6ixz7G0aN9Nayh/LvdNBW/0nkKoLHFyEAcV/Uy+79R3oB0uBC1J6DM9XlDxCC1uMkbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-minify/binding-linux-riscv64-musl@0.151.0': + resolution: {integrity: sha512-QER/ajZukltjbfFi4rGx6/jC5XEnImAma5P606Xnej0D3fyMC8rXPmae5Z3HyUJMt1Hd5POOkC3S5yQBNPppwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-minify/binding-linux-s390x-gnu@0.151.0': + resolution: {integrity: sha512-+r7s5IPrWPtdz/x5LiNSk0ySmzvdP0/VLwt23ULFvcU6jsMrw8DseylMfp4HxvGlJcYyKAUj7JSGxwEmEACUvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-minify/binding-linux-x64-gnu@0.151.0': + resolution: {integrity: sha512-O1Rs0HbgKvEhTiq1bVcZnsOY6q8f4LoPE6pgOieB19Coyz1XP/ugq7XILUD6evlszBGRKliRAwycRScbl2kjqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-minify/binding-linux-x64-musl@0.151.0': + resolution: {integrity: sha512-RSbyV8xzLsvypVTo871IYAfL5We9/8JNyAQLMXiCRnQGD/daFm/bp+iLhtoy2jelgOa6kp4H3bkdl+ci5SSwDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-minify/binding-openharmony-arm64@0.151.0': + resolution: {integrity: sha512-WHwdIt3Kj0TQSVey3RiWXgyeZpXUz3Hapq/mdCXyxVCBkvRk/KHIxmHE32EGE/PRx+EQFHVo1+gm627XOde/qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-minify/binding-win32-arm64-msvc@0.151.0': + resolution: {integrity: sha512-mFL0B3HByMlBxspR+SeqyBR8b96QkoI2D8quQOwBXEUExR4Ozu3Ya0uyYR9MBdwvQHZZWQ2EWgum8bdHdTfl/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-minify/binding-win32-ia32-msvc@0.151.0': + resolution: {integrity: sha512-p6QBBxYsg0A0jUQu5NRqW/UuPtoLtmA72SE8N7Rkhn43m/gSLNTE/nFjsLSsX6T9yM8d8k5PfnYvFnSUf+IlRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-minify/binding-win32-x64-msvc@0.151.0': + resolution: {integrity: sha512-lkP3SsNXm3JUrNX9c9Ijhl3Iipw0fWuCAOOxju+YjH2AolPUbTLE664KeEDdxt8kaLCh+wCBNyqnV67OLVHZWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-parser/binding-android-arm64@0.76.0': + resolution: {integrity: sha512-1XJW/16CDmF5bHE7LAyPPmEEVnxSadDgdJz+xiLqBrmC4lfAeuAfRw3HlOygcPGr+AJsbD4Z5sFJMkwjbSZlQg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.76.0': + resolution: {integrity: sha512-yoQwSom8xsB+JdGsPUU0xxmxLKiF2kdlrK7I56WtGKZilixuBf/TmOwNYJYLRWkBoW5l2/pDZOhBm2luwmLiLw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.76.0': + resolution: {integrity: sha512-uRIopPLvr3pf2Xj7f5LKyCuqzIU6zOS+zEIR8UDYhcgJyZHnvBkfrYnfcztyIcrGdQehrFUi3uplmI09E7RdiQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.76.0': + resolution: {integrity: sha512-a0EOFvnOd2FqmDSvH6uWLROSlU6KV/JDKbsYDA/zRLyKcG6HCsmFnPsp8iV7/xr9WMbNgyJi6R5IMpePQlUq7Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.76.0': + resolution: {integrity: sha512-ikRYDHL3fOdZwfJKmcdqjlLgkeNZ3Ez0qM8wAev5zlHZ+lY/Ig7qG5SCqPlvuTu+nNQ6zrFFaKvvt69EBKXU/g==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.76.0': + resolution: {integrity: sha512-dtRv5J5MRCLR7x39K8ufIIW4svIc7gYFUaI0YFXmmeOBhK/K2t/CkguPnDroKtsmXIPHDRtmJ1JJYzNcgJl6Wg==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.76.0': + resolution: {integrity: sha512-IE4iiiggFH2snagQxHrY5bv6dDpRMMat+vdlMN/ibonA65eOmRLp8VLTXnDiNrcla/itJ1L9qGABHNKU+SnE8g==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.76.0': + resolution: {integrity: sha512-wi9zQPMDHrBuRuT7Iurfidc9qlZh7cKa5vfYzOWNBCaqJdgxmNOFzvYen02wVUxSWGKhpiPHxrPX0jdRyJ8Npg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.76.0': + resolution: {integrity: sha512-0tqqu1pqPee2lLGY8vtYlX1L415fFn89e0a3yp4q5N9f03j1rRs0R31qesTm3bt/UK8HYjECZ+56FCVPs2MEMQ==} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.76.0': + resolution: {integrity: sha512-y36Hh1a5TA+oIGtlc8lT7N9vdHXBlhBetQJW0p457KbiVQ7jF7AZkaPWhESkjHWAsTVKD2OjCa9ZqfaqhSI0FQ==} + engines: {node: '>=20.0.0'} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.76.0': + resolution: {integrity: sha512-7/acaG9htovp3gp/J0kHgbItQTuHctl+rbqPPqZ9DRBYTz8iV8kv3QN8t8Or8i/hOmOjfZp9McDoSU1duoR4/A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.76.0': + resolution: {integrity: sha512-AxFt0reY6Q2rfudABmMTFGR8tFFr58NlH2rRBQgcj+F+iEwgJ+jMwAPhXd2y1I2zaI8GspuahedUYQinqxWqjA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-wasm32-wasi@0.76.0': + resolution: {integrity: sha512-wHdkHdhf6AWBoO8vs5cpoR6zEFY1rB+fXWtq6j/xb9j/lu1evlujRVMkh8IM/M/pOUIrNkna3nzST/mRImiveQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.76.0': + resolution: {integrity: sha512-G7ZlEWcb2hNwCK3qalzqJoyB6HaTigQ/GEa7CU8sAJ/WwMdG/NnPqiC9IqpEAEy1ARSo4XMALfKbKNuqbSs5mg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.76.0': + resolution: {integrity: sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} - '@oxfmt/binding-android-arm-eabi@0.57.0': - resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} + '@oxc-project/types@0.76.0': + resolution: {integrity: sha512-CH3THIrSViKal8yV/Wh3FK0pFhp40nzW1MUDCik9fNuid2D/7JJXKJnfFOAvMxInGXDlvmgT6ACAzrl47TqzkQ==} + + '@oxfmt/binding-android-arm-eabi@0.71.0': + resolution: {integrity: sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.57.0': - resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} + '@oxfmt/binding-android-arm64@0.71.0': + resolution: {integrity: sha512-E8q39SUZzSXQ08oZoFoTkxRADkdBNgL6De4qgZYr2O4JCunwqrZwAyCebxIF3yVHBafHM30WUkhTkDi+jNYZOw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.57.0': - resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} + '@oxfmt/binding-darwin-arm64@0.71.0': + resolution: {integrity: sha512-pTteTrN88DicrmJ3DocBmpNDa6Umfh0iveEG8rnAlDFQpclmtDS7hnIvZbIEV9mDO0Ot1U0sjtoJQ1itd/ZMhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.57.0': - resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} + '@oxfmt/binding-darwin-x64@0.71.0': + resolution: {integrity: sha512-G5melRC3IiNEgXlNKJhSExJYQGAQIppHFp2cl/wFrqQcEwmc4k4TkrHaFTR29SDudjoW5AZq5cXPGJDqtlH/Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.57.0': - resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} + '@oxfmt/binding-freebsd-x64@0.71.0': + resolution: {integrity: sha512-Hb1P5yKb9aiH2VSbm3Ml37mKjzqpunYet9krw/YK7V5JsAUJDQlju3Yk/dEqQvJD/usBvpi01Vbk9ZDNo0a8Cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': - resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': + resolution: {integrity: sha512-r7n4JEO+rkp0kaP3ia+0hv6FzK3PQIHqaF0jxGuhjJ4JHVX9ZVRbgpDVDW+OloyuJsRWpWFDKIHe8gfl1p/4mQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.57.0': - resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': + resolution: {integrity: sha512-EEynTF4nakgqDhVCmd3cHQCmTcw0BM8ea6eHigTdtZ3yxdsBNv+dYkt+OpDDZx1n/jzjI7J+Ht7IgkqFWuJUxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.57.0': - resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} + '@oxfmt/binding-linux-arm64-gnu@0.71.0': + resolution: {integrity: sha512-7VgJIrywCwR/G6YMv+HqsccUt8Z4q4mbM2xR+Ry/6AeMk4pCmZ9aO4Srd3mmrvVu8PsCZGP7oHEHSXWTmyloaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-arm64-musl@0.57.0': - resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} + '@oxfmt/binding-linux-arm64-musl@0.71.0': + resolution: {integrity: sha512-AOCaminv/+fhinUXKvtPZT4POhNYmN9GfvaJdgJa1AbvuzClWGwDIXfJg8eu3sYaVL2B68jNvmS7LZGAYa0z3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxfmt/binding-linux-ppc64-gnu@0.57.0': - resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': + resolution: {integrity: sha512-0SHxuaa4QRLM7jfsd8wc6L7KdLg+FR6/ah9k1eAvcrK/Sni7oxchMvsjwUsyakVAta+wBVDkjJTr7/RFU0uUGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxfmt/binding-linux-riscv64-gnu@0.57.0': - resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': + resolution: {integrity: sha512-UNKj1+rPorTK6uSlQzpN96vDNbHbL/63zcpznweo0ImQdkDdtQCS7G3puxX++2cESPKT2B56Y6DICUmFTJde5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-riscv64-musl@0.57.0': - resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} + '@oxfmt/binding-linux-riscv64-musl@0.71.0': + resolution: {integrity: sha512-5cVXKj7a0mWu7GU6/bZgNyv+KtLgM5S9cnT26Os2/abj8pK/bklHsbdYEHGLLniGjNYkyzwem8mZciPCaDtilw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxfmt/binding-linux-s390x-gnu@0.57.0': - resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} + '@oxfmt/binding-linux-s390x-gnu@0.71.0': + resolution: {integrity: sha512-lNpcMuZvyU1V/CY5h19hac03m4E4PLSYRckcxZOynSPojI4Mwt6nEu9wT/gVf0bclH3xGEWjwqApnSTgjkd2oA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxfmt/binding-linux-x64-gnu@0.57.0': - resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} + '@oxfmt/binding-linux-x64-gnu@0.71.0': + resolution: {integrity: sha512-5/Z6pUewQpknXqC4/ykK6Zc6RiteAnPem1Ci7K1RZLVF6w6MMjwHjR4vsjijW4Czidgv7HKeVglGjElADliT9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-linux-x64-musl@0.57.0': - resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} + '@oxfmt/binding-linux-x64-musl@0.71.0': + resolution: {integrity: sha512-uVdG2N/4GEbOeljpQ+xv+NeEwJWJGj0WaxSiSYnoiqIYy3RWrWd3rGUmxWXP1A8+ferNvvwFoDAtvgsDUvBuSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxfmt/binding-openharmony-arm64@0.57.0': - resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} + '@oxfmt/binding-openharmony-arm64@0.71.0': + resolution: {integrity: sha512-1LwEZmSDRFKeBaGd/Hgj2xI3SmgGFpF4YtgkSx0sTYdTiZfHWbcFvRIBjL1YiDNdQd9Khda3Ij8CoqBULjskDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.57.0': - resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} + '@oxfmt/binding-win32-arm64-msvc@0.71.0': + resolution: {integrity: sha512-5tplmkeMXmza3PUptJQy25wCPJbIAg5g5r7zs+DI+7eEYOqbY+7roMdSH2zbDvv1Pb4oTDJ6NWYx9IRxBxXwhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.57.0': - resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} + '@oxfmt/binding-win32-ia32-msvc@0.71.0': + resolution: {integrity: sha512-T2LWgh3Vx4PYDjtcqRRGDyCsVxeF/VE5xTpUQiCm65bNLwMpRnJYj6Hlkf6qRM3+jxCIOqHD1kVHNQCCiAVMNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.57.0': - resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} + '@oxfmt/binding-win32-x64-msvc@0.71.0': + resolution: {integrity: sha512-L06SVp7Hu1EQTkh3+PHnKK26dxVbEpOG2InMwCooTE4MA437b5FgfTleCCiGn4ykISHnHU6UHaIg7SiMO7rBHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.72.0': - resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + '@oxlint/binding-android-arm-eabi@1.86.0': + resolution: {integrity: sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.72.0': - resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + '@oxlint/binding-android-arm64@1.86.0': + resolution: {integrity: sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.72.0': - resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + '@oxlint/binding-darwin-arm64@1.86.0': + resolution: {integrity: sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.72.0': - resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + '@oxlint/binding-darwin-x64@1.86.0': + resolution: {integrity: sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.72.0': - resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + '@oxlint/binding-freebsd-x64@1.86.0': + resolution: {integrity: sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': - resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + resolution: {integrity: sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.72.0': - resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + resolution: {integrity: sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.72.0': - resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + '@oxlint/binding-linux-arm64-gnu@1.86.0': + resolution: {integrity: sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.72.0': - resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + '@oxlint/binding-linux-arm64-musl@1.86.0': + resolution: {integrity: sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.72.0': - resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + resolution: {integrity: sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.72.0': - resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + resolution: {integrity: sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.72.0': - resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + '@oxlint/binding-linux-riscv64-musl@1.86.0': + resolution: {integrity: sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.72.0': - resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + '@oxlint/binding-linux-s390x-gnu@1.86.0': + resolution: {integrity: sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.72.0': - resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + '@oxlint/binding-linux-x64-gnu@1.86.0': + resolution: {integrity: sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.72.0': - resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + '@oxlint/binding-linux-x64-musl@1.86.0': + resolution: {integrity: sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.72.0': - resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + '@oxlint/binding-openharmony-arm64@1.86.0': + resolution: {integrity: sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.72.0': - resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + '@oxlint/binding-win32-arm64-msvc@1.86.0': + resolution: {integrity: sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.72.0': - resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + '@oxlint/binding-win32-ia32-msvc@1.86.0': + resolution: {integrity: sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.72.0': - resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + '@oxlint/binding-win32-x64-msvc@1.86.0': + resolution: {integrity: sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1551,8 +2067,8 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@24.19.0': + resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} @@ -1569,6 +2085,126 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -1598,11 +2234,24 @@ packages: '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -1679,18 +2328,36 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} bcp-47@2.1.1: resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + binaryen@132.0.0: + resolution: {integrity: sha512-4Usg7yEvwO3FMso3Oov6qkOOHSod1IEv+boKkh77kTljM+eNBouqCTbqWswGzbG+vDNO/b/v2C4gfEh1WgB9Lg==} + hasBin: true + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1714,6 +2381,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1736,10 +2407,19 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + common-ancestor-path@2.0.0: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} + componentize-qjs@0.4.5: + resolution: {integrity: sha512-/Brt+/Ps7DQ1mYuUsDISTQTFSaZfCSoRHkHnh9Dm5WHCO6arc/vzVdD8TXssfyNex2Iv3UR8NHdahsqABPDh0w==} + engines: {node: '>=18.0.0'} + hasBin: true + cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} @@ -1855,6 +2535,9 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -1903,9 +2586,17 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expressive-code@0.44.1: resolution: {integrity: sha512-GakidxhapWDzpKLqEaFQ8wGk6gAqEtPQibu8+yPBfnDLgev5Vdsh1pasTxnrXL/mzIknyqeTwhMHTghdaiUrTg==} @@ -1936,6 +2627,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + find-proc@0.1.0: resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2054,6 +2748,9 @@ packages: typescript: optional: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2085,6 +2782,11 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -2371,6 +3073,23 @@ packages: resolution: {integrity: sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q==} engines: {node: '>=22.0.0'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2422,8 +3141,16 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - oxfmt@0.57.0: - resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} + oxc-minify@0.151.0: + resolution: {integrity: sha512-fYdJw3+WYswBWedCl3G0ReJPlHIR1msz3uf5cGAgpHOlQOxfrVu5nOp2gLnccA91Vs7Yw2nH00lj8pc0rjZvYg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-parser@0.76.0: + resolution: {integrity: sha512-l98B2e9evuhES7zN99rb1QGhbzx25829TJFaKi2j0ib3/K/G5z1FdGYz6HZkrU3U8jdH7v2FC8mX1j2l9JrOUg==} + engines: {node: '>=20.0.0'} + + oxfmt@0.71.0: + resolution: {integrity: sha512-lUPUl0d/+Io5pDrsPXWs6rB4N/bpB78oj9CTDpnbulfDz+0r3XXcHPlQ7kRPJ2GjIT4nX+/mcqunOeP9BvsEtg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2435,12 +3162,12 @@ packages: vite-plus: optional: true - oxlint@1.72.0: - resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + oxlint@1.86.0: + resolution: {integrity: sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.22.1' + oxlint-tsgolint: '>=7.0.2003' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -2536,12 +3263,24 @@ packages: resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} engines: {node: '>=18.0.0'} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2564,6 +3303,13 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + regenerate-unicode-properties@10.3.0: + resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==} + engines: {node: '>=4'} + + regenerate@1.5.0: + resolution: {integrity: sha512-1s+CxXjJ71i7LNVy8nYwg47XV4y6lAnOEhsMtSKq3WpmPfhl9LuKlTqKIn/3yUpE8lBz+W+TT88Bxyhnr0U4yw==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2573,6 +3319,17 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.4: + resolution: {integrity: sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==} + hasBin: true + rehype-expressive-code@0.44.1: resolution: {integrity: sha512-+VZgs7Evw4LXRN3owpoBNSTpYuW6GeOdjqcUT1TuY8o/4MGPtbd0EU7Bgrju7X8KrQ6SslOBAuGWJ5fV5TriJQ==} @@ -2646,6 +3403,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + satteri@0.10.5: resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} @@ -2712,6 +3472,9 @@ packages: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2734,6 +3497,10 @@ packages: engines: {node: '>=16'} hasBin: true + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -2749,10 +3516,14 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} engines: {node: ^20.0.0 || >=22.0.0} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2778,6 +3549,16 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -2790,6 +3571,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2804,6 +3588,22 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2906,6 +3706,9 @@ packages: resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} engines: {node: '>=20'} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3074,6 +3877,14 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + workerd@1.20260815.1: resolution: {integrity: sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==} engines: {node: '>=16'} @@ -3117,6 +3928,10 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml-language-server@1.23.0: resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} hasBin: true @@ -3157,6 +3972,30 @@ packages: snapshots: + '@andreiltd/componentize-qjs-binding-darwin-arm64@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-darwin-x64@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-linux-arm64-gnu@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-linux-arm64-musl@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-linux-x64-gnu@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-linux-x64-musl@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-win32-arm64-msvc@0.4.5': + optional: true + + '@andreiltd/componentize-qjs-binding-win32-x64-msvc@0.4.5': + optional: true + '@astrojs/check@0.9.10(prettier@3.9.6)(typescript@5.9.3)': dependencies: '@astrojs/language-server': 2.16.14(prettier@3.9.6)(typescript@5.9.3) @@ -3168,12 +4007,12 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/cloudflare@14.2.5(@types/node@26.0.1)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(esbuild@0.28.2)(tsx@4.22.4)(wrangler@4.127.0)(yaml@2.9.0)': + '@astrojs/cloudflare@14.2.5(@types/node@26.0.1)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(esbuild@0.28.2)(tsx@4.22.4)(wrangler@4.127.0)(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/underscore-redirects': 1.0.4 '@cloudflare/vite-plugin': 1.53.0(vite@8.2.1(@types/node@26.0.1)(esbuild@0.28.2)(tsx@4.22.4)(yaml@2.9.0))(wrangler@4.127.0) - astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) + astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) piccolore: 0.1.3 vite: 8.2.1(@types/node@26.0.1)(esbuild@0.28.2)(tsx@4.22.4)(yaml@2.9.0) wrangler: 4.127.0 @@ -3211,9 +4050,9 @@ snapshots: '@astrojs/compiler-binding-linux-x64-musl@0.4.0': optional: true - '@astrojs/compiler-binding-wasm32-wasi@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-binding-wasm32-wasi@0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -3225,7 +4064,7 @@ snapshots: '@astrojs/compiler-binding-win32-x64-msvc@0.4.0': optional: true - '@astrojs/compiler-binding@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-binding@0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)': optionalDependencies: '@astrojs/compiler-binding-darwin-arm64': 0.4.0 '@astrojs/compiler-binding-darwin-x64': 0.4.0 @@ -3233,16 +4072,16 @@ snapshots: '@astrojs/compiler-binding-linux-arm64-musl': 0.4.0 '@astrojs/compiler-binding-linux-x64-gnu': 0.4.0 '@astrojs/compiler-binding-linux-x64-musl': 0.4.0 - '@astrojs/compiler-binding-wasm32-wasi': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-wasm32-wasi': 0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.0 '@astrojs/compiler-binding-win32-x64-msvc': 0.4.0 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@astrojs/compiler-rs@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-rs@0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)': dependencies: - '@astrojs/compiler-binding': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding': 0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -3314,13 +4153,13 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.7(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))': + '@astrojs/mdx@7.0.7(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/markdown-remark': 7.2.4 '@mdx-js/mdx': 3.1.1 acorn: 8.18.0 - astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) + astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) es-module-lexer: 2.3.2 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -3346,17 +4185,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.41.9(@astrojs/markdown-remark@7.2.4)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(typescript@5.9.3)': + '@astrojs/starlight@0.41.9(@astrojs/markdown-remark@7.2.4)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0))(typescript@5.9.3)': dependencies: '@astrojs/markdown-satteri': 0.3.8 - '@astrojs/mdx': 7.0.7(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0)) + '@astrojs/mdx': 7.0.7(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.5.2 '@types/hast': 3.0.5 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) - astro-expressive-code: 0.44.1(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0)) + astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) + astro-expressive-code: 0.44.1(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0)) bcp-47: 2.1.1 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -3440,38 +4279,166 @@ snapshots: '@bruits/satteri-linux-x64-gnu@0.9.5': optional: true - '@bruits/satteri-linux-x64-musl@0.10.5': - optional: true + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.9.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-wasm32-wasi@0.9.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.9.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.9.5': + optional: true + + '@bytecodealliance/componentize-js@0.19.3': + dependencies: + '@bytecodealliance/jco': 1.37.0 + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + + '@bytecodealliance/componentize-js@0.23.0': + dependencies: + '@bytecodealliance/jco': 1.37.0 + '@bytecodealliance/weval': 0.5.0 + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + + '@bytecodealliance/jco-node-fs-darwin-arm64@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-darwin-x64@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-arm64-gnu@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-arm64-musl@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-x64-gnu@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-x64-musl@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-win32-arm64-msvc@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-win32-x64-msvc@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs@0.3.2': + optionalDependencies: + '@bytecodealliance/jco-node-fs-darwin-arm64': 0.3.2 + '@bytecodealliance/jco-node-fs-darwin-x64': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-arm64-gnu': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-arm64-musl': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-x64-gnu': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-x64-musl': 0.3.2 + '@bytecodealliance/jco-node-fs-win32-arm64-msvc': 0.3.2 + '@bytecodealliance/jco-node-fs-win32-x64-msvc': 0.3.2 + + '@bytecodealliance/jco-std@0.7.0(@bytecodealliance/preview2-shim@0.28.0)': + dependencies: + acorn: 8.17.0 + acorn-walk: 8.3.5 + minimatch: 10.2.6 + punycode: 2.3.1 + readable-stream: 4.7.0 + urlpattern-polyfill: 10.1.0 + whatwg-url: 14.2.0 + optionalDependencies: + '@bytecodealliance/preview2-shim': 0.28.0 + + '@bytecodealliance/jco-transpile@0.18.0': + dependencies: + '@bytecodealliance/preview2-shim': 0.28.0 + '@bytecodealliance/preview3-shim': 0.9.0 + binaryen: 132.0.0 + oxc-minify: 0.151.0 + + '@bytecodealliance/jco@1.37.0': + dependencies: + '@bytecodealliance/componentize-js': 0.23.0 + '@bytecodealliance/componentize-js-0-19-3': '@bytecodealliance/componentize-js@0.19.3' + '@bytecodealliance/jco-std': 0.7.0(@bytecodealliance/preview2-shim@0.28.0) + '@bytecodealliance/jco-transpile': 0.18.0 + '@bytecodealliance/preview2-shim': 0.28.0 + binaryen: 132.0.0 + commander: 14.0.3 + componentize-qjs: 0.4.5 + mkdirp: 3.0.1 + regexpu-core: 6.4.0 + rolldown: 1.2.5 + typescript: 7.0.2 + typescript-compiler-api: typescript@6.0.3 + unenv: 2.0.0-rc.24 + + '@bytecodealliance/preview2-shim@0.28.0': + dependencies: + '@bytecodealliance/jco-node-fs': 0.3.2 - '@bruits/satteri-linux-x64-musl@0.9.5': - optional: true + '@bytecodealliance/preview3-shim@0.9.0': + dependencies: + '@bytecodealliance/jco-node-fs': 0.3.2 + '@bytecodealliance/preview2-shim': 0.28.0 - '@bruits/satteri-wasm32-wasi@0.10.5': + '@bytecodealliance/weval@0.5.0': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/lzma': 1.5.1 + fflate: 0.8.3 + tar: 7.5.22 + + '@bytecodealliance/wizer-darwin-arm64@10.0.0': optional: true - '@bruits/satteri-wasm32-wasi@0.9.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer-darwin-x64@10.0.0': optional: true - '@bruits/satteri-win32-arm64-msvc@0.10.5': + '@bytecodealliance/wizer-linux-arm64@10.0.0': optional: true - '@bruits/satteri-win32-arm64-msvc@0.9.5': + '@bytecodealliance/wizer-linux-s390x@10.0.0': optional: true - '@bruits/satteri-win32-x64-msvc@0.10.5': + '@bytecodealliance/wizer-linux-x64@10.0.0': optional: true - '@bruits/satteri-win32-x64-msvc@0.9.5': + '@bytecodealliance/wizer-win32-x64@10.0.0': optional: true + '@bytecodealliance/wizer@10.0.0': + optionalDependencies: + '@bytecodealliance/wizer-darwin-arm64': 10.0.0 + '@bytecodealliance/wizer-darwin-x64': 10.0.0 + '@bytecodealliance/wizer-linux-arm64': 10.0.0 + '@bytecodealliance/wizer-linux-s390x': 10.0.0 + '@bytecodealliance/wizer-linux-x64': 10.0.0 + '@bytecodealliance/wizer-win32-x64': 10.0.0 + '@capsizecss/unpack@4.0.1': dependencies: fontkitten: 1.0.3 @@ -3580,11 +4547,22 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -3990,6 +4968,10 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -4029,6 +5011,88 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/lzma-android-arm-eabi@1.5.1': + optional: true + + '@napi-rs/lzma-android-arm64@1.5.1': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.5.1': + optional: true + + '@napi-rs/lzma-darwin-x64@1.5.1': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.5.1': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.5.1': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.5.1': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.5.1': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.5.1': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.5.1': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.5.1': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.5.1': + optional: true + + '@napi-rs/lzma@1.5.1': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.5.1 + '@napi-rs/lzma-android-arm64': 1.5.1 + '@napi-rs/lzma-darwin-arm64': 1.5.1 + '@napi-rs/lzma-darwin-x64': 1.5.1 + '@napi-rs/lzma-freebsd-x64': 1.5.1 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.5.1 + '@napi-rs/lzma-linux-arm64-gnu': 1.5.1 + '@napi-rs/lzma-linux-arm64-musl': 1.5.1 + '@napi-rs/lzma-linux-ppc64-gnu': 1.5.1 + '@napi-rs/lzma-linux-riscv64-gnu': 1.5.1 + '@napi-rs/lzma-linux-s390x-gnu': 1.5.1 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@napi-rs/lzma-linux-x64-musl': 1.5.1 + '@napi-rs/lzma-wasm32-wasi': 1.5.1 + '@napi-rs/lzma-win32-arm64-msvc': 1.5.1 + '@napi-rs/lzma-win32-ia32-msvc': 1.5.1 + '@napi-rs/lzma-win32-x64-msvc': 1.5.1 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -4036,129 +5100,242 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@emnapi/core': 1.11.1 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.3 optional: true '@oslojs/encoding@1.1.0': {} + '@oxc-minify/binding-android-arm-eabi@0.151.0': + optional: true + + '@oxc-minify/binding-android-arm64@0.151.0': + optional: true + + '@oxc-minify/binding-darwin-arm64@0.151.0': + optional: true + + '@oxc-minify/binding-darwin-x64@0.151.0': + optional: true + + '@oxc-minify/binding-freebsd-x64@0.151.0': + optional: true + + '@oxc-minify/binding-linux-arm-gnueabihf@0.151.0': + optional: true + + '@oxc-minify/binding-linux-arm-musleabihf@0.151.0': + optional: true + + '@oxc-minify/binding-linux-arm64-gnu@0.151.0': + optional: true + + '@oxc-minify/binding-linux-arm64-musl@0.151.0': + optional: true + + '@oxc-minify/binding-linux-ppc64-gnu@0.151.0': + optional: true + + '@oxc-minify/binding-linux-riscv64-gnu@0.151.0': + optional: true + + '@oxc-minify/binding-linux-riscv64-musl@0.151.0': + optional: true + + '@oxc-minify/binding-linux-s390x-gnu@0.151.0': + optional: true + + '@oxc-minify/binding-linux-x64-gnu@0.151.0': + optional: true + + '@oxc-minify/binding-linux-x64-musl@0.151.0': + optional: true + + '@oxc-minify/binding-openharmony-arm64@0.151.0': + optional: true + + '@oxc-minify/binding-win32-arm64-msvc@0.151.0': + optional: true + + '@oxc-minify/binding-win32-ia32-msvc@0.151.0': + optional: true + + '@oxc-minify/binding-win32-x64-msvc@0.151.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.76.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.76.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.76.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.76.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.76.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.76.0': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.76.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.76.0': + optional: true + '@oxc-project/types@0.146.0': {} - '@oxfmt/binding-android-arm-eabi@0.57.0': + '@oxc-project/types@0.76.0': {} + + '@oxfmt/binding-android-arm-eabi@0.71.0': optional: true - '@oxfmt/binding-android-arm64@0.57.0': + '@oxfmt/binding-android-arm64@0.71.0': optional: true - '@oxfmt/binding-darwin-arm64@0.57.0': + '@oxfmt/binding-darwin-arm64@0.71.0': optional: true - '@oxfmt/binding-darwin-x64@0.57.0': + '@oxfmt/binding-darwin-x64@0.71.0': optional: true - '@oxfmt/binding-freebsd-x64@0.57.0': + '@oxfmt/binding-freebsd-x64@0.71.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.57.0': + '@oxfmt/binding-linux-arm64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.57.0': + '@oxfmt/binding-linux-arm64-musl@0.71.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.57.0': + '@oxfmt/binding-linux-riscv64-musl@0.71.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.57.0': + '@oxfmt/binding-linux-s390x-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.57.0': + '@oxfmt/binding-linux-x64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.57.0': + '@oxfmt/binding-linux-x64-musl@0.71.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.57.0': + '@oxfmt/binding-openharmony-arm64@0.71.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.57.0': + '@oxfmt/binding-win32-arm64-msvc@0.71.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.57.0': + '@oxfmt/binding-win32-ia32-msvc@0.71.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.57.0': + '@oxfmt/binding-win32-x64-msvc@0.71.0': optional: true - '@oxlint/binding-android-arm-eabi@1.72.0': + '@oxlint/binding-android-arm-eabi@1.86.0': optional: true - '@oxlint/binding-android-arm64@1.72.0': + '@oxlint/binding-android-arm64@1.86.0': optional: true - '@oxlint/binding-darwin-arm64@1.72.0': + '@oxlint/binding-darwin-arm64@1.86.0': optional: true - '@oxlint/binding-darwin-x64@1.72.0': + '@oxlint/binding-darwin-x64@1.86.0': optional: true - '@oxlint/binding-freebsd-x64@1.72.0': + '@oxlint/binding-freebsd-x64@1.86.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.72.0': + '@oxlint/binding-linux-arm-musleabihf@1.86.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.72.0': + '@oxlint/binding-linux-arm64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.72.0': + '@oxlint/binding-linux-arm64-musl@1.86.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.72.0': + '@oxlint/binding-linux-ppc64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.72.0': + '@oxlint/binding-linux-riscv64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.72.0': + '@oxlint/binding-linux-riscv64-musl@1.86.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.72.0': + '@oxlint/binding-linux-s390x-gnu@1.86.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.72.0': + '@oxlint/binding-linux-x64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-x64-musl@1.72.0': + '@oxlint/binding-linux-x64-musl@1.86.0': optional: true - '@oxlint/binding-openharmony-arm64@1.72.0': + '@oxlint/binding-openharmony-arm64@1.86.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.72.0': + '@oxlint/binding-win32-arm64-msvc@1.86.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.72.0': + '@oxlint/binding-win32-ia32-msvc@1.86.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.72.0': + '@oxlint/binding-win32-x64-msvc@1.86.0': optional: true '@pagefind/darwin-arm64@1.5.2': @@ -4324,9 +5501,9 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/node@24.13.3': + '@types/node@24.19.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.24.6 '@types/node@25.3.5': dependencies: @@ -4344,6 +5521,66 @@ snapshots: '@types/unist@3.0.3': {} + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@ungap/structured-clone@1.3.3': {} '@volar/kit@2.4.28(typescript@5.9.3)': @@ -4396,10 +5633,20 @@ snapshots: '@vscode/l10n@0.0.18': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.17.0: {} + acorn@8.18.0: {} ajv-draft-04@1.0.0(ajv@8.20.0): @@ -4440,15 +5687,15 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.44.1(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0)): + astro-expressive-code@0.44.1(astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) + astro: 7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0) rehype-expressive-code: 0.44.1 url-extras: 0.1.0 - astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@25.3.5)(tsx@4.22.4)(yaml@2.9.0): + astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.3.5)(tsx@4.22.4)(yaml@2.9.0): dependencies: - '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.10.4 '@astrojs/markdown-satteri': 0.3.8 '@astrojs/telemetry': 3.3.3 @@ -4538,9 +5785,9 @@ snapshots: - uploadthing - yaml - astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0): + astro@7.2.8(@astrojs/markdown-remark@7.2.4)(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.0.1)(tsx@4.22.4)(yaml@2.9.0): dependencies: - '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.10.4 '@astrojs/markdown-satteri': 0.3.8 '@astrojs/telemetry': 3.3.3 @@ -4634,6 +5881,10 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + bcp-47-match@2.0.3: {} bcp-47@2.1.1: @@ -4642,10 +5893,21 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + binaryen@132.0.0: {} + blake3-wasm@2.1.5: {} boolbase@1.0.0: {} + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + ccount@2.0.1: {} character-entities-html4@2.1.0: {} @@ -4664,6 +5926,8 @@ snapshots: dependencies: readdirp: 5.1.1 + chownr@3.0.0: {} + ci-info@4.4.0: {} cliui@9.0.1: @@ -4680,8 +5944,21 @@ snapshots: commander@11.1.0: {} + commander@14.0.3: {} + common-ancestor-path@2.0.0: {} + componentize-qjs@0.4.5: + optionalDependencies: + '@andreiltd/componentize-qjs-binding-darwin-arm64': 0.4.5 + '@andreiltd/componentize-qjs-binding-darwin-x64': 0.4.5 + '@andreiltd/componentize-qjs-binding-linux-arm64-gnu': 0.4.5 + '@andreiltd/componentize-qjs-binding-linux-arm64-musl': 0.4.5 + '@andreiltd/componentize-qjs-binding-linux-x64-gnu': 0.4.5 + '@andreiltd/componentize-qjs-binding-linux-x64-musl': 0.4.5 + '@andreiltd/componentize-qjs-binding-win32-arm64-msvc': 0.4.5 + '@andreiltd/componentize-qjs-binding-win32-x64-msvc': 0.4.5 + cookie-es@1.2.3: {} cookie@1.1.1: {} @@ -4779,6 +6056,8 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} esast-util-from-estree@2.0.0: @@ -4890,8 +6169,12 @@ snapshots: dependencies: '@types/estree': 1.0.9 + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} + expressive-code@0.44.1: dependencies: '@expressive-code/core': 0.44.1 @@ -4919,6 +6202,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + find-proc@0.1.0: {} flattie@1.1.1: {} @@ -5157,6 +6442,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + ieee754@1.2.1: {} + inline-style-parser@0.2.7: {} iron-webcrypto@1.2.1: {} @@ -5180,6 +6467,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} jsonc-parser@2.3.1: {} @@ -5746,6 +7035,18 @@ snapshots: - bufferutil - utf-8-validate + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@3.0.1: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -5788,51 +7089,93 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - oxfmt@0.57.0: - dependencies: - tinypool: 2.1.0 + oxc-minify@0.151.0: + optionalDependencies: + '@oxc-minify/binding-android-arm-eabi': 0.151.0 + '@oxc-minify/binding-android-arm64': 0.151.0 + '@oxc-minify/binding-darwin-arm64': 0.151.0 + '@oxc-minify/binding-darwin-x64': 0.151.0 + '@oxc-minify/binding-freebsd-x64': 0.151.0 + '@oxc-minify/binding-linux-arm-gnueabihf': 0.151.0 + '@oxc-minify/binding-linux-arm-musleabihf': 0.151.0 + '@oxc-minify/binding-linux-arm64-gnu': 0.151.0 + '@oxc-minify/binding-linux-arm64-musl': 0.151.0 + '@oxc-minify/binding-linux-ppc64-gnu': 0.151.0 + '@oxc-minify/binding-linux-riscv64-gnu': 0.151.0 + '@oxc-minify/binding-linux-riscv64-musl': 0.151.0 + '@oxc-minify/binding-linux-s390x-gnu': 0.151.0 + '@oxc-minify/binding-linux-x64-gnu': 0.151.0 + '@oxc-minify/binding-linux-x64-musl': 0.151.0 + '@oxc-minify/binding-openharmony-arm64': 0.151.0 + '@oxc-minify/binding-win32-arm64-msvc': 0.151.0 + '@oxc-minify/binding-win32-ia32-msvc': 0.151.0 + '@oxc-minify/binding-win32-x64-msvc': 0.151.0 + + oxc-parser@0.76.0: + dependencies: + '@oxc-project/types': 0.76.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.57.0 - '@oxfmt/binding-android-arm64': 0.57.0 - '@oxfmt/binding-darwin-arm64': 0.57.0 - '@oxfmt/binding-darwin-x64': 0.57.0 - '@oxfmt/binding-freebsd-x64': 0.57.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 - '@oxfmt/binding-linux-arm64-gnu': 0.57.0 - '@oxfmt/binding-linux-arm64-musl': 0.57.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-musl': 0.57.0 - '@oxfmt/binding-linux-s390x-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-musl': 0.57.0 - '@oxfmt/binding-openharmony-arm64': 0.57.0 - '@oxfmt/binding-win32-arm64-msvc': 0.57.0 - '@oxfmt/binding-win32-ia32-msvc': 0.57.0 - '@oxfmt/binding-win32-x64-msvc': 0.57.0 - - oxlint@1.72.0: + '@oxc-parser/binding-android-arm64': 0.76.0 + '@oxc-parser/binding-darwin-arm64': 0.76.0 + '@oxc-parser/binding-darwin-x64': 0.76.0 + '@oxc-parser/binding-freebsd-x64': 0.76.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.76.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.76.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.76.0 + '@oxc-parser/binding-linux-arm64-musl': 0.76.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.76.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.76.0 + '@oxc-parser/binding-linux-x64-gnu': 0.76.0 + '@oxc-parser/binding-linux-x64-musl': 0.76.0 + '@oxc-parser/binding-wasm32-wasi': 0.76.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.76.0 + '@oxc-parser/binding-win32-x64-msvc': 0.76.0 + + oxfmt@0.71.0: + dependencies: + tinypool: 2.2.0 optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.72.0 - '@oxlint/binding-android-arm64': 1.72.0 - '@oxlint/binding-darwin-arm64': 1.72.0 - '@oxlint/binding-darwin-x64': 1.72.0 - '@oxlint/binding-freebsd-x64': 1.72.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 - '@oxlint/binding-linux-arm-musleabihf': 1.72.0 - '@oxlint/binding-linux-arm64-gnu': 1.72.0 - '@oxlint/binding-linux-arm64-musl': 1.72.0 - '@oxlint/binding-linux-ppc64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-musl': 1.72.0 - '@oxlint/binding-linux-s390x-gnu': 1.72.0 - '@oxlint/binding-linux-x64-gnu': 1.72.0 - '@oxlint/binding-linux-x64-musl': 1.72.0 - '@oxlint/binding-openharmony-arm64': 1.72.0 - '@oxlint/binding-win32-arm64-msvc': 1.72.0 - '@oxlint/binding-win32-ia32-msvc': 1.72.0 - '@oxlint/binding-win32-x64-msvc': 1.72.0 + '@oxfmt/binding-android-arm-eabi': 0.71.0 + '@oxfmt/binding-android-arm64': 0.71.0 + '@oxfmt/binding-darwin-arm64': 0.71.0 + '@oxfmt/binding-darwin-x64': 0.71.0 + '@oxfmt/binding-freebsd-x64': 0.71.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.71.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.71.0 + '@oxfmt/binding-linux-arm64-gnu': 0.71.0 + '@oxfmt/binding-linux-arm64-musl': 0.71.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-musl': 0.71.0 + '@oxfmt/binding-linux-s390x-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-musl': 0.71.0 + '@oxfmt/binding-openharmony-arm64': 0.71.0 + '@oxfmt/binding-win32-arm64-msvc': 0.71.0 + '@oxfmt/binding-win32-ia32-msvc': 0.71.0 + '@oxfmt/binding-win32-x64-msvc': 0.71.0 + + oxlint@1.86.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.86.0 + '@oxlint/binding-android-arm64': 1.86.0 + '@oxlint/binding-darwin-arm64': 1.86.0 + '@oxlint/binding-darwin-x64': 1.86.0 + '@oxlint/binding-freebsd-x64': 1.86.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.86.0 + '@oxlint/binding-linux-arm-musleabihf': 1.86.0 + '@oxlint/binding-linux-arm64-gnu': 1.86.0 + '@oxlint/binding-linux-arm64-musl': 1.86.0 + '@oxlint/binding-linux-ppc64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-musl': 1.86.0 + '@oxlint/binding-linux-s390x-gnu': 1.86.0 + '@oxlint/binding-linux-x64-gnu': 1.86.0 + '@oxlint/binding-linux-x64-musl': 1.86.0 + '@oxlint/binding-openharmony-arm64': 1.86.0 + '@oxlint/binding-win32-arm64-msvc': 1.86.0 + '@oxlint/binding-win32-ia32-msvc': 1.86.0 + '@oxlint/binding-win32-x64-msvc': 1.86.0 p-limit@7.3.1: dependencies: @@ -5922,10 +7265,22 @@ snapshots: process-ancestry@0.1.0: {} + process@0.11.10: {} + property-information@7.2.0: {} + punycode@2.3.1: {} + radix3@1.1.2: {} + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@4.1.2: {} readdirp@5.1.1: {} @@ -5959,6 +7314,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + regenerate-unicode-properties@10.3.0: + dependencies: + regenerate: 1.5.0 + + regenerate@1.5.0: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -5969,6 +7330,21 @@ snapshots: dependencies: regex-utilities: 2.3.0 + regexpu-core@6.4.0: + dependencies: + regenerate: 1.5.0 + regenerate-unicode-properties: 10.3.0 + regjsgen: 0.8.0 + regjsparser: 0.13.4 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.4: + dependencies: + jsesc: 3.1.0 + rehype-expressive-code@0.44.1: dependencies: expressive-code: 0.44.1 @@ -6122,6 +7498,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.5 '@rolldown/binding-win32-x64-msvc': 1.2.5 + safe-buffer@5.2.1: {} + satteri@0.10.5: dependencies: '@types/estree-jsx': 1.0.5 @@ -6275,7 +7653,7 @@ snapshots: sitemap@9.0.1: dependencies: - '@types/node': 24.13.3 + '@types/node': 24.19.0 '@types/sax': 1.2.7 arg: 5.0.2 sax: 1.6.1 @@ -6301,6 +7679,10 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -6330,6 +7712,14 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + tiny-inflate@1.0.3: {} tinyclip@0.1.15: {} @@ -6341,7 +7731,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - tinypool@2.1.0: {} + tinypool@2.2.0: {} + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 trim-lines@3.0.1: {} @@ -6365,6 +7759,31 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ufo@1.6.4: {} ultrahtml@1.7.0: {} @@ -6373,6 +7792,8 @@ snapshots: undici-types@7.18.2: {} + undici-types@7.24.6: {} + undici-types@8.3.0: {} undici@7.29.0: {} @@ -6383,6 +7804,17 @@ snapshots: dependencies: pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -6458,6 +7890,8 @@ snapshots: url-extras@0.1.0: {} + urlpattern-polyfill@10.1.0: {} + util-deprecate@1.0.2: {} vfile-location@5.0.3: @@ -6619,6 +8053,13 @@ snapshots: web-namespaces@2.0.1: {} + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + workerd@1.20260815.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260815.1 @@ -6663,6 +8104,8 @@ snapshots: y18n@5.0.8: {} + yallist@5.0.0: {} + yaml-language-server@1.23.0: dependencies: '@vscode/l10n': 0.0.18 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f42..6ffddfa99 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - packages/* + - src/bin/forge/ts/lib diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 000000000..753065179 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/src/arena.rs b/src/arena.rs new file mode 100644 index 000000000..e2097f57d --- /dev/null +++ b/src/arena.rs @@ -0,0 +1,274 @@ +//! Arenas: values of a kind kept together, and referred to by where they +//! are rather than by owning them, so that a document of any size is read +//! into a few allocations. + +use std::hash::{Hash, Hasher}; +use std::marker::PhantomData; + +/// The index of a `T` in its arena. +pub(crate) struct Id(u32, PhantomData T>); + +impl Id { + pub(crate) fn new(index: usize) -> Id { + Id( + u32::try_from(index).expect("fewer than 2^32 values of a kind"), + PhantomData, + ) + } + + pub(crate) fn index(self) -> usize { + self.0 as usize + } +} + +impl Clone for Id { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Id {} + +impl PartialEq for Id { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for Id {} + +impl Hash for Id { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +/// A string in [`Strings`]. It is not comparable, since two equal strings +/// can be at different places: compare what [`Strings::get`] returns. +#[derive(Clone, Copy, Default)] +pub(crate) struct Str { + start: u32, + len: u32, +} + +impl Str { + pub(crate) fn is_empty(self) -> bool { + self.len == 0 + } + + /// Returns the `len` bytes of the string from byte `offset`. + fn slice(self, offset: usize, len: usize) -> Str { + debug_assert!(offset + len <= self.len as usize); + Str { + start: self.start + offset as u32, + len: len as u32, + } + } +} + +/// Strings, one after another in one buffer. +#[derive(Default)] +pub(crate) struct Strings { + buf: String, +} + +impl Strings { + /// Adds `s`, and returns where it is. + pub(crate) fn push(&mut self, s: &str) -> Str { + if s.is_empty() { + return Str::default(); + } + let start = self.buf.len(); + self.buf.push_str(s); + let end = u32::try_from(self.buf.len()).expect("fewer than 4 GiB of strings"); + Str { + start: start as u32, + len: end - start as u32, + } + } + + /// Adds `s`, and returns each part of it between `separator`s, as + /// [`str::split`] does, as a list in `list`. + pub(crate) fn push_split(&mut self, s: &str, separator: char, list: &mut List) -> Span { + let whole = self.push(s); + let start = list.start(); + let mut offset = 0; + for part in s.split(separator) { + list.push(whole.slice(offset, part.len())); + offset += part.len() + separator.len_utf8(); + } + list.finish(start) + } + + pub(crate) fn get(&self, s: Str) -> &str { + let start = s.start as usize; + &self.buf[start..start + s.len as usize] + } +} + +/// A list of `T` in a [`List`]. +pub(crate) struct Span { + start: u32, + len: u32, + ty: PhantomData T>, +} + +impl Span { + pub(crate) fn is_empty(self) -> bool { + self.len == 0 + } +} + +impl Clone for Span { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Span {} + +impl Default for Span { + fn default() -> Self { + Span { + start: 0, + len: 0, + ty: PhantomData, + } + } +} + +/// An arena of lists of `T`, each one a [`Span`] of `items`. +/// +/// A list can be added whole, with [`extend`](List::extend), or built an +/// item at a time. One built an item at a time is kept in `scratch` and +/// moved to `items` once it is done, so that lists can be built while +/// others are, as the values of a document nest. Each list must be +/// finished before the one it is inside. +pub(crate) struct List { + items: Vec, + scratch: Vec, +} + +impl Default for List { + fn default() -> Self { + List { + items: Vec::new(), + scratch: Vec::new(), + } + } +} + +impl List { + /// Starts a list, and returns what to finish it with. + pub(crate) fn start(&self) -> usize { + self.scratch.len() + } + + /// Adds `item` to the list started last. + pub(crate) fn push(&mut self, item: T) { + self.scratch.push(item); + } + + /// Finishes the list `start` started, and returns it. + pub(crate) fn finish(&mut self, start: usize) -> Span { + if self.scratch.len() == start { + return Span::default(); + } + let begin = self.items.len(); + self.items.extend(self.scratch.drain(start..)); + self.span(begin) + } + + /// Adds the list of `items`, and returns it. + pub(crate) fn extend(&mut self, items: impl IntoIterator) -> Span { + let begin = self.items.len(); + self.items.extend(items); + if self.items.len() == begin { + return Span::default(); + } + self.span(begin) + } + + /// Returns the items from `begin` to the end. + fn span(&self, begin: usize) -> Span { + let end = u32::try_from(self.items.len()).expect("fewer than 2^32 items of a kind"); + Span { + start: begin as u32, + len: end - begin as u32, + ty: PhantomData, + } + } + + pub(crate) fn get(&self, span: Span) -> &[T] { + let start = span.start as usize; + &self.items[start..start + span.len as usize] + } + + pub(crate) fn get_mut(&mut self, span: Span) -> &mut [T] { + let start = span.start as usize; + &mut self.items[start..start + span.len as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strings_are_kept_apart() { + let mut strings = Strings::default(); + let a = strings.push("ab"); + let empty = strings.push(""); + let b = strings.push("c"); + + assert_eq!([strings.get(a), strings.get(empty), strings.get(b)], ["ab", "", "c"]); + } + + #[test] + fn split_strings_are_parts_of_the_string() { + let mut strings = Strings::default(); + let mut list = List::default(); + let parts = strings.push_split("a..bc", '.', &mut list); + let parts: Vec<&str> = list.get(parts).iter().map(|&s| strings.get(s)).collect(); + + assert_eq!(parts, ["a", "", "bc"]); + } + + #[test] + fn lists_built_inside_others_are_kept_apart() { + let mut list = List::default(); + let outer = list.start(); + list.push(1); + let inner = list.start(); + list.push(2); + list.push(3); + let inner = list.finish(inner); + list.push(4); + let outer = list.finish(outer); + + assert_eq!(list.get(inner), [2, 3]); + assert_eq!(list.get(outer), [1, 4]); + } + + #[test] + fn lists_added_whole_are_kept_apart() { + let mut list = List::default(); + let a = list.extend([1, 2]); + let b = list.extend([3]); + list.get_mut(a)[0] = 0; + + assert_eq!(list.get(a), [0, 2]); + assert_eq!(list.get(b), [3]); + } + + #[test] + fn an_empty_list_is_empty() { + let mut list: List = List::default(); + let start = list.start(); + let built = list.finish(start); + let added = list.extend([]); + + assert!(built.is_empty() && added.is_empty()); + assert!(list.get(built).is_empty()); + } +} diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs new file mode 100644 index 000000000..6781dd8ea --- /dev/null +++ b/src/bin/forge/main.rs @@ -0,0 +1,114 @@ +//! The `forge` command, which generates an SDK from an OpenAPI document. +//! +//! ```text +//! forge openapi.json --target ts --out sdk +//! forge openapi.json --target wasm://plugin.wasm --out sdk +//! ``` + +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::Parser; +use forge::{Err, Forge}; + +mod ts; + +/// Generates an SDK from an OpenAPI document. +#[derive(Parser)] +#[command(version)] +struct Args { + /// The OpenAPI JSON document, or - to read it from standard input. + spec: PathBuf, + /// The language of the SDK: `ts` for TypeScript, or `wasm://PATH` for + /// the plugin compiled to a WebAssembly component at PATH. + #[arg(long, value_parser = Target::parse)] + target: Target, + /// The directory to write the SDK to. + #[arg(long)] + out: PathBuf, +} + +/// What forge generates an SDK with. +#[derive(Clone)] +enum Target { + /// The TypeScript generator. + Ts, + /// A plugin compiled to a WebAssembly component, at the path. + #[cfg(feature = "wasm")] + Wasm(PathBuf), +} + +impl Target { + fn parse(s: &str) -> Result { + if s == "ts" { + return Ok(Target::Ts); + } + let Some(path) = s.strip_prefix("wasm://") else { + return Err("possible values: ts, wasm://PATH".to_owned()); + }; + if path.is_empty() { + return Err("wasm:// needs the path of a plugin, as in wasm://plugin.wasm".to_owned()); + } + #[cfg(feature = "wasm")] + return Ok(Target::Wasm(PathBuf::from(path))); + #[cfg(not(feature = "wasm"))] + Err("this forge was built without wasm plugins; build it with `--features wasm`".to_owned()) + } +} + +fn main() -> ExitCode { + match run(Args::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("forge: {err}"); + ExitCode::FAILURE + } + } +} + +/// Generates the SDK that `args` ask for. +fn run(args: Args) -> Result<(), Err> { + match args.target { + Target::Ts => { + let forge = read(&args.spec)?; + forge::write(&args.out, forge::run(&forge, &ts::Generator)?) + } + #[cfg(feature = "wasm")] + Target::Wasm(path) => { + // Compiling the plugin and reading the document are both slow, + // so they run at the same time. + let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(&args.spec)); + let (plugin, forge) = (plugin?, forge?); + forge::write(&args.out, forge::run(&forge, &plugin)?) + } + } +} + +/// Reads the document at `path`, or from standard input when `path` is `-`. +fn read(path: &Path) -> Result { + let parse = || -> Result { + let json = if path == Path::new("-") { + let mut json = Vec::new(); + io::stdin().read_to_end(&mut json)?; + json + } else { + fs::read(path)? + }; + Ok(serde_json::from_slice(&json)?) + }; + parse().map_err(|err| format!("{}: {err}", path.display()).into()) +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + use super::Args; + + #[test] + fn args() { + Args::command().debug_assert(); + } +} diff --git a/src/bin/forge/ts.rs b/src/bin/forge/ts.rs new file mode 100644 index 000000000..7e35e516c --- /dev/null +++ b/src/bin/forge/ts.rs @@ -0,0 +1,199 @@ +//! The TypeScript SDK generator, ported from Go's `internal/ts` and +//! following the specs in specs/ts. +//! +//! Every group gets a client.ts, a models.ts and an index.ts in its +//! directory. The root also gets the runtime library, in `_/`. + +use std::borrow::Cow; + +use forge::ts::{self, Ident, Module, PREAMBLE}; +use forge::{Err, Forge, Group, Method, Model, Plugin}; + +mod client; +mod codec; +mod index; +mod models; +mod operation; +mod types; + +/// Generates a TypeScript SDK. +pub struct Generator; + +impl Plugin for Generator { + type File = File; + + /// Returns the files of the root, and the runtime library. + fn generate(&self, forge: &Forge) -> Result, Err> { + let mut files = files(Node::Root(forge))?; + files.extend(RUNTIME.iter().map(|&(path, source)| File::Runtime { + path: RUNTIME_DIR.sub(path).as_str().to_owned(), + content: format!("{PREAMBLE}{source}"), + })); + Ok(files.into_iter()) + } + + fn generate_group(&self, group: Group<'_>) -> Result, Err> { + Ok(files(Node::Group(group))?.into_iter()) + } +} + +/// Returns the client.ts, models.ts and index.ts of `node`. A node without +/// models has an empty models.ts, which isn't written. +fn files(node: Node<'_>) -> Result, Err> { + let models = models::models(node).map_err(|errs| errs.join("\n"))?; + Ok(vec![ + File::Ts(client::client(node)), + File::Ts(models), + File::Ts(index::index(node)), + ]) +} + +/// A file of the SDK. +// TODO: Replace with `ts::File` once it can hold fixed content, such as a +// constructor that takes the text to write after the preamble. +pub enum File { + /// A generated file. + Ts(ts::File), + /// A file of the runtime library, after the preamble. + Runtime { path: String, content: String }, +} + +impl forge::File for File { + fn path(&self) -> &str { + match self { + File::Ts(f) => forge::File::path(f), + File::Runtime { path, .. } => path, + } + } + + fn content(&self) -> Result, Err> { + match self { + File::Ts(f) => forge::File::content(f), + File::Runtime { content, .. } => Ok(Cow::Borrowed(content.as_bytes())), + } + } +} + +/// The root, or a group: what has a client.ts, models.ts and index.ts. +// TODO: Remove once the library exposes the root as a `Group`, which +// `Plugin::generate` could pass in. Then every function here takes a +// `Group`. +#[derive(Clone, Copy)] +enum Node<'f> { + Root(&'f Forge), + Group(Group<'f>), +} + +impl<'f> Node<'f> { + /// Returns the directory of the node's files. + fn dir(self) -> Module { + match self { + Node::Root(_) => Module::ROOT, + Node::Group(group) => Module::group(group), + } + } + + /// Returns how many groups deep the node is: 0 for the root. + fn depth(self) -> usize { + match self { + Node::Root(_) => 0, + Node::Group(group) => group.path().len(), + } + } + + /// Returns the names of the groups down to the node, in camelCase and + /// separated by dots, for errors. + fn path(self) -> String { + match self { + Node::Root(_) => String::new(), + Node::Group(group) => group.path().map(|name| name.camel()).collect::>().join("."), + } + } + + /// Returns the methods directly in the node, sorted by name. The root + /// has none. + fn methods(self) -> Vec> { + match self { + Node::Root(_) => Vec::new(), + Node::Group(group) => group.methods().collect(), + } + } + + /// Returns the groups directly in the node, sorted by name. + fn children(self) -> Vec> { + match self { + Node::Root(forge) => forge.groups().filter(|g| g.parent().is_none()).collect(), + Node::Group(group) => group.children().collect(), + } + } + + /// Returns the models placed directly in the node, sorted by schema + /// name. + fn models(self) -> Vec> { + let mut models: Vec> = match self { + Node::Root(forge) => forge.models().collect(), + Node::Group(group) => group.models().collect(), + }; + models.sort_by_key(|m| m.schema_name()); + models + } +} + +/// The directory of the runtime library. +const RUNTIME_DIR: Module = Module::new("_"); + +/// The module of the runtime library that exports it. +const RUNTIME_INDEX: Module = Module::new("_/index.ts"); + +/// Returns the identifier `name` that the runtime library exports. +fn runtime(name: &'static str) -> Ident { + RUNTIME_INDEX.ident(name) +} + +/// Returns the type of the options of a call. +fn request_options() -> Ident { + runtime("RequestOptions").ty() +} + +/// The sources of the runtime library, by path in lib/src +/// (specs/12-runtime-library.md). +const RUNTIME: [(&str, &str); 5] = [ + ("codec.ts", include_str!("ts/lib/src/codec.ts")), + ("error.ts", include_str!("ts/lib/src/error.ts")), + ("hooks/envelope.ts", include_str!("ts/lib/src/hooks/envelope.ts")), + ("index.ts", include_str!("ts/lib/src/index.ts")), + ("transport.ts", include_str!("ts/lib/src/transport.ts")), +]; + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use super::RUNTIME; + + /// Every source of the runtime library is embedded. + #[test] + fn runtime_is_complete() { + fn walk(dir: &Path, root: &Path, paths: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, root, paths); + } else { + let rel = path.strip_prefix(root).unwrap(); + let parts: Vec<_> = rel.iter().map(|part| part.to_str().unwrap()).collect(); + paths.push(parts.join("/")); + } + } + } + let root: PathBuf = [env!("CARGO_MANIFEST_DIR"), "src/bin/forge/ts/lib/src"] + .iter() + .collect(); + let mut paths = Vec::new(); + walk(&root, &root, &mut paths); + paths.sort(); + let embedded: Vec<&str> = RUNTIME.iter().map(|&(path, _)| path).collect(); + assert_eq!(paths, embedded); + } +} diff --git a/src/bin/forge/ts/client.rs b/src/bin/forge/ts/client.rs new file mode 100644 index 000000000..f91d5adb4 --- /dev/null +++ b/src/bin/forge/ts/client.rs @@ -0,0 +1,511 @@ +//! The client.ts of a group: its client (specs/07-client-surface.md), and +//! the requests (specs/05-requests.md), responses (specs/06-responses.md) +//! and operations of its methods. + +use std::collections::{HashMap, HashSet}; + +use forge::ts::{File, Fragment, Ident, Module, frag, p}; +use forge::{Field, Group, Location, Method, Model, Type}; + +use super::models::print_fields; +use super::operation::operation; +use super::types::{ + Prec, all_optional, alternatives, doc, has_fields, identifier, js_string, key, object_like, typ, valid_name, +}; +use super::{Node, request_options, runtime}; + +/// Returns the client.ts of `node`: its ClientOptions and Client, +/// createClient and bind, the request and response types of each method, +/// then the operation of each method. The library has already rejected +/// methods and groups with no name and duplicate methods, and equal group +/// names are one group. +pub(super) fn client(node: Node<'_>) -> File { + let groups = node.children(); + let group_names: HashMap> = groups.iter().map(|&sub| (group_name(sub), sub)).collect(); + + // A method that is not JSON is left out (specs/13-non-json-operations.md). + let methods: Vec<(Method<'_>, Request<'_>)> = node + .methods() + .into_iter() + .filter(|&m| !skipped(m)) + .map(|m| (m, Request::new(m))) + .collect(); + + let mut f = File::new(node.dir().sub("client.ts")); + client_options(&mut f); + p!(f); + client_interface(&mut f, &methods, &groups, &group_names); + p!(f); + p!( + f, + "export function ", + f.ident("createClient"), + "(options: ", + f.ident("ClientOptions"), + "): ", + f.ident("Client"), + " {" + ); + f.indent(|f| { + p!( + f, + "return ", + f.ident("bind"), + "(new ", + runtime("Transport"), + "(options));" + ); + }); + p!(f, "}"); + p!(f); + bind(&mut f, &methods, &groups, &group_names); + for (m, request) in &methods { + p!(f); + request_type(&mut f, *m, request); + p!(f); + response_type(&mut f, *m); + } + for (m, request) in &methods { + p!(f); + operation(&mut f, *m, request); + } + f +} + +/// Declares the ClientOptions of a group: the options every client takes, +/// and baseUrl, which is required. +fn client_options(f: &mut File) { + p!( + f, + "export interface ", + f.ident("ClientOptions"), + " extends ", + runtime("BaseClientOptions").ty(), + " {" + ); + f.indent(|f| { + f.doc("The URL the paths of the API are relative to."); + p!(f, "baseUrl: string;"); + }); + p!(f, "}"); +} + +/// Declares bind, which returns the Client of a group on a transport: each +/// method sends its operation, and each subgroup is a getter that binds the +/// subgroup's Client the first time it is read. A method and a subgroup +/// with the same name share a getter, of the method with the members of the +/// subgroup's Client. +fn bind( + f: &mut File, + methods: &[(Method<'_>, Request<'_>)], + groups: &[Group<'_>], + group_names: &HashMap>, +) { + let shared: HashMap> = methods + .iter() + .map(|&(m, _)| (method_name(m), m)) + .filter(|(name, _)| group_names.contains_key(name)) + .collect(); + p!( + f, + "export function ", + f.ident("bind"), + "(transport: ", + runtime("Transport"), + "): ", + f.ident("Client"), + " {" + ); + f.indent(|f| { + for &sub in groups { + let name = group_name(sub); + match shared.get(&name) { + Some(&m) => { + let method = js_string(&method_name(m)); + p!( + f, + "let ", + name, + "Client: ", + f.ident("Client"), + "[", + method, + "] | undefined;" + ); + } + None => p!(f, "let ", name, "Client: ", client_ident(sub), " | undefined;"), + } + } + p!(f, "return {"); + f.indent(|f| { + for &(m, _) in methods { + let name = method_name(m); + if !shared.contains_key(&name) { + p!(f, key(&name), ": (request, options) => ", send(f, m), ","); + } + } + for &sub in groups { + let name = group_name(sub); + p!(f, "get ", key(&name), "() {"); + f.indent(|f| match shared.get(&name) { + Some(&m) => { + p!(f, "return (", name, "Client ??= Object.assign("); + f.indent(|f| { + p!( + f, + "(request: ", + request_ident(f, m), + ", options?: ", + request_options(), + "): ", + result(f, m), + " => ", + send(f, m), + "," + ); + p!(f, bind_ident(sub), "(transport),"); + }); + p!(f, "));"); + } + None => p!(f, "return (", name, "Client ??= ", bind_ident(sub), "(transport));"), + }); + p!(f, "},"); + } + }); + p!(f, "};"); + }); + p!(f, "}"); +} + +/// Returns the call that sends the operation of `m`. +fn send(f: &File, m: Method<'_>) -> Fragment { + frag!("transport.send(", operation_ident(f, m), ", request, options)") +} + +/// Returns the bind of group `g`, imported under the name of the group. +fn bind_ident(g: Group<'_>) -> Ident { + let alias = format!("bind{}", identifier(g.name().pascal())); + Module::group(g).sub("client.ts").ident("bind").alias(alias) +} + +/// Returns the Client of group `g`, imported under the name of the group. +fn client_ident(g: Group<'_>) -> Ident { + let alias = format!("{}Client", identifier(g.name().pascal())); + Module::group(g).sub("client.ts").ident("Client").ty().alias(alias) +} + +/// Declares the Client of a group: its methods, then its subgroups, which +/// `group_names` has by name. A method and a subgroup with the same name +/// share the property, which is a function with the members of the +/// subgroup's Client. +fn client_interface( + f: &mut File, + methods: &[(Method<'_>, Request<'_>)], + groups: &[Group<'_>], + group_names: &HashMap>, +) { + if methods.is_empty() && groups.is_empty() { + p!(f, "export interface ", f.ident("Client"), " {}"); + return; + } + p!(f, "export interface ", f.ident("Client"), " {"); + let mut shared = HashSet::new(); + f.indent(|f| { + for (m, request) in methods { + let m = *m; + f.doc(&doc(&method_doc(m), m.is_deprecated())); + let name = method_name(m); + let params = method_params(f, m, request); + match group_names.get(&name) { + None => p!(f, key(&name), "(", params, "): ", result(f, m), ";"), + Some(&sub) => { + p!( + f, + "readonly ", + key(&name), + ": ((", + params, + ") => ", + result(f, m), + ") & ", + client_ident(sub), + ";" + ); + shared.insert(name); + } + } + } + for &sub in groups { + let name = group_name(sub); + if !shared.contains(&name) { + p!(f, "readonly ", key(&name), ": ", client_ident(sub), ";"); + } + } + }); + p!(f, "}"); +} + +/// Returns the parameters of `m`: its request, which is optional when an +/// empty object is a request, and the options of the call. +fn method_params(f: &File, m: Method<'_>, request: &Request<'_>) -> Fragment { + frag!( + "request", + if request.optional() { "?: " } else { ": " }, + request_ident(f, m), + ", options?: ", + request_options() + ) +} + +/// Returns what `m` returns: a promise of its response. +fn result(f: &File, m: Method<'_>) -> Fragment { + frag!("Promise<", response_ident(f, m), ">") +} + +/// Declares the request type of `m`: its parameters, then its body. A flat +/// body sits next to the parameters, in an interface when it is an object +/// type, and intersected with them otherwise. Any other body is the field +/// `body`. +fn request_type(f: &mut File, m: Method<'_>, request: &Request<'_>) { + let name = request_ident(f, m); + let params = &request.params; + if let Some(body) = &request.body + && request.flat + && !has_fields(body.ty) + { + if params.is_empty() { + p!(f, "export type ", name, " = ", typ(body.ty, Prec::Top), ";"); + return; + } + p!(f, "export type ", name, " = {"); + print_params(f, params); + p!(f, "} & ", typ(body.ty, Prec::Intersection), ";"); + return; + } + if params.is_empty() && request.body.is_none() { + p!(f, "export interface ", name, " {}"); + return; + } + p!(f, "export interface ", name, " {"); + print_params(f, params); + if let Some(body) = &request.body { + match body.ty { + Type::Object(object) if request.flat => print_fields(f, object), + ty => f.indent(|f| { + f.doc(body.description); + p!( + f, + "body", + if body.required { ": " } else { "?: " }, + typ(ty, Prec::Top), + ";" + ); + }), + } + } + p!(f, "}"); +} + +/// Declares the response type of `m`: void when no success response has a +/// body, an interface when the only one is an object type, and otherwise a +/// union of the bodies, with undefined when a success response can have +/// none. +fn response_type(f: &mut File, m: Method<'_>) { + let name = response_ident(f, m); + let (types, optional) = response(m); + match types[..] { + [] => p!(f, "export type ", name, " = void;"), + [Type::Object(object)] if !optional && has_fields(types[0]) => { + p!(f, "export interface ", name, " {"); + print_fields(f, object); + p!(f, "}"); + } + _ if optional => p!(f, "export type ", name, " = ", alternatives(types), " | undefined;"), + [ty] => p!(f, "export type ", name, " = ", typ(ty, Prec::Top), ";"), + _ => p!(f, "export type ", name, " = ", alternatives(types), ";"), + } +} + +/// Returns the types of the JSON bodies of the success responses of `m`, +/// each model once, and whether a success response can have no body. +pub(super) fn response(m: Method<'_>) -> (Vec>, bool) { + let mut types = Vec::new(); + let mut optional = false; + let mut seen: HashSet> = HashSet::new(); + for response in m.responses() { + match response.json().map(|content| content.ty()) { + None => optional = true, + Some(Type::Model(model)) if !seen.insert(model) => {} + Some(ty) => types.push(ty), + } + } + (types, optional) +} + +/// The request of a method: its parameters that have a field, and its JSON +/// body. +pub(super) struct Request<'f> { + /// The parameters, path parameters first. + pub(super) params: Vec>, + pub(super) body: Option>, + /// Whether the body's fields sit next to the parameters, rather than in + /// the field `body`. + pub(super) flat: bool, +} + +/// A parameter of a method, and the name of its field in the request. +pub(super) struct Param<'f> { + pub(super) field: Field<'f>, + pub(super) name: String, + pub(super) location: Location, +} + +/// The JSON request body of a method. +pub(super) struct Body<'f> { + pub(super) ty: Type<'f>, + pub(super) media_type: &'f str, + pub(super) required: bool, + pub(super) description: &'f str, +} + +impl<'f> Request<'f> { + /// Returns the request of `m`, as [`Method::request`] lays it out. A + /// query parameter whose name is not valid has no field, and one named + /// like another parameter gets a `$`. A parameter named `body` next to a + /// body that is not flat gets a `$` too. + fn new(m: Method<'f>) -> Request<'f> { + let request = m.request(); + let object = match request { + Some(Type::Object(object)) => Some(object), + Some(Type::Intersection(intersection)) => match intersection.members().next() { + Some(Type::Object(object)) => Some(object), + _ => None, + }, + _ => None, + }; + let located: Vec<(Field<'f>, Location)> = object + .into_iter() + .flat_map(|object| object.fields()) + .filter_map(|field| Some((field, m.location(field)?))) + .collect(); + + let body = m.body().and_then(|body| { + let content = body.json()?; + Some(Body { + ty: content.ty(), + media_type: content.media_type(), + required: body.is_required(), + description: body.description(), + }) + }); + let flat = match &body { + None => false, + Some(body) if located.is_empty() => object_like(body.ty, &mut HashSet::new()), + Some(_) => matches!(request, Some(Type::Intersection(_))), + }; + + let mut taken = HashSet::new(); + let mut params = Vec::new(); + for (field, location) in located { + if matches!(location, Location::Query { .. }) && !valid_name(field.wire_name()) { + continue; + } + let mut name = field.name().camel(); + if !flat && name == "body" { + name.push('$'); + } + while !taken.insert(name.clone()) { + name.push('$'); + } + params.push(Param { field, name, location }); + } + Request { params, body, flat } + } + + /// Reports whether the request can be left out: no parameter is + /// required, and an empty object is a body. + fn optional(&self) -> bool { + let params = !self.params.iter().any(|p| p.field.is_required()); + let body = match &self.body { + None => true, + Some(body) if self.flat => all_optional(body.ty), + Some(body) => !body.required, + }; + params && body + } +} + +/// Prints the parameters of a request, each on a line of its own after its +/// documentation. +fn print_params(f: &mut File, params: &[Param<'_>]) { + f.indent(|f| { + for param in params { + let field = param.field; + f.doc(&doc(field.description(), field.is_deprecated())); + p!( + f, + key(¶m.name), + if field.is_required() { ": " } else { "?: " }, + typ(field.ty(), Prec::Top), + ";" + ); + } + }); +} + +/// Returns the request and response types of `m`, which are named after it. +fn request_ident(f: &File, m: Method<'_>) -> Ident { + f.ident(request_name(m)) +} + +fn response_ident(f: &File, m: Method<'_>) -> Ident { + f.ident(response_name(m)) +} + +pub(super) fn request_name(m: Method<'_>) -> String { + format!("{}Request", identifier(m.name().pascal())) +} + +pub(super) fn response_name(m: Method<'_>) -> String { + format!("{}Response", identifier(m.name().pascal())) +} + +/// Returns the operation of `m`, which is named after it. +pub(super) fn operation_ident(f: &File, m: Method<'_>) -> Ident { + f.ident(format!("{}Operation", identifier(m.name().pascal()))) +} + +/// Returns the property of a method and a group in the Client. +fn method_name(m: Method<'_>) -> String { + identifier(m.name().camel()) +} + +fn group_name(g: Group<'_>) -> String { + identifier(g.name().camel()) +} + +/// Returns the documentation of `m`: its summary, then its description. +fn method_doc(m: Method<'_>) -> String { + let mut text = m.summary().trim().to_owned(); + let description = m.description().trim(); + if !description.is_empty() && description != text { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(description); + } + text +} + +/// Reports whether `m` is left out of the SDK: its request body, or a +/// success response, has content, and none of it is JSON +/// (specs/13-non-json-operations.md). +pub(super) fn skipped(m: Method<'_>) -> bool { + if let Some(body) = m.body() + && body.json().is_none() + && body.contents().len() > 0 + { + return true; + } + m.responses().any(|r| r.json().is_none() && r.contents().len() > 0) +} diff --git a/src/bin/forge/ts/codec.rs b/src/bin/forge/ts/codec.rs new file mode 100644 index 000000000..43a8fe71b --- /dev/null +++ b/src/bin/forge/ts/codec.rs @@ -0,0 +1,265 @@ +//! Codecs, following specs/09-case-conversion.md. +//! +//! A type needs a codec when a value of it has a key to rename, or a date, +//! bytes or int64 value to convert, at any depth. A model's codec is +//! declared after the models of its file, unless it is a single other +//! codec, such as that of a date or of another model. Its uses then refer to +//! that codec instead, so that no codec is another codec declared later in +//! the file. + +use std::collections::HashSet; + +use forge::ts::{File, Fragment, Ident, frag, join, p}; +use forge::{Discriminator, Field, Model, Number, Scalar, Type}; + +use super::types::{elem, fields, has_fields, js_string, kept, key}; +use super::{RUNTIME_INDEX, runtime}; + +/// Reports whether model `m` declares a codec: it needs one, and its codec +/// is not a single other codec. +pub(super) fn declares_codec(m: Model<'_>) -> bool { + needs_codec(m.ty(), &mut HashSet::new()) && codec_of(m) == own_codec(m) +} + +/// Declares the codec of model `m`, which declares one: an object codec +/// with an entry on each line, or any other codec on one line. +pub(super) fn model_codec(f: &mut File, m: Model<'_>) { + let ty = m.ty(); + let object = match ty { + Type::Object(object) if has_fields(ty) => object, + _ => { + p!(f, "export const ", own_codec(m), " = ", codec(ty, false), ";"); + return; + } + }; + p!(f, "export const ", own_codec(m), " = ", runtime("object"), "(["); + f.indent(|f| { + for field in fields(object) { + let entry = entry(field); + if !entry.is_empty() { + p!(f, entry, ","); + } + } + }); + p!(f, "]);"); +} + +/// Returns the codec of `t`, or an empty fragment when `t` needs none. +/// +/// A model's codec is referred to through a function, `() => codec`, so +/// that codecs can refer to each other in any order. When `direct`, the +/// codec itself refers to the model's codec directly, as an operation does, +/// since it is built after the models it uses. +pub(super) fn codec(t: Type<'_>, direct: bool) -> Fragment { + match t { + Type::Scalar(scalar) => match scalar_codec(scalar) { + Some(id) => frag!(id), + None => frag!(), + }, + Type::Model(m) if needs_codec(t, &mut HashSet::new()) => codec_ref(m, direct), + Type::Array(array) => match codec(array.elem(), false) { + elem if elem.is_empty() => elem, + elem => frag!(runtime("array"), "(", elem, ")"), + }, + Type::Object(object) if !has_fields(t) => match codec(elem(object), false) { + elem if elem.is_empty() => elem, + elem => frag!(runtime("map"), "(", elem, ")"), + }, + Type::Object(object) => { + let entries = join(", ", fields(object).into_iter().map(entry)); + if entries.is_empty() { + entries + } else { + frag!(runtime("object"), "([", entries, "])") + } + } + Type::Union(union) => match discriminated(t) { + Some(d) => discriminator(d), + None => members_codec(union.members(), "union", direct), + }, + Type::Intersection(intersection) => members_codec(intersection.members(), "intersection", direct), + Type::Model(_) | Type::Enum(_) => frag!(), + } +} + +/// Returns the codec of a union or intersection of `members`, which runtime +/// function `function` combines: the codec of the one member that needs +/// one, or of all of them that do. +pub(super) fn members_codec<'f>( + members: impl IntoIterator>, + function: &'static str, + direct: bool, +) -> Fragment { + let members: Vec> = members + .into_iter() + .filter(|&m| needs_codec(m, &mut HashSet::new())) + .collect(); + match members[..] { + [] => frag!(), + [only] => codec(only, direct), + _ => { + let codecs = join(", ", members.into_iter().map(|m| codec(m, false))); + frag!(runtime(function), "([", codecs, "])") + } + } +} + +/// Returns the codec of a scalar the runtime converts, or `None`. +fn scalar_codec(scalar: Scalar) -> Option { + let name = match scalar { + Scalar::DateTime => "instant", + Scalar::Date => "date", + Scalar::Bytes => "bytes", + Scalar::Number(Number::Int64) => "int64", + // A JSON string with format `binary` is kept as the string it is + // on the wire; see `scalar_type`. + Scalar::Binary => return None, + _ => return None, + }; + Some(runtime(name)) +} + +/// Returns the entry of a property in an object codec: its wire name, then +/// its field name when it is renamed, then the codec of its value when it is +/// converted. It returns an empty fragment when neither is needed. +fn entry(field: Field<'_>) -> Fragment { + let name = field.name().camel(); + let renamed = name != field.wire_name(); + let codec = codec(field.ty(), false); + if !kept(field) || !renamed && codec.is_empty() { + return frag!(); + } + frag!( + "[", + join!( + ", ", + js_string(field.wire_name()), + if renamed { js_string(&name) } else { "null".into() }, + codec + ), + "]" + ) +} + +/// Returns the codec of a union that a property discriminates: the codec of +/// each case that needs one, by the value of the property. +fn discriminator(d: Discriminator<'_>) -> Fragment { + let name = d.name().camel(); + let mut cases: Vec<(&str, Model<'_>)> = d + .cases() + .filter_map(|(value, ty)| match ty { + Type::Model(m) if needs_codec(ty, &mut HashSet::new()) => Some((value, m)), + _ => None, + }) + .collect(); + cases.sort_by_key(|&(value, _)| value); + let cases = join( + ", ", + cases + .into_iter() + .map(|(value, m)| frag!(key(value), ": ", codec_ref(m, false))), + ); + frag!( + runtime("discriminated"), + "(", + js_string(d.wire_name()), + ", ", + if name == d.wire_name() { + "null".into() + } else { + js_string(&name) + }, + ", { ", + cases, + " })", + ) +} + +/// Returns the discriminator of `t` when its codec converts by it: every +/// member is a model, and a case needs a codec. +fn discriminated(t: Type<'_>) -> Option> { + let Type::Union(union) = t else { return None }; + let d = union.discriminator()?; + let models = union.members().all(|m| matches!(m, Type::Model(_))); + let converts = d.cases().any(|(_, ty)| needs_codec(ty, &mut HashSet::new())); + (models && converts).then_some(d) +} + +/// Reports whether `t` needs a codec. `seen` has the models already looked +/// at, whose codecs are needed only for what else is reached. +pub(super) fn needs_codec<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { + match t { + Type::Scalar(scalar) => scalar_codec(scalar).is_some(), + Type::Model(m) => seen.insert(m) && needs_codec(m.ty(), seen), + Type::Array(array) => needs_codec(array.elem(), seen), + Type::Object(object) => { + let converts = |field: Field<'f>, seen: &mut HashSet>| { + kept(field) && (field.name().camel() != field.wire_name() || needs_codec(field.ty(), seen)) + }; + for field in object.fields() { + if converts(field, seen) { + return true; + } + } + !has_fields(t) && needs_codec(elem(object), seen) + } + Type::Union(union) => union.members().any(|m| needs_codec(m, seen)), + Type::Intersection(intersection) => intersection.members().any(|m| needs_codec(m, seen)), + Type::Enum(_) => false, + } +} + +/// Refers to the codec of model `m`, which needs one: a codec of the +/// runtime library directly, and a model's through a function unless +/// `direct`. +fn codec_ref(m: Model<'_>, direct: bool) -> Fragment { + let id = codec_of(m); + if direct || id.module == RUNTIME_INDEX { + frag!(id) + } else { + frag!("() => ", id) + } +} + +/// Returns the codec model `m` declares, named after it. +fn own_codec(m: Model<'_>) -> Ident { + let id = Ident::model(m); + id.module.ident(format!("{}Codec", id.name)) +} + +/// Returns the codec that stands for model `m`, which needs one: the single +/// codec its type is, or else its own. +fn codec_of(m: Model<'_>) -> Ident { + target_codec(m, &mut HashSet::new()) +} + +fn target_codec<'f>(m: Model<'f>, visiting: &mut HashSet>) -> Ident { + if visiting.insert(m) + && let Some(id) = single_codec(m.ty(), visiting) + { + return id; + } + own_codec(m) +} + +/// Returns the codec of `t` when it is a single codec: that of a date, +/// bytes or int64 value, or of a model, or of the one member of a union or +/// intersection that needs a codec. +fn single_codec<'f>(t: Type<'f>, visiting: &mut HashSet>) -> Option { + let members: Vec> = match t { + Type::Scalar(scalar) => return scalar_codec(scalar), + Type::Model(m) => { + return needs_codec(t, &mut HashSet::new()).then(|| target_codec(m, visiting)); + } + Type::Union(_) if discriminated(t).is_some() => return None, + Type::Union(union) => union.members().collect(), + Type::Intersection(intersection) => intersection.members().collect(), + _ => return None, + }; + let mut converted = members.into_iter().filter(|&m| needs_codec(m, &mut HashSet::new())); + let only = converted.next()?; + if converted.next().is_some() { + return None; + } + single_codec(only, visiting) +} diff --git a/src/bin/forge/ts/index.rs b/src/bin/forge/ts/index.rs new file mode 100644 index 000000000..339960bc5 --- /dev/null +++ b/src/bin/forge/ts/index.rs @@ -0,0 +1,71 @@ +//! The index.ts of a group, its public surface (specs/01-output-layout.md). + +use std::collections::HashSet; + +use forge::ts::{File, Ident, p}; + +use super::Node; +use super::client::{request_name, response_name, skipped}; + +/// Returns the index.ts of `node`: ApiError and RequestOptions from the +/// runtime library, so that every entry point is complete, then +/// createClient and the types of its client, then its models. A model named +/// like another export is exported with a `$` added. +pub(super) fn index(node: Node<'_>) -> File { + let mut f = File::new(node.dir().sub("index.ts")); + let depth = node.depth(); + let mut runtime = format!("{}_/index.js", "../".repeat(depth)); + if depth == 0 { + runtime.insert_str(0, "./"); + } + export(&mut f, "export", &["ApiError".to_owned()], &runtime); + export(&mut f, "export type", &["RequestOptions".to_owned()], &runtime); + + export(&mut f, "export", &["createClient".to_owned()], "./client.js"); + let mut names = vec!["Client".to_owned(), "ClientOptions".to_owned()]; + for m in node.methods() { + if !skipped(m) { + names.push(request_name(m)); + names.push(response_name(m)); + } + } + export(&mut f, "export type", &names, "./client.js"); + + let mut taken: HashSet = ["ApiError", "RequestOptions"].map(String::from).into(); + taken.extend(names); + let mut models = Vec::new(); + for m in node.models() { + let name = Ident::model(m).name.into_owned(); + let mut alias = name.clone(); + while taken.contains(&alias) { + alias.push('$'); + } + taken.insert(alias.clone()); + models.push(if alias == name { + name + } else { + format!("{name} as {alias}") + }); + } + export(&mut f, "export type", &models, "./models.js"); + f +} + +/// Prints an export of `names` from the module at `spec`, with each name on +/// a line of its own when there is more than one. It prints nothing when +/// there are no names. +fn export(f: &mut File, keyword: &str, names: &[String], spec: &str) { + match names { + [] => {} + [name] => p!(f, keyword, " { ", name, r#" } from ""#, spec, r#"";"#), + names => { + p!(f, keyword, " {"); + f.indent(|f| { + for name in names { + p!(f, name, ","); + } + }); + p!(f, r#"} from ""#, spec, r#"";"#); + } + } +} diff --git a/src/bin/forge/ts/lib/.gitignore b/src/bin/forge/ts/lib/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/src/bin/forge/ts/lib/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/src/bin/forge/ts/lib/package.json b/src/bin/forge/ts/lib/package.json new file mode 100644 index 000000000..8742ad9b4 --- /dev/null +++ b/src/bin/forge/ts/lib/package.json @@ -0,0 +1,21 @@ +{ + "name": "@internal/ts-lib", + "version": "0.1.0", + "private": true, + "description": "The runtime that the generated TypeScript clients import.", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json", + "test": "node --harmony-temporal --import ./test/register.ts --test \"test/**/*.test.ts\"" + }, + "devDependencies": { + "@types/node": "24.19.0", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=26" + } +} diff --git a/src/bin/forge/ts/lib/src/codec.ts b/src/bin/forge/ts/lib/src/codec.ts new file mode 100644 index 000000000..d0e40b9ed --- /dev/null +++ b/src/bin/forge/ts/lib/src/codec.ts @@ -0,0 +1,361 @@ +/** + * A codec converts a value between its wire form, as JSON has it, and its + * TypeScript form. A value whose shape does not match the codec is kept as + * it is. + */ +export interface Codec { + /** + * Converts a wire value. `source` is the source text of a number whose + * value JSON.parse could not hold exactly. + */ + decode(value: unknown, source?: string): unknown; + /** + * Converts a TypeScript value to its wire form. + */ + encode(value: unknown): unknown; +} + +/** + * A codec, or a function that returns one, so codecs can form cycles. + */ +export type CodecRef = Codec | (() => Codec); + +/** + * An entry of an object codec: the wire name of a key, its field name when + * it is renamed, and the codec of its value when the value is converted. + */ +export type Entry = + | readonly [wire: string, field: string] + | readonly [wire: string, field: string | null, codec: CodecRef]; + +function resolve(ref: CodecRef): Codec { + return typeof ref === 'function' ? ref() : ref; +} + +type Record = { [key: string]: unknown }; + +/** + * Reports whether value is a plain object, as JSON.parse creates them. + */ +export function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const proto: unknown = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Sets a key of a new object, including one named `__proto__`. + */ +export function set(target: Record, key: string, value: unknown): void { + if (key === '__proto__') { + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } else { + target[key] = value; + } +} + +// The source text of the numbers that JSON.parse could not hold exactly, by +// the object or array they are in and their key. +const sources = new WeakMap>(); + +function sourceOf(holder: object, key: string): string | undefined { + return sources.get(holder)?.get(key); +} + +type Reviver = (this: unknown, key: string, value: unknown, context?: { source?: string }) => unknown; + +const recordSource: Reviver = function (key, value, context) { + const source = context?.source; + if (typeof value === 'number' && !Number.isSafeInteger(value) && source !== undefined && /^-?\d+$/.test(source)) { + const holder = this as object; + let keys = sources.get(holder); + if (keys === undefined) { + keys = new Map(); + sources.set(holder, keys); + } + keys.set(key, source); + } + return value; +}; + +const parseWithReviver = JSON.parse as (text: string, reviver: Reviver) => unknown; + +/** + * Parses JSON, and keeps the source text of integers too large for a number, + * so that `int64` decodes them exactly. + */ +export function parse(text: string): unknown { + // An integer that a number cannot hold has at least 16 digits. + if (!/\d{16}/.test(text)) { + return JSON.parse(text); + } + return parseWithReviver(text, recordSource); +} + +type Table = Map; + +class ObjectCodec implements Codec { + readonly #entries: readonly Entry[]; + #decodeTable: Table | undefined; + #encodeTable: Table | undefined; + + constructor(entries: readonly Entry[]) { + this.#entries = entries; + } + + #table(decode: boolean): Table { + const table: Table = new Map(); + for (const [wire, field, codec] of this.#entries) { + const resolved = codec === undefined ? undefined : resolve(codec); + if (decode) { + table.set(wire, [field ?? wire, resolved]); + } else { + table.set(field ?? wire, [wire, resolved]); + } + } + return table; + } + + decode(value: unknown): unknown { + if (!isRecord(value)) { + return value; + } + this.#decodeTable ??= this.#table(true); + return convert(value, this.#decodeTable, true); + } + + encode(value: unknown): unknown { + if (!isRecord(value)) { + return value; + } + this.#encodeTable ??= this.#table(false); + return convert(value, this.#encodeTable, false); + } +} + +function convert(value: Record, table: Table, decode: boolean): Record { + const out: Record = {}; + for (const key of Object.keys(value)) { + const item = value[key]; + const entry = table.get(key); + if (entry === undefined) { + set(out, key, item); + continue; + } + const [name, codec] = entry; + if (codec === undefined || item === undefined || item === null) { + set(out, name, item); + } else if (decode) { + set(out, name, codec.decode(item, sourceOf(value, key))); + } else { + set(out, name, codec.encode(item)); + } + } + return out; +} + +/** + * Returns the codec of an object, with an entry for each key that is renamed + * or whose value is converted. Other keys are kept as they are. + */ +export function object(entries: readonly Entry[]): Codec { + return new ObjectCodec(entries); +} + +/** + * Returns the codec of an array, which converts its items. + */ +export function array(items: CodecRef): Codec { + return { + decode(value) { + if (!Array.isArray(value)) { + return value; + } + const codec = resolve(items); + return value.map((item: unknown, i) => (item === null ? item : codec.decode(item, sourceOf(value, String(i))))); + }, + encode(value) { + if (!Array.isArray(value)) { + return value; + } + const codec = resolve(items); + return value.map((item: unknown) => (item === null || item === undefined ? item : codec.encode(item))); + }, + }; +} + +/** + * Returns the codec of a map, which converts its values and keeps its keys. + */ +export function map(values: CodecRef): Codec { + return { + decode(value) { + if (!isRecord(value)) { + return value; + } + const codec = resolve(values); + const out: Record = {}; + for (const key of Object.keys(value)) { + const item = value[key]; + set(out, key, item === null ? item : codec.decode(item, sourceOf(value, key))); + } + return out; + }, + encode(value) { + if (!isRecord(value)) { + return value; + } + const codec = resolve(values); + const out: Record = {}; + for (const key of Object.keys(value)) { + const item = value[key]; + set(out, key, item === null || item === undefined ? item : codec.encode(item)); + } + return out; + }, + }; +} + +function every(members: readonly CodecRef[]): Codec { + return { + decode(value, source) { + for (const member of members) { + value = resolve(member).decode(value, source); + } + return value; + }, + encode(value) { + for (const member of members) { + value = resolve(member).encode(value); + } + return value; + }, + }; +} + +/** + * Returns the codec of a union. Each member's codec converts the parts of + * the value it knows, and keeps the rest. + */ +export function union(members: readonly CodecRef[]): Codec { + return every(members); +} + +/** + * Returns the codec of an intersection, which converts the value with the + * codec of each member. + */ +export function intersection(members: readonly CodecRef[]): Codec { + return every(members); +} + +/** + * Returns the codec of a union that a property discriminates: the codec of + * the member that the property's value names. `wire` and `field` are the + * property's names, with `field` null when it is not renamed. + */ +export function discriminated( + wire: string, + field: string | null, + cases: { readonly [value: string]: CodecRef }, +): Codec { + const pick = (value: unknown): Codec | undefined => { + const key = String(value); + return Object.hasOwn(cases, key) ? resolve(cases[key]!) : undefined; + }; + return { + decode(value, source) { + if (!isRecord(value)) { + return value; + } + const codec = pick(value[wire]); + return codec === undefined ? value : codec.decode(value, source); + }, + encode(value) { + if (!isRecord(value)) { + return value; + } + const codec = pick(value[field ?? wire]); + return codec === undefined ? value : codec.encode(value); + }, + }; +} + +// attempt returns the result of fn, or fallback when the value is not in the +// form fn expects. A missing global, such as Temporal, is still thrown. +function attempt(fn: () => T, fallback: unknown): T | unknown { + try { + return fn(); + } catch (e) { + if (e instanceof ReferenceError) { + throw e; + } + return fallback; + } +} + +function toText(value: unknown): unknown { + return typeof value === 'object' && value !== null ? String(value) : value; +} + +/** + * Converts a `date-time` string and a Temporal.Instant. + */ +export const instant: Codec = { + decode: (value) => (typeof value === 'string' ? attempt(() => Temporal.Instant.from(value), value) : value), + encode: toText, +}; + +/** + * Converts a `date` string and a Temporal.PlainDate. + */ +export const date: Codec = { + decode: (value) => (typeof value === 'string' ? attempt(() => Temporal.PlainDate.from(value), value) : value), + encode: toText, +}; + +/** + * Returns the base64 encoding of bytes. + */ +export function toBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + +function fromBase64(text: string): Uint8Array { + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Converts a `byte` string, in base64, and a Uint8Array. + */ +export const bytes: Codec = { + decode: (value) => (typeof value === 'string' ? attempt(() => fromBase64(value), value) : value), + encode: (value) => (value instanceof Uint8Array ? toBase64(value) : value), +}; + +type WithRawJSON = { rawJSON(text: string): unknown }; +const rawJSON = (JSON as unknown as WithRawJSON).rawJSON; + +/** + * Converts an `int64` number and a bigint, without losing digits. + */ +export const int64: Codec = { + decode: (value, source) => (typeof value === 'number' && Number.isInteger(value) ? BigInt(source ?? value) : value), + encode: (value) => (typeof value === 'bigint' ? rawJSON(value.toString()) : value), +}; diff --git a/src/bin/forge/ts/lib/src/error.ts b/src/bin/forge/ts/lib/src/error.ts new file mode 100644 index 000000000..f3368c303 --- /dev/null +++ b/src/bin/forge/ts/lib/src/error.ts @@ -0,0 +1,70 @@ +import { isRecord } from './codec.js'; + +/** + * An error response from the API, or a success response whose body is not + * valid JSON. + */ +export class ApiError extends Error { + /** + * The status of the response. + */ + readonly status: number; + /** + * The response, whose body has been read. + */ + readonly response: Response; + /** + * The body decoded as the error the API declares for the status, or the + * body as text when it declares none or the body does not parse, or + * undefined when the body is empty. + */ + readonly details: unknown; + + constructor(message: string, response: Response, details: unknown, options?: ErrorOptions) { + super(message, options); + this.name = 'ApiError'; + this.status = response.status; + this.response = response; + this.details = details; + } +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** + * Returns the message of a JSON error body: a string `message`, `error`, + * `detail` or `title`, then `error.message` or `error.detail`, then the + * messages of an `errors` array. + */ +export function errorMessage(body: unknown): string | undefined { + if (!isRecord(body)) { + return undefined; + } + const message = text(body['message']) ?? text(body['error']) ?? text(body['detail']) ?? text(body['title']); + if (message !== undefined) { + return message; + } + const error = body['error']; + if (isRecord(error)) { + const nested = text(error['message']) ?? text(error['detail']); + if (nested !== undefined) { + return nested; + } + } + const errors = body['errors']; + if (Array.isArray(errors)) { + const messages: string[] = []; + for (const item of errors) { + const m = isRecord(item) ? text(item['message']) : text(item); + if (m !== undefined) { + messages.push(m); + } + } + if (messages.length > 0) { + return messages.join('; '); + } + } + return undefined; +} diff --git a/src/bin/forge/ts/lib/src/hooks/envelope.ts b/src/bin/forge/ts/lib/src/hooks/envelope.ts new file mode 100644 index 000000000..7d2bf70a3 --- /dev/null +++ b/src/bin/forge/ts/lib/src/hooks/envelope.ts @@ -0,0 +1,13 @@ +/** + * Returns the body a client decodes from a success response, given the + * response and its JSON body. It runs for every 2XX response with a JSON + * body, before the body is decoded or paginated. + * + * This implementation returns the body as it is. An SDK whose API wraps its + * payloads in an envelope can replace this file in the generated `_/hooks/` + * directory with one that returns the part of the envelope that the + * description declares. + */ +export function unwrapEnvelope(_response: Response, body: unknown): unknown { + return body; +} diff --git a/src/bin/forge/ts/lib/src/index.ts b/src/bin/forge/ts/lib/src/index.ts new file mode 100644 index 000000000..889eca4e1 --- /dev/null +++ b/src/bin/forge/ts/lib/src/index.ts @@ -0,0 +1,23 @@ +export { + array, + bytes, + type Codec, + type CodecRef, + date, + discriminated, + type Entry, + instant, + int64, + intersection, + map, + object, + union, +} from './codec.js'; +export { ApiError } from './error.js'; +export { + type BaseClientOptions, + type Operation, + type Pagination, + type RequestOptions, + Transport, +} from './transport.js'; diff --git a/src/bin/forge/ts/lib/src/transport.ts b/src/bin/forge/ts/lib/src/transport.ts new file mode 100644 index 000000000..8943a6f53 --- /dev/null +++ b/src/bin/forge/ts/lib/src/transport.ts @@ -0,0 +1,362 @@ +import { type Codec, isRecord, parse, set, toBase64 } from './codec.js'; +import { ApiError, errorMessage } from './error.js'; +import { unwrapEnvelope } from './hooks/envelope.js'; + +/** + * The options every client takes. + */ +export interface BaseClientOptions { + /** + * The API token, sent as `Authorization: Bearer `. + */ + token: string; + /** + * The URL the paths of the API are relative to. + */ + baseUrl?: string; + /** + * The fetch function to send requests with; the global one by default. + */ + fetch?: typeof fetch; + /** + * Headers sent with every request. + */ + headers?: HeadersInit; + /** + * Query parameters sent with every request. + */ + query?: URLSearchParams; +} + +/** + * The options of a call. Every member is optional. + */ +export interface RequestOptions { + /** + * Aborts the request. + */ + signal?: AbortSignal; + /** + * Headers sent with the request, which replace those of the client. + */ + headers?: HeadersInit; + /** + * Query parameters appended after those of the client. + */ + query?: URLSearchParams; + /** + * Called with the response before its body is read. + */ + onResponse?: (response: Response) => void; +} + +/** + * How a paginated operation moves from one page to the next. Each function + * reads the response of a page, and the value of `param` that picked it. + */ +export interface Pagination { + /** + * Returns the items of a page. + */ + items(response: unknown): readonly unknown[] | undefined; + /** + * The request field that picks the page. + */ + param: string; + /** + * Returns the value of `param` for the next page. + */ + next(response: unknown, param: unknown): unknown; + /** + * Returns whether there is a next page. + */ + more(response: unknown, param: unknown): unknown; +} + +/** + * An operation of the API, as the generated clients describe it. + */ +export interface Operation { + /** + * The HTTP method. + */ + method: string; + /** + * The path of the URL, with each path parameter as `{field}`. + */ + route: string; + /** + * The path parameters that fall back to the client's options. + */ + variables?: readonly string[]; + /** + * The wire names of the query parameters, by field. + */ + query?: { readonly [field: string]: string }; + /** + * The query parameters whose arrays are joined with commas. + */ + joined?: readonly string[]; + /** + * Where the body is: the fields of the request that are not parameters + * ("flat"), or its `body` field ("nested"). No body when absent. + */ + body?: 'flat' | 'nested'; + /** + * The media type of the body, when not application/json. + */ + contentType?: string; + /** + * The codec of the body, when it has something to convert. + */ + request?: Codec; + /** + * The codec of the response body, null when it has nothing to convert, + * and absent when the operation has no response body. + */ + response?: Codec | null; + /** + * The declared error responses, by status, range such as "4XX", or + * "default": the codec of the body, true for a JSON body with nothing to + * convert, and false for a body that is not JSON. + */ + errors?: { readonly [status: string]: Codec | boolean }; + /** + * How the operation pages. + */ + pagination?: Pagination; +} + +type Fields = { [key: string]: unknown }; + +// The fields of each operation that are parameters rather than body. +const paramFields = new WeakMap>(); + +function paramsOf(operation: Operation): Set { + let fields = paramFields.get(operation); + if (fields === undefined) { + fields = new Set(Object.keys(operation.query ?? {})); + for (const [, field] of operation.route.matchAll(/\{([^}]+)\}/g)) { + fields.add(field!); + } + paramFields.set(operation, fields); + } + return fields; +} + +function toParam(value: unknown): string { + if (value instanceof Uint8Array) { + return toBase64(value); + } + if (isRecord(value)) { + return JSON.stringify(value); + } + return String(value); +} + +function lookup(errors: Operation['errors'], status: number): Codec | boolean | undefined { + if (errors === undefined) { + return undefined; + } + for (const key of [String(status), `${Math.floor(status / 100)}XX`]) { + if (Object.hasOwn(errors, key)) { + return errors[key]; + } + } + return Object.hasOwn(errors, 'default') ? errors['default'] : undefined; +} + +async function apiError(operation: Operation, response: Response): Promise { + const text = await response.text(); + let body: unknown; + let json = false; + if (text !== '') { + try { + body = parse(text); + json = true; + } catch { + // The body is kept as text. + } + } + const declared = lookup(operation.errors, response.status); + let details: unknown = text === '' ? undefined : text; + if (json && declared !== undefined && declared !== false) { + details = declared === true ? body : declared.decode(body); + } + const message = (json ? errorMessage(body) : undefined) ?? `${response.status} ${response.statusText}`.trim(); + return new ApiError(message, response, details); +} + +/** + * Sends the requests of the generated clients. + */ +export class Transport { + readonly #options: BaseClientOptions; + readonly #baseUrl: string; + + /** + * Checks the options of a client. `baseUrl` is used when the options have + * none. + */ + constructor(options: BaseClientOptions, baseUrl?: string) { + if (typeof options?.token !== 'string' || options.token === '') { + throw new TypeError('createClient: token is required'); + } + const url = options.baseUrl ?? baseUrl; + if (url === undefined || url === '') { + throw new TypeError('createClient: baseUrl is required'); + } + this.#options = options; + this.#baseUrl = url.replace(/\/+$/, ''); + } + + /** + * Sends a request for operation and returns its decoded response. + */ + send(operation: Operation, request: object | undefined, options: RequestOptions | undefined): Promise { + return this.#send(operation, (request ?? {}) as Fields, options) as Promise; + } + + /** + * Sends a request for a paginated operation. Awaiting the result gives + * the first page, and iterating it gives every item of every page. + */ + paginate( + operation: Operation, + request: object | undefined, + options: RequestOptions | undefined, + ): Promise & AsyncIterable { + const fields = (request ?? {}) as Fields; + const first = this.#send(operation, fields, options); + return Object.assign(first as Promise, { + [Symbol.asyncIterator]: () => this.#items(operation, fields, options, first) as AsyncIterator, + }); + } + + async *#items( + operation: Operation, + request: Fields, + options: RequestOptions | undefined, + first: Promise, + ): AsyncGenerator { + const pagination = operation.pagination!; + let current = request; + let response = await first; + for (;;) { + yield* pagination.items(response) ?? []; + const param = current[pagination.param]; + if (!pagination.more(response, param)) { + return; + } + const next = pagination.next(response, param); + // A next page that is the one just fetched would loop forever. + if (next === undefined || next === null || next === param) { + return; + } + current = { ...current, [pagination.param]: next }; + response = await this.#send(operation, current, options); + } + } + + async #send(operation: Operation, request: Fields, options: RequestOptions | undefined): Promise { + const url = this.#url(operation, request, options); + const headers = new Headers({ accept: 'application/json' }); + headers.set('authorization', `Bearer ${this.#options.token}`); + const init: RequestInit = { method: operation.method, headers }; + const body = this.#body(operation, request); + if (body !== undefined) { + headers.set('content-type', operation.contentType ?? 'application/json'); + init.body = JSON.stringify(body); + } + new Headers(this.#options.headers).forEach((value, key) => { + headers.set(key, value); + }); + new Headers(options?.headers).forEach((value, key) => { + headers.set(key, value); + }); + if (options?.signal !== undefined) { + init.signal = options.signal; + } + const fetcher = this.#options.fetch ?? fetch; + const response = await fetcher(url, init); + options?.onResponse?.(response); + if (response.status < 200 || response.status > 299) { + throw await apiError(operation, response); + } + if (operation.response === undefined) { + return undefined; + } + const text = await response.text(); + if (text === '') { + return undefined; + } + let value: unknown; + try { + value = parse(text); + } catch (e) { + throw new ApiError('the response body is not valid JSON', response, text, { + cause: e, + }); + } + value = unwrapEnvelope(response, value); + return operation.response === null ? value : operation.response.decode(value); + } + + #url(operation: Operation, request: Fields, options: RequestOptions | undefined): URL { + const client = this.#options as unknown as Fields; + const path = operation.route.replace(/\{([^}]+)\}/g, (_, field: string) => { + let value = request[field]; + if (value === undefined && operation.variables?.includes(field)) { + value = client[field]; + } + if (value === undefined || value === null || value === '') { + throw new TypeError(`${field} is required: set it in the request or the client options`); + } + return encodeURIComponent(toParam(value)); + }); + const url = new URL(this.#baseUrl + path); + const query = url.searchParams; + for (const [field, wire] of Object.entries(operation.query ?? {})) { + const value = request[field]; + if (value === undefined || value === null) { + continue; + } + if (!Array.isArray(value)) { + query.append(wire, toParam(value)); + } else if (operation.joined?.includes(field)) { + query.append(wire, value.map(toParam).join(',')); + } else { + for (const item of value) { + query.append(wire, toParam(item)); + } + } + } + this.#options.query?.forEach((value, key) => { + query.append(key, value); + }); + options?.query?.forEach((value, key) => { + query.append(key, value); + }); + return url; + } + + #body(operation: Operation, request: Fields): unknown { + let body: unknown; + if (operation.body === 'nested') { + body = request['body']; + } else if (operation.body === 'flat') { + const params = paramsOf(operation); + const fields: Fields = {}; + for (const key of Object.keys(request)) { + if (!params.has(key)) { + set(fields, key, request[key]); + } + } + body = fields; + } + if (body === undefined || operation.request === undefined) { + return body; + } + return operation.request.encode(body); + } +} diff --git a/src/bin/forge/ts/lib/test/codec.test.ts b/src/bin/forge/ts/lib/test/codec.test.ts new file mode 100644 index 000000000..13337426c --- /dev/null +++ b/src/bin/forge/ts/lib/test/codec.test.ts @@ -0,0 +1,532 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { + array, + bytes, + type Codec, + date, + discriminated, + instant, + int64, + intersection, + isRecord, + map, + object, + parse, + set, + toBase64, + union, +} from '../src/codec.js'; + +// A codec that tags what it converts, to see which codec ran on a value. +function tag(name: string): Codec { + return { + decode: (value) => `${name}(${String(value)})`, + encode: (value) => `${name}'(${String(value)})`, + }; +} + +// Replaces globalThis.Temporal with nothing for the length of fn. +function withoutTemporal(fn: () => void): void { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Temporal'); + assert.ok(descriptor, 'run the tests with --harmony-temporal'); + Reflect.deleteProperty(globalThis, 'Temporal'); + try { + fn(); + } finally { + Object.defineProperty(globalThis, 'Temporal', descriptor); + } +} + +describe('isRecord', () => { + test('accepts plain objects', () => { + assert.equal(isRecord({}), true); + assert.equal(isRecord({ a: 1 }), true); + assert.equal(isRecord(Object.create(null)), true); + assert.equal(isRecord(JSON.parse('{"a":1}')), true); + }); + + test('rejects everything else', () => { + for (const value of [ + null, + undefined, + 0, + '', + 'text', + true, + [], + [1], + new Date(), + new Map(), + new Uint8Array(), + new (class Box { + value = 1; + })(), + Object.create({}), + () => {}, + ]) { + assert.equal(isRecord(value), false, `isRecord(${String(value)})`); + } + }); +}); + +describe('set', () => { + test('sets an ordinary key', () => { + const target: { [key: string]: unknown } = {}; + set(target, 'a', 1); + assert.deepEqual(target, { a: 1 }); + }); + + test('sets __proto__ as an own key, keeping the prototype', () => { + const target: { [key: string]: unknown } = {}; + set(target, '__proto__', { polluted: true }); + assert.equal(Object.getPrototypeOf(target), Object.prototype); + assert.deepEqual(Object.keys(target), ['__proto__']); + assert.deepEqual(Object.getOwnPropertyDescriptor(target, '__proto__'), { + value: { polluted: true }, + enumerable: true, + writable: true, + configurable: true, + }); + assert.equal(({} as { polluted?: boolean }).polluted, undefined); + }); +}); + +describe('parse', () => { + test('parses JSON as JSON.parse does', () => { + const text = '{"a":[1,2.5,"x",null,true],"b":{"c":1234567890123456}}'; + assert.deepEqual(parse(text), JSON.parse(text)); + assert.deepEqual(parse('12345678901234567890'), Number('12345678901234567890')); + }); + + test('throws on invalid JSON', () => { + assert.throws(() => parse('{'), SyntaxError); + assert.throws(() => parse('{"a":12345678901234567890'), SyntaxError); + }); + + test('keeps the digits of large integers in objects', () => { + const codec = object([['id', null, int64]]); + assert.deepEqual(codec.decode(parse('{"id":12345678901234567890}')), { + id: 12345678901234567890n, + }); + assert.deepEqual(codec.decode(parse('{"id":-98765432109876543210}')), { + id: -98765432109876543210n, + }); + }); + + test('keeps the digits of large integers in arrays and maps', () => { + assert.deepEqual(array(int64).decode(parse('[1, 12345678901234567890, null]')), [1n, 12345678901234567890n, null]); + assert.deepEqual(map(int64).decode(parse('{"a":12345678901234567890,"b":2}')), { + a: 12345678901234567890n, + b: 2n, + }); + }); + + test('keeps the digits of large integers in nested values', () => { + const codec = object([['items', null, array(object([['id', null, int64]]))]]); + assert.deepEqual(codec.decode(parse('{"items":[{"id":9007199254740993},{"id":9007199254740995}]}')), { + items: [{ id: 9007199254740993n }, { id: 9007199254740995n }], + }); + }); + + test('does not keep the source of numbers that are not integers', () => { + // An exponent is not the digits of an integer, so the value is the + // number JSON.parse gives. + const value = parse('{"id":12345678901234567890e0}'); + assert.deepEqual(object([['id', null, int64]]).decode(value), { + id: BigInt(Number('12345678901234567890')), + }); + }); +}); + +describe('object', () => { + test('renames keys and converts values', () => { + const codec = object([ + ['user_name', 'userName'], + ['value', null, tag('v')], + ['created_at', 'createdAt', tag('t')], + ]); + assert.deepEqual(codec.decode({ user_name: 'a', value: 1, created_at: 2, other: 3 }), { + userName: 'a', + value: 'v(1)', + createdAt: 't(2)', + other: 3, + }); + assert.deepEqual(codec.encode({ userName: 'a', value: 1, createdAt: 2, other: 3 }), { + user_name: 'a', + value: "v'(1)", + created_at: "t'(2)", + other: 3, + }); + }); + + test('keeps the order of keys', () => { + const codec = object([['b_b', 'bB']]); + assert.deepEqual(Object.keys(codec.decode({ a: 1, b_b: 2, c: 3 }) as object), ['a', 'bB', 'c']); + }); + + test('renames but does not convert null and undefined', () => { + const codec = object([['a_a', 'aA', tag('x')]]); + assert.deepEqual(codec.decode({ a_a: null }), { aA: null }); + assert.deepEqual(codec.encode({ aA: null }), { a_a: null }); + assert.deepEqual(codec.encode({ aA: undefined }), { a_a: undefined }); + }); + + test('keeps values that are not plain objects', () => { + const codec = object([['a', 'b']]); + const values = [null, undefined, 1, 'a', [{ a: 1 }], new Date(0)]; + for (const value of values) { + assert.equal(codec.decode(value), value); + assert.equal(codec.encode(value), value); + } + }); + + test('does not change its input', () => { + const codec = object([['a', 'b', tag('x')]]); + const input = { a: 1 }; + const output = codec.decode(input); + assert.notEqual(output, input); + assert.deepEqual(input, { a: 1 }); + }); + + test('keeps a __proto__ key as data', () => { + const codec = object([['__proto__', 'proto']]); + const decoded = codec.decode(JSON.parse('{"__proto__":{"a":1}}')); + assert.deepEqual(decoded, { proto: { a: 1 } }); + + const kept = object([]).decode(JSON.parse('{"__proto__":{"a":1}}')); + assert.equal(Object.getPrototypeOf(kept), Object.prototype); + assert.deepEqual(Object.keys(kept as object), ['__proto__']); + }); + + test('resolves codec functions once, on first use', () => { + let calls = 0; + const codec = object([ + [ + 'a', + null, + () => { + calls++; + return tag('x'); + }, + ], + ]); + assert.equal(calls, 0); + codec.decode({ a: 1 }); + codec.decode({ a: 2 }); + assert.equal(calls, 1); + codec.encode({ a: 1 }); + assert.equal(calls, 2); + }); + + test('supports cycles', () => { + const node: Codec = object([ + ['value', null, int64], + ['child_nodes', 'children', () => array(node)], + ]); + const wire = { + value: 1, + child_nodes: [{ value: 2, child_nodes: [{ value: 3, child_nodes: [] }] }], + }; + const decoded = { + value: 1n, + children: [{ value: 2n, children: [{ value: 3n, children: [] }] }], + }; + assert.deepEqual(node.decode(wire), decoded); + assert.deepEqual(JSON.parse(JSON.stringify(node.encode(decoded))) as unknown, wire); + }); +}); + +describe('array', () => { + test('converts items', () => { + const codec = array(tag('x')); + assert.deepEqual(codec.decode([1, 2]), ['x(1)', 'x(2)']); + assert.deepEqual(codec.encode([1, 2]), ["x'(1)", "x'(2)"]); + }); + + test('keeps null items, and undefined items when encoding', () => { + const codec = array(tag('x')); + assert.deepEqual(codec.decode([null, 1]), [null, 'x(1)']); + assert.deepEqual(codec.encode([null, undefined, 1]), [null, undefined, "x'(1)"]); + }); + + test('keeps values that are not arrays', () => { + const codec = array(tag('x')); + for (const value of [null, undefined, 1, 'ab', { 0: 1, length: 1 }]) { + assert.equal(codec.decode(value), value); + assert.equal(codec.encode(value), value); + } + }); + + test('resolves codec functions', () => { + const codec = array(() => tag('x')); + assert.deepEqual(codec.decode([1]), ['x(1)']); + }); +}); + +describe('map', () => { + test('converts values and keeps keys', () => { + const codec = map(tag('x')); + assert.deepEqual(codec.decode({ a_b: 1, c: null }), { + a_b: 'x(1)', + c: null, + }); + assert.deepEqual(codec.encode({ a_b: 1, c: null, d: undefined }), { + a_b: "x'(1)", + c: null, + d: undefined, + }); + }); + + test('keeps a __proto__ key as data', () => { + const decoded = map(tag('x')).decode(JSON.parse('{"__proto__":1}')); + assert.equal(Object.getPrototypeOf(decoded), Object.prototype); + assert.equal(Object.getOwnPropertyDescriptor(decoded, '__proto__')?.value, 'x(1)'); + }); + + test('keeps values that are not plain objects', () => { + const codec = map(tag('x')); + for (const value of [null, undefined, 1, 'ab', [1]]) { + assert.equal(codec.decode(value), value); + assert.equal(codec.encode(value), value); + } + }); +}); + +describe('union and intersection', () => { + for (const [name, combine] of [ + ['union', union], + ['intersection', intersection], + ] as const) { + test(`${name} converts with each member in order`, () => { + const codec = combine([tag('a'), () => tag('b')]); + assert.equal(codec.decode(1), 'b(a(1))'); + assert.equal(codec.encode(1), "b'(a'(1))"); + }); + + test(`${name} passes the source to each member`, () => { + const codec = combine([object([['id', null, int64]]), object([])]); + assert.deepEqual(codec.decode(parse('{"id":12345678901234567890}')), { + id: 12345678901234567890n, + }); + }); + + test(`${name} of object members converts each member's keys`, () => { + const codec = combine([object([['a_a', 'aA']]), object([['b_b', 'bB', int64]])]); + assert.deepEqual(codec.decode({ a_a: 1, b_b: 2 }), { aA: 1, bB: 2n }); + }); + + test(`${name} with no members keeps the value`, () => { + const value = { a: 1 }; + assert.equal(combine([]).decode(value), value); + assert.equal(combine([]).encode(value), value); + }); + } +}); + +describe('discriminated', () => { + const codec = discriminated('kind_name', 'kindName', { + cat: object([ + ['kind_name', 'kindName'], + ['lives', null, int64], + ]), + dog: () => + object([ + ['kind_name', 'kindName'], + ['good_boy', 'goodBoy'], + ]), + 1: object([['kind_name', 'kindName']]), + }); + + test('converts with the member the property names', () => { + assert.deepEqual(codec.decode({ kind_name: 'cat', lives: 9 }), { + kindName: 'cat', + lives: 9n, + }); + assert.deepEqual(codec.decode({ kind_name: 'dog', good_boy: true }), { + kindName: 'dog', + goodBoy: true, + }); + assert.deepEqual(JSON.stringify(codec.encode({ kindName: 'cat', lives: 9n })), '{"kind_name":"cat","lives":9}'); + assert.deepEqual(codec.encode({ kindName: 'dog', goodBoy: true }), { + kind_name: 'dog', + good_boy: true, + }); + }); + + test('matches values that are not strings by their text', () => { + assert.deepEqual(codec.decode({ kind_name: 1 }), { kindName: 1 }); + }); + + test('keeps values of an unknown member', () => { + for (const value of [ + { kind_name: 'bird' }, + { kind_name: 'toString' }, + { kind_name: '__proto__' }, + { kindName: 'cat' }, + {}, + ]) { + assert.equal(codec.decode(value), value); + } + const encoded = { kind_name: 'cat' }; + assert.equal(codec.encode(encoded), encoded); + }); + + test('keeps values that are not plain objects', () => { + for (const value of [null, undefined, 'cat', ['cat']]) { + assert.equal(codec.decode(value), value); + assert.equal(codec.encode(value), value); + } + }); + + test('reads the wire name when the property is not renamed', () => { + const plain = discriminated('type', null, { a: object([['x_x', 'xX']]) }); + assert.deepEqual(plain.decode({ type: 'a', x_x: 1 }), { type: 'a', xX: 1 }); + assert.deepEqual(plain.encode({ type: 'a', xX: 1 }), { type: 'a', x_x: 1 }); + }); + + test('passes the source to the member', () => { + const decoded = object([['pet', null, codec]]).decode( + parse('{"pet":{"kind_name":"cat","lives":12345678901234567890}}'), + ); + assert.deepEqual(decoded, { + pet: { kindName: 'cat', lives: 12345678901234567890n }, + }); + }); +}); + +describe('instant', () => { + test('decodes a date-time string', () => { + const value = instant.decode('2024-01-02T03:04:05.678Z'); + assert.ok(value instanceof Temporal.Instant); + assert.equal(value.epochMilliseconds, Date.UTC(2024, 0, 2, 3, 4, 5, 678)); + + const offset = instant.decode('2024-01-02T05:04:05+02:00'); + assert.ok(offset instanceof Temporal.Instant); + assert.equal(offset.toString(), '2024-01-02T03:04:05Z'); + }); + + test('keeps strings that are not date-times, and other values', () => { + for (const value of ['', 'yesterday', '2024-01-02', 0, null, {}]) { + assert.equal(instant.decode(value), value); + } + }); + + test('encodes an instant as its string', () => { + const value = Temporal.Instant.from('2024-01-02T03:04:05Z'); + assert.equal(instant.encode(value), '2024-01-02T03:04:05Z'); + assert.equal(JSON.stringify({ at: value }), '{"at":"2024-01-02T03:04:05Z"}'); + }); + + test('keeps values that are not objects when encoding', () => { + for (const value of ['2024-01-02T03:04:05Z', 0, null, undefined]) { + assert.equal(instant.encode(value), value); + } + }); + + test('throws when Temporal is missing', () => { + withoutTemporal(() => { + assert.throws(() => instant.decode('2024-01-02T03:04:05Z'), ReferenceError); + }); + }); +}); + +describe('date', () => { + test('decodes a date string', () => { + const value = date.decode('2024-02-29'); + assert.ok(value instanceof Temporal.PlainDate); + assert.deepEqual([value.year, value.month, value.day], [2024, 2, 29]); + }); + + test('keeps strings that are not dates, and other values', () => { + for (const value of ['', '2023-02-29', 'tomorrow', 20240101, null]) { + assert.equal(date.decode(value), value); + } + }); + + test('encodes a date as its string', () => { + assert.equal(date.encode(Temporal.PlainDate.from('2024-02-29')), '2024-02-29'); + assert.equal(date.encode('2024-02-29'), '2024-02-29'); + assert.equal(date.encode(null), null); + }); + + test('throws when Temporal is missing', () => { + withoutTemporal(() => { + assert.throws(() => date.decode('2024-01-02'), ReferenceError); + }); + }); +}); + +describe('toBase64', () => { + test('encodes bytes', () => { + assert.equal(toBase64(new Uint8Array()), ''); + assert.equal(toBase64(new TextEncoder().encode('hello')), 'aGVsbG8='); + assert.equal(toBase64(new Uint8Array([0xfb, 0xff])), '+/8='); + }); + + test("encodes more bytes than fit in one call's arguments", () => { + const data = new Uint8Array(0x8000 * 3 + 7).map((_, i) => i * 31); + assert.equal(toBase64(data), Buffer.from(data).toString('base64')); + }); +}); + +describe('bytes', () => { + test('decodes base64', () => { + assert.deepEqual(bytes.decode('aGVsbG8='), new TextEncoder().encode('hello')); + assert.deepEqual(bytes.decode(''), new Uint8Array()); + }); + + test('round-trips every byte value', () => { + const data = new Uint8Array(256).map((_, i) => i); + assert.deepEqual(bytes.decode(bytes.encode(data)), data); + }); + + test('keeps strings that are not base64, and other values', () => { + for (const value of ['not base64!', 'a', 0, null, [1, 2]]) { + assert.equal(bytes.decode(value), value); + } + }); + + test('encodes a Uint8Array and keeps other values', () => { + assert.equal(bytes.encode(new Uint8Array([1, 2, 3])), 'AQID'); + for (const value of ['AQID', [1, 2, 3], null, new ArrayBuffer(3)]) { + assert.equal(bytes.encode(value), value); + } + }); +}); + +describe('int64', () => { + test('decodes integers as bigints', () => { + assert.equal(int64.decode(0), 0n); + assert.equal(int64.decode(-42), -42n); + assert.equal(int64.decode(Number.MAX_SAFE_INTEGER), BigInt(Number.MAX_SAFE_INTEGER)); + }); + + test('decodes from the source text when there is one', () => { + assert.equal(int64.decode(Number('9223372036854775807'), '9223372036854775807'), 9223372036854775807n); + }); + + test('keeps values that are not integers', () => { + for (const value of [1.5, Number.NaN, Infinity, '1', null, 1n]) { + assert.equal(int64.decode(value), value); + } + }); + + test('encodes a bigint as a JSON number with every digit', () => { + assert.equal(JSON.stringify({ id: int64.encode(-9223372036854775808n) }), '{"id":-9223372036854775808}'); + assert.equal(JSON.stringify([int64.encode(0n)]), '[0]'); + }); + + test('keeps values that are not bigints', () => { + for (const value of [1, '1', null, undefined]) { + assert.equal(int64.encode(value), value); + } + }); + + test('round-trips through parse', () => { + const codec = object([['id', null, int64]]); + const text = '{"id":-9223372036854775808}'; + assert.equal(JSON.stringify(codec.encode(codec.decode(parse(text)))), text); + }); +}); diff --git a/src/bin/forge/ts/lib/test/error.test.ts b/src/bin/forge/ts/lib/test/error.test.ts new file mode 100644 index 000000000..60f2f08e8 --- /dev/null +++ b/src/bin/forge/ts/lib/test/error.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { ApiError, errorMessage } from '../src/error.js'; + +describe('ApiError', () => { + test('holds the response and details', () => { + const response = new Response('{}', { status: 422 }); + const cause = new Error('cause'); + const error = new ApiError('invalid', response, { field: 'a' }, { cause }); + assert.ok(error instanceof Error); + assert.ok(error instanceof ApiError); + assert.equal(error.name, 'ApiError'); + assert.equal(error.message, 'invalid'); + assert.equal(error.status, 422); + assert.equal(error.response, response); + assert.deepEqual(error.details, { field: 'a' }); + assert.equal(error.cause, cause); + assert.match(String(error.stack), /^ApiError: invalid/); + }); + + test('has no cause without options', () => { + const error = new ApiError('x', new Response(null, { status: 500 }), ''); + assert.equal('cause' in error, false); + }); +}); + +describe('errorMessage', () => { + test('reads the top-level message fields in order', () => { + const body = { message: 'm', error: 'e', detail: 'd', title: 't' }; + assert.equal(errorMessage(body), 'm'); + assert.equal(errorMessage({ ...body, message: undefined }), 'e'); + assert.equal(errorMessage({ detail: 'd', title: 't' }), 'd'); + assert.equal(errorMessage({ title: 't' }), 't'); + }); + + test('skips fields that are empty or not strings', () => { + assert.equal(errorMessage({ message: '', error: 'e' }), 'e'); + assert.equal(errorMessage({ message: 1, detail: 'd' }), 'd'); + assert.equal(errorMessage({ message: ['m'], title: 't' }), 't'); + }); + + test('reads a nested error object', () => { + assert.equal(errorMessage({ error: { message: 'm', detail: 'd' } }), 'm'); + assert.equal(errorMessage({ error: { detail: 'd' } }), 'd'); + assert.equal(errorMessage({ error: { message: '' } }), undefined); + }); + + test('prefers top-level fields to the nested error', () => { + assert.equal(errorMessage({ error: { message: 'n' }, title: 't' }), 't'); + }); + + test('joins the messages of an errors array', () => { + assert.equal( + errorMessage({ + errors: [{ message: 'a' }, 'b', { code: 1 }, '', 2, null, { message: 'c' }], + }), + 'a; b; c', + ); + assert.equal(errorMessage({ errors: [] }), undefined); + assert.equal(errorMessage({ errors: [{ code: 1 }] }), undefined); + assert.equal(errorMessage({ errors: 'a' }), undefined); + }); + + test('prefers the nested error to the errors array', () => { + assert.equal(errorMessage({ error: { message: 'n' }, errors: [{ message: 'a' }] }), 'n'); + }); + + test('returns undefined for bodies with no message', () => { + for (const body of [undefined, null, 'message', 42, [{ message: 'm' }], {}, { code: 1 }]) { + assert.equal(errorMessage(body), undefined); + } + }); +}); diff --git a/src/bin/forge/ts/lib/test/hooks.test.ts b/src/bin/forge/ts/lib/test/hooks.test.ts new file mode 100644 index 000000000..ab30d4c70 --- /dev/null +++ b/src/bin/forge/ts/lib/test/hooks.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { unwrapEnvelope } from '../src/hooks/envelope.js'; +import { type Operation, Transport } from '../src/transport.js'; + +describe('unwrapEnvelope', () => { + test('returns the body as it is', () => { + const body = { success: true, errors: [], messages: [], result: { id: 'a' } }; + assert.equal(unwrapEnvelope(new Response(), body), body); + }); + + test("leaves the clients' bodies as they are", async () => { + const body = { success: true, errors: [], messages: [], result: { id: 'a' } }; + const transport = new Transport({ + token: 'secret', + baseUrl: 'https://api.test', + fetch: (async () => new Response(JSON.stringify(body))) as typeof fetch, + }); + const get: Operation = { method: 'GET', route: '/a', response: null }; + assert.deepEqual(await transport.send(get, undefined, undefined), body); + }); +}); diff --git a/src/bin/forge/ts/lib/test/index.test.ts b/src/bin/forge/ts/lib/test/index.test.ts new file mode 100644 index 000000000..98b6f409b --- /dev/null +++ b/src/bin/forge/ts/lib/test/index.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import * as lib from '../src/index.js'; + +test('exports the runtime of the generated clients', () => { + assert.deepEqual(Object.keys(lib).toSorted(), [ + 'ApiError', + 'Transport', + 'array', + 'bytes', + 'date', + 'discriminated', + 'instant', + 'int64', + 'intersection', + 'map', + 'object', + 'union', + ]); +}); diff --git a/src/bin/forge/ts/lib/test/register.ts b/src/bin/forge/ts/lib/test/register.ts new file mode 100644 index 000000000..e9543429b --- /dev/null +++ b/src/bin/forge/ts/lib/test/register.ts @@ -0,0 +1,21 @@ +import { registerHooks } from 'node:module'; + +// The sources import each other as `./name.js`, the way TypeScript emits +// them. Node runs the `.ts` files as they are, so a relative `.js` import +// that does not exist resolves to the `.ts` file next to it. +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if ( + (specifier.startsWith('./') || specifier.startsWith('../')) && + specifier.endsWith('.js') && + (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND' + ) { + return nextResolve(`${specifier.slice(0, -3)}.ts`, context); + } + throw error; + } + }, +}); diff --git a/src/bin/forge/ts/lib/test/transport.test.ts b/src/bin/forge/ts/lib/test/transport.test.ts new file mode 100644 index 000000000..12c4dc15f --- /dev/null +++ b/src/bin/forge/ts/lib/test/transport.test.ts @@ -0,0 +1,734 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { int64, object, instant } from '../src/codec.js'; +import { ApiError } from '../src/error.js'; +import { type BaseClientOptions, type Operation, type Pagination, Transport } from '../src/transport.js'; + +interface Call { + url: URL; + method: string | undefined; + headers: Headers; + body: unknown; + signal: AbortSignal | null | undefined; +} + +interface ClientOptions extends BaseClientOptions { + account_id?: string; +} + +// Returns a fetch that answers with responses in turn, and the calls it got. +function fake(...responses: (Response | (() => Response))[]): { + fetch: typeof fetch; + calls: Call[]; +} { + const calls: Call[] = []; + const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => { + assert.ok(input instanceof URL, 'fetch is called with a URL'); + calls.push({ + url: input, + method: init?.method, + headers: new Headers(init?.headers), + body: init?.body, + signal: init?.signal, + }); + const next = responses.shift(); + assert.ok(next !== undefined, `unexpected request to ${input.href}`); + return typeof next === 'function' ? next() : next; + }; + return { fetch: fetcher as typeof fetch, calls }; +} + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +function client(fetcher: typeof fetch, options: Partial = {}): Transport { + const all: ClientOptions = { + token: 'secret', + baseUrl: 'https://api.test/v1', + fetch: fetcher, + ...options, + }; + return new Transport(all); +} + +const get: Operation = { method: 'GET', route: '/things', response: null }; + +// Sends a request that fails, and returns the error it rejects with. +async function failure(response: Response, errors?: Operation['errors']): Promise { + const { fetch } = fake(response); + const operation: Operation = { ...get }; + if (errors !== undefined) { + operation.errors = errors; + } + const error: unknown = await client(fetch) + .send(operation, undefined, undefined) + .catch((e: unknown) => e); + assert.ok(error instanceof ApiError, 'the call rejects with an ApiError'); + return error; +} + +// Returns every item of an async iterable. +async function collect(iterable: AsyncIterable): Promise { + const out: T[] = []; + for await (const item of iterable) { + out.push(item); + } + return out; +} + +describe('Transport constructor', () => { + test('requires a token', () => { + for (const options of [{}, { token: '' }, { token: 1 }, undefined, null] as unknown as BaseClientOptions[]) { + assert.throws(() => new Transport(options, 'https://api.test'), { + name: 'TypeError', + message: 'createClient: token is required', + }); + } + }); + + test('requires a base URL', () => { + for (const [options, fallback] of [ + [{ token: 't' }, undefined], + [{ token: 't', baseUrl: '' }, undefined], + [{ token: 't' }, ''], + ] as const) { + assert.throws(() => new Transport(options, fallback), { + name: 'TypeError', + message: 'createClient: baseUrl is required', + }); + } + }); + + test('prefers the base URL of the options', async () => { + const { fetch, calls } = fake(json({}), json({})); + await new Transport({ token: 't', fetch }, 'https://default.test').send(get, undefined, undefined); + await new Transport({ token: 't', fetch, baseUrl: 'https://custom.test' }, 'https://default.test').send( + get, + undefined, + undefined, + ); + assert.equal(calls[0]?.url.href, 'https://default.test/things'); + assert.equal(calls[1]?.url.href, 'https://custom.test/things'); + }); + + test('drops trailing slashes of the base URL', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch, { baseUrl: 'https://api.test/v1//' }).send(get, undefined, undefined); + assert.equal(calls[0]?.url.href, 'https://api.test/v1/things'); + }); + + test('uses the global fetch by default', async (t) => { + const { fetch, calls } = fake(json({ ok: true })); + t.mock.method(globalThis, 'fetch', fetch); + const transport = new Transport({ token: 't', baseUrl: 'https://a.test' }); + assert.deepEqual(await transport.send(get, undefined, undefined), { + ok: true, + }); + assert.equal(calls.length, 1); + }); +}); + +describe('Transport URL', () => { + test('fills in path parameters', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { method: 'GET', route: '/accounts/{account_id}/items/{id}' }, + { account_id: 'a/b c', id: 7 }, + undefined, + ); + assert.equal(calls[0]?.url.href, 'https://api.test/v1/accounts/a%2Fb%20c/items/7'); + }); + + test('falls back to client options for variables', async () => { + const operation: Operation = { + method: 'GET', + route: '/accounts/{account_id}', + variables: ['account_id'], + }; + const { fetch, calls } = fake(json({}), json({})); + const transport = client(fetch, { account_id: 'client' }); + await transport.send(operation, {}, undefined); + await transport.send(operation, { account_id: 'request' }, undefined); + assert.equal(calls[0]?.url.pathname, '/v1/accounts/client'); + assert.equal(calls[1]?.url.pathname, '/v1/accounts/request'); + }); + + test('does not fall back for parameters that are not variables', async () => { + const { fetch, calls } = fake(); + await assert.rejects( + client(fetch, { account_id: 'client' }).send({ method: 'GET', route: '/accounts/{account_id}' }, {}, undefined), + { + name: 'TypeError', + message: 'account_id is required: set it in the request or the client options', + }, + ); + assert.equal(calls.length, 0); + }); + + test('rejects missing path parameters', async () => { + const { fetch } = fake(); + const transport = client(fetch); + const operation: Operation = { + method: 'GET', + route: '/items/{id}', + variables: ['id'], + }; + for (const request of [undefined, {}, { id: null }, { id: '' }]) { + await assert.rejects(transport.send(operation, request, undefined), { + name: 'TypeError', + message: 'id is required: set it in the request or the client options', + }); + } + }); + + test('encodes bytes and objects in parameters', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { + method: 'GET', + route: '/blobs/{key}', + query: { filter: 'filter', raw: 'raw' }, + }, + { key: new Uint8Array([0xfb, 0xff]), filter: { a: 1 }, raw: false }, + undefined, + ); + const url = calls[0]!.url; + assert.equal(url.pathname, '/v1/blobs/%2B%2F8%3D'); + assert.equal(url.searchParams.get('filter'), '{"a":1}'); + assert.equal(url.searchParams.get('raw'), 'false'); + }); + + test('adds query parameters by their wire names', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { + method: 'GET', + route: '/things', + query: { + perPage: 'per_page', + ids: 'id', + tags: 'tags', + missing: 'missing', + none: 'none', + empty: 'empty', + }, + joined: ['tags'], + }, + { + perPage: 10, + ids: [1, 2], + tags: ['a', 'b'], + none: null, + empty: '', + other: 'x', + }, + undefined, + ); + assert.deepEqual( + [...calls[0]!.url.searchParams], + [ + ['per_page', '10'], + ['id', '1'], + ['id', '2'], + ['tags', 'a,b'], + ['empty', ''], + ], + ); + }); + + test('appends the query of the client, then of the call', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch, { + query: new URLSearchParams('a=client&b=client'), + }).send( + { method: 'GET', route: '/things', query: { a: 'a' } }, + { a: 'request' }, + { query: new URLSearchParams('a=call') }, + ); + assert.deepEqual( + [...calls[0]!.url.searchParams], + [ + ['a', 'request'], + ['a', 'client'], + ['b', 'client'], + ['a', 'call'], + ], + ); + }); + + test('keeps the query of the route', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { method: 'GET', route: '/things?view=full', query: { page: 'page' } }, + { page: 2 }, + undefined, + ); + assert.equal(calls[0]?.url.search, '?view=full&page=2'); + }); +}); + +describe('Transport headers', () => { + test('sends accept and authorization', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send(get, undefined, undefined); + const headers = calls[0]!.headers; + assert.equal(headers.get('accept'), 'application/json'); + assert.equal(headers.get('authorization'), 'Bearer secret'); + assert.equal(headers.has('content-type'), false); + }); + + test('lets the client, then the call, replace headers', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch, { + headers: { accept: 'text/plain', 'x-a': 'client', 'x-b': 'client' }, + }).send(get, undefined, { + headers: [ + ['x-b', 'call'], + ['authorization', 'Basic abc'], + ], + }); + const headers = calls[0]!.headers; + assert.equal(headers.get('accept'), 'text/plain'); + assert.equal(headers.get('authorization'), 'Basic abc'); + assert.equal(headers.get('x-a'), 'client'); + assert.equal(headers.get('x-b'), 'call'); + }); + + test('passes the method and signal', async () => { + const { fetch, calls } = fake(json({}), json({})); + const controller = new AbortController(); + await client(fetch).send({ method: 'DELETE', route: '/things' }, undefined, { + signal: controller.signal, + }); + await client(fetch).send(get, undefined, undefined); + assert.equal(calls[0]?.method, 'DELETE'); + assert.equal(calls[0]?.signal, controller.signal); + assert.equal(calls[1]?.signal, undefined); + }); +}); + +describe('Transport body', () => { + test('sends the fields that are not parameters', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { + method: 'POST', + route: '/items/{id}', + query: { dryRun: 'dry_run' }, + body: 'flat', + }, + { id: 1, dryRun: true, name: 'n', tags: ['a'], note: null }, + undefined, + ); + assert.equal(calls[0]?.headers.get('content-type'), 'application/json'); + assert.equal(calls[0]?.body, '{"name":"n","tags":["a"],"note":null}'); + assert.equal(calls[0]?.url.search, '?dry_run=true'); + }); + + test('sends an empty object for a flat body with no fields', async () => { + const { fetch, calls } = fake(json({}), json({})); + const operation: Operation = { method: 'POST', route: '/a', body: 'flat' }; + await client(fetch).send(operation, undefined, undefined); + await client(fetch).send(operation, {}, undefined); + assert.equal(calls[0]?.body, '{}'); + assert.equal(calls[1]?.body, '{}'); + }); + + test('keeps a __proto__ field in a flat body', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { method: 'POST', route: '/a', body: 'flat' }, + JSON.parse('{"__proto__":{"a":1}}') as object, + undefined, + ); + assert.equal(calls[0]?.body, '{"__proto__":{"a":1}}'); + }); + + test('sends the body field of a nested body', async () => { + const { fetch, calls } = fake(json({}), json({})); + const operation: Operation = { + method: 'PUT', + route: '/items/{id}', + body: 'nested', + }; + await client(fetch).send(operation, { id: 1, body: [1, 2], other: 3 }, undefined); + await client(fetch).send(operation, { id: 1 }, undefined); + assert.equal(calls[0]?.body, '[1,2]'); + assert.equal(calls[1]?.body, undefined); + assert.equal(calls[1]?.headers.has('content-type'), false); + }); + + test('sends no body when the operation has none', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send(get, { name: 'n', body: {} }, undefined); + assert.equal(calls[0]?.body, undefined); + assert.equal(calls[0]?.headers.has('content-type'), false); + }); + + test('encodes the body with the request codec', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { + method: 'POST', + route: '/events', + body: 'flat', + request: object([ + ['createdAt', null, instant], + ['big_id', 'bigId', int64], + ]), + }, + { + createdAt: Temporal.Instant.from('2024-01-02T03:04:05Z'), + bigId: 9223372036854775807n, + }, + undefined, + ); + assert.equal(calls[0]?.body, '{"createdAt":"2024-01-02T03:04:05Z","big_id":9223372036854775807}'); + }); + + test('sends the declared content type', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch, { headers: { 'x-a': 'b' } }).send( + { + method: 'PATCH', + route: '/a', + body: 'nested', + contentType: 'application/merge-patch+json', + }, + { body: { a: null } }, + undefined, + ); + assert.equal(calls[0]?.headers.get('content-type'), 'application/merge-patch+json'); + assert.equal(calls[0]?.body, '{"a":null}'); + }); + + test('lets headers replace the content type', async () => { + const { fetch, calls } = fake(json({})); + await client(fetch).send( + { method: 'POST', route: '/a', body: 'nested' }, + { body: {} }, + { headers: { 'content-type': 'application/vnd.test+json' } }, + ); + assert.equal(calls[0]?.headers.get('content-type'), 'application/vnd.test+json'); + }); +}); + +describe('Transport response', () => { + test('returns the parsed body when there is no codec', async () => { + const { fetch } = fake(json({ a: [1, 'b'] })); + assert.deepEqual(await client(fetch).send(get, undefined, undefined), { + a: [1, 'b'], + }); + }); + + test('decodes the body with the response codec', async () => { + const { fetch } = fake(new Response('{"big_id":12345678901234567890,"at":"2024-01-02T03:04:05Z"}')); + const result = await client(fetch).send<{ bigId: bigint; at: unknown }>( + { + method: 'GET', + route: '/a', + response: object([ + ['big_id', 'bigId', int64], + ['at', null, instant], + ]), + }, + undefined, + undefined, + ); + assert.equal(result.bigId, 12345678901234567890n); + assert.ok(result.at instanceof Temporal.Instant); + }); + + test('returns undefined without reading the body when the operation has no response', async () => { + const { fetch } = fake(json({ a: 1 })); + let response: Response | undefined; + const result = await client(fetch).send({ method: 'POST', route: '/a' }, undefined, { + onResponse: (r) => (response = r), + }); + assert.equal(result, undefined); + assert.equal(response?.bodyUsed, false); + }); + + test('returns undefined for an empty body', async () => { + const { fetch } = fake(new Response(null, { status: 204 }), new Response('')); + const transport = client(fetch); + assert.equal(await transport.send(get, undefined, undefined), undefined); + assert.equal(await transport.send(get, undefined, undefined), undefined); + }); + + test('calls onResponse before reading the body', async () => { + const { fetch } = fake(json({ a: 1 }, { status: 201 })); + const seen: [number, boolean][] = []; + await client(fetch).send(get, undefined, { + onResponse: (r) => seen.push([r.status, r.bodyUsed]), + }); + assert.deepEqual(seen, [[201, false]]); + }); + + test('rejects a body that is not JSON', async () => { + const { fetch } = fake(new Response('', { status: 200 })); + const error: unknown = await client(fetch) + .send(get, undefined, undefined) + .catch((e: unknown) => e); + assert.ok(error instanceof ApiError); + assert.equal(error.message, 'the response body is not valid JSON'); + assert.equal(error.status, 200); + assert.equal(error.details, ''); + assert.ok(error.cause instanceof SyntaxError); + assert.equal(error.response.bodyUsed, true); + }); + + test('propagates fetch errors', async () => { + const cause = new TypeError('network down'); + const transport = client(async () => { + throw cause; + }); + await assert.rejects(transport.send(get, undefined, undefined), cause); + }); +}); + +describe('Transport errors', () => { + test('rejects statuses outside 2XX', async () => { + for (const status of [300, 304, 400, 404, 500, 599]) { + const response = new Response(status === 304 ? null : '', { status }); + const error = await failure(response); + assert.equal(error.status, status); + assert.equal(error.response, response); + } + }); + + test('takes the message from a JSON body', async () => { + const error = await failure(json({ errors: [{ message: 'a' }, { message: 'b' }] }, { status: 400 })); + assert.equal(error.message, 'a; b'); + assert.equal(error.details, '{"errors":[{"message":"a"},{"message":"b"}]}'); + }); + + test('falls back to the status for the message', async () => { + const cases: [Response, string][] = [ + [json({ code: 1 }, { status: 409, statusText: 'Conflict' }), '409 Conflict'], + [new Response('oops', { status: 500, statusText: 'Boom' }), '500 Boom'], + [new Response('{"message":"m"', { status: 500 }), '500'], + [new Response('', { status: 502 }), '502'], + ]; + for (const [response, message] of cases) { + assert.equal((await failure(response)).message, message); + } + }); + + test('gives the text of an undeclared error as details', async () => { + assert.equal((await failure(json({ a: 1 }, { status: 400 }))).details, '{"a":1}'); + assert.equal((await failure(new Response('oops', { status: 400 }))).details, 'oops'); + assert.equal((await failure(new Response('', { status: 400 }))).details, undefined); + }); + + test('decodes a declared error by status, range, then default', async () => { + const errors: Operation['errors'] = { + '404': object([['code', 'exact']]), + '4XX': object([['code', 'range']]), + default: object([['code', 'fallback']]), + }; + const body = { code: 1 }; + assert.deepEqual((await failure(json(body, { status: 404 }), errors)).details, { exact: 1 }); + assert.deepEqual((await failure(json(body, { status: 400 }), errors)).details, { range: 1 }); + assert.deepEqual((await failure(json(body, { status: 503 }), errors)).details, { fallback: 1 }); + }); + + test('keeps the text of an error that matches no declaration', async () => { + const error = await failure(json({ a: 1 }, { status: 500 }), { + '4XX': true, + }); + assert.equal(error.details, '{"a":1}'); + }); + + test('gives the parsed body of an error declared as JSON', async () => { + const error = await failure( + new Response('{"id":12345678901234567890,"message":"m"}', { + status: 400, + }), + { '400': true }, + ); + assert.deepEqual(error.details, { + id: Number('12345678901234567890'), + message: 'm', + }); + assert.equal(error.message, 'm'); + }); + + test('keeps the text of an error declared as not JSON', async () => { + const error = await failure(json({ message: 'm' }, { status: 400 }), { + '4XX': false, + default: true, + }); + assert.equal(error.details, '{"message":"m"}'); + assert.equal(error.message, 'm'); + }); + + test('keeps the text of a declared error whose body is not JSON', async () => { + const error = await failure(new Response('oops', { status: 400, statusText: 'Bad Request' }), { + '400': object([]), + }); + assert.equal(error.details, 'oops'); + assert.equal(error.message, '400 Bad Request'); + }); + + test('gives undefined details for an empty declared error', async () => { + const error = await failure(new Response(null, { status: 404 }), { + '404': true, + }); + assert.equal(error.details, undefined); + }); + + test('ignores inherited keys of the declarations', async () => { + const errors = Object.create({ '400': true }) as Operation['errors']; + const error = await failure(json({ a: 1 }, { status: 400 }), errors); + assert.equal(error.details, '{"a":1}'); + }); +}); + +describe('Transport paginate', () => { + interface Page { + items?: number[]; + next?: string | null; + more?: boolean; + } + + const cursor: Pagination = { + items: (r) => (r as Page).items, + param: 'cursor', + next: (r) => (r as Page).next, + more: (r) => (r as Page).more, + }; + + const operation: Operation = { + method: 'GET', + route: '/items', + query: { cursor: 'cursor', limit: 'limit' }, + response: null, + pagination: cursor, + }; + + test('resolves to the first page', async () => { + const { fetch, calls } = fake(json({ items: [1], next: 'b', more: true })); + const page = await client(fetch).paginate(operation, { limit: 1 }, undefined); + assert.deepEqual(page, { items: [1], next: 'b', more: true }); + assert.equal(calls.length, 1); + }); + + test('iterates the items of every page', async () => { + const { fetch, calls } = fake( + json({ items: [1, 2], next: 'b', more: true }), + json({ items: [3], next: 'c', more: true }), + json({ items: [4], next: 'd', more: false }), + ); + const items = await collect(client(fetch).paginate(operation, { limit: 2 }, undefined)); + assert.deepEqual(items, [1, 2, 3, 4]); + assert.deepEqual( + calls.map((c) => c.url.search), + ['?limit=2', '?cursor=b&limit=2', '?cursor=c&limit=2'], + ); + }); + + test('fetches the first page once when awaited and iterated', async () => { + const { fetch, calls } = fake(json({ items: [1], next: 'b', more: true }), json({ items: [2], more: false })); + const pages = client(fetch).paginate(operation, undefined, undefined); + assert.deepEqual(await pages, { items: [1], next: 'b', more: true }); + assert.deepEqual(await collect(pages), [1, 2]); + assert.equal(calls.length, 2); + }); + + test('stops when there is no next page', async () => { + for (const last of [ + { items: [2], next: null, more: true }, + { items: [2], more: true }, + { items: [2], next: 'a', more: true }, + ]) { + const { fetch, calls } = fake(json({ items: [1], next: 'a', more: true }), json(last)); + const items = await collect(client(fetch).paginate(operation, {}, undefined)); + assert.deepEqual(items, [1, 2]); + assert.equal(calls.length, 2); + } + }); + + test('continues past pages without items', async () => { + const { fetch } = fake( + json({ next: 'b', more: true }), + json({ items: [], next: 'c', more: true }), + json({ items: [1], more: false }), + ); + const items = await collect(client(fetch).paginate(operation, {}, undefined)); + assert.deepEqual(items, [1]); + }); + + test('passes the current param to the pagination', async () => { + const seen: unknown[] = []; + const offset: Operation = { + method: 'GET', + route: '/items', + query: { page: 'page' }, + response: null, + pagination: { + items: (r) => r as number[], + param: 'page', + next: (_, page) => ((page as number | undefined) ?? 1) + 1, + more: (r, page) => { + seen.push(page); + return (r as number[]).length > 0; + }, + }, + }; + const { fetch, calls } = fake(json([1, 2]), json([3]), json([])); + const items = await collect(client(fetch).paginate(offset, {}, undefined)); + assert.deepEqual(items, [1, 2, 3]); + assert.deepEqual(seen, [undefined, 2, 3]); + assert.deepEqual( + calls.map((c) => c.url.search), + ['', '?page=2', '?page=3'], + ); + }); + + test('decodes each page and sends the call options', async () => { + const { fetch, calls } = fake(json({ items: [1], next_page: 'b' }), json({ items: [2] })); + const decoded: Operation = { + ...operation, + response: object([['next_page', 'nextPage']]), + pagination: { + ...cursor, + next: (r) => (r as { nextPage?: string }).nextPage, + more: (r) => (r as { nextPage?: string }).nextPage !== undefined, + }, + }; + const items = await collect( + client(fetch).paginate( + decoded, + {}, + { + headers: { 'x-a': 'b' }, + }, + ), + ); + assert.deepEqual(items, [1, 2]); + assert.deepEqual( + calls.map((c) => c.headers.get('x-a')), + ['b', 'b'], + ); + }); + + test('rejects the iteration when a page fails', async () => { + const { fetch } = fake(json({ items: [1], next: 'b', more: true }), json({ message: 'gone' }, { status: 410 })); + const seen: number[] = []; + await assert.rejects( + (async () => { + for await (const item of client(fetch).paginate(operation, {}, undefined)) { + seen.push(item); + } + })(), + (error: unknown) => error instanceof ApiError && error.status === 410, + ); + assert.deepEqual(seen, [1]); + }); +}); diff --git a/src/bin/forge/ts/lib/tsconfig.json b/src/bin/forge/ts/lib/tsconfig.json new file mode 100644 index 000000000..b8eb265fa --- /dev/null +++ b/src/bin/forge/ts/lib/tsconfig.json @@ -0,0 +1,27 @@ +{ + // Checks the runtime against the web platform alone, as the generated + // clients run in browsers and servers alike. + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "types": [], + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/src/bin/forge/ts/lib/tsconfig.test.json b/src/bin/forge/ts/lib/tsconfig.test.json new file mode 100644 index 000000000..166106a44 --- /dev/null +++ b/src/bin/forge/ts/lib/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + // Checks the tests, which run on Node, with the sources they import. + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/src/bin/forge/ts/models.rs b/src/bin/forge/ts/models.rs new file mode 100644 index 000000000..bcefc0f16 --- /dev/null +++ b/src/bin/forge/ts/models.rs @@ -0,0 +1,92 @@ +//! The models.ts of a group, following specs/04-model-declarations.md. + +use std::collections::HashMap; + +use forge::ts::{File, Ident, p}; +use forge::{Model, Object, Type}; + +use super::Node; +use super::codec::{declares_codec, model_codec}; +use super::types::{Prec, doc, fields, has_fields, kept, member, typ}; + +/// Returns the models.ts of `node`: each model, then the codecs of the +/// models. It reports a model whose name has no letters or digits, and two +/// models with the same name (specs/02-naming.md). +pub(super) fn models(node: Node<'_>) -> Result> { + let models = node.models(); + // TODO: Move both checks into the library, next to the method checks in + // src/link.rs. It accepts a model whose name has no letters or digits, + // and two models in a group with equal names, such as schemas `Pet` and + // `pet`. Then this function, `files` and `generate_group` can't fail. + let mut errs = Vec::new(); + let mut names: HashMap> = HashMap::new(); + for &m in &models { + let name = Ident::model(m).name.into_owned(); + if m.name().is_empty() { + errs.push(format!( + "schema {:?}: the name has no letters or digits", + m.schema_name() + )); + } else if let Some(other) = names.get(&name) { + errs.push(format!( + "group {:?}: schemas {:?} and {:?} have the same name {name:?}", + node.path(), + other.schema_name(), + m.schema_name(), + )); + } + names.insert(name, m); + } + if !errs.is_empty() { + return Err(errs); + } + + let mut f = File::new(node.dir().sub("models.ts")); + for (i, &m) in models.iter().enumerate() { + if i > 0 { + p!(f); + } + model(&mut f, m); + } + for &m in &models { + if declares_codec(m) { + p!(f); + model_codec(&mut f, m); + } + } + Ok(f) +} + +/// Declares `m`: an interface for an object with fields, and a type alias +/// for everything else. +fn model(f: &mut File, m: Model<'_>) { + f.doc(&doc(m.description(), m.is_deprecated())); + let ty = m.ty(); + match ty { + Type::Object(object) if has_fields(ty) => { + p!(f, "export interface ", m, " {"); + print_fields(f, object); + p!(f, "}"); + } + _ => p!(f, "export type ", m, " = ", typ(ty, Prec::Top), ";"), + } +} + +/// Prints the fields of `object`, each on a line of its own after its +/// documentation, and an index signature when it allows other keys. +pub(super) fn print_fields(f: &mut File, object: Object<'_>) { + let mut open = object.extra().is_some(); + f.indent(|f| { + for field in fields(object) { + if !kept(field) { + open = true; + continue; + } + f.doc(&doc(field.description(), field.is_deprecated())); + p!(f, member(field), ";"); + } + if open { + p!(f, "[key: string]: unknown;"); + } + }); +} diff --git a/src/bin/forge/ts/operation.rs b/src/bin/forge/ts/operation.rs new file mode 100644 index 000000000..1febf86ad --- /dev/null +++ b/src/bin/forge/ts/operation.rs @@ -0,0 +1,109 @@ +//! The operation of a method, which the runtime sends +//! (specs/12-runtime-library.md). + +use forge::ts::{File, frag, join, p}; +use forge::{Location, Method}; + +use super::client::{Param, Request, operation_ident, response}; +use super::codec::{codec, members_codec}; +use super::runtime; +use super::types::{js_string, key}; + +/// Declares the operation of `m`: its HTTP method and route, where its +/// parameters and body go, and the codecs of its body and responses. +pub(super) fn operation(f: &mut File, m: Method<'_>, request: &Request<'_>) { + p!( + f, + "const ", + operation_ident(f, m), + ": ", + runtime("Operation").ty(), + " = {" + ); + f.indent(|f| { + p!(f, "method: ", js_string(m.http_method().as_str()), ","); + p!(f, "route: ", js_string(&route(m, &request.params)), ","); + let mut query = Vec::new(); + let mut joined = Vec::new(); + for param in &request.params { + if let Location::Query { exploded } = param.location { + query.push(format!("{}: {}", key(¶m.name), js_string(param.field.wire_name()))); + if !exploded { + joined.push(js_string(¶m.name)); + } + } + } + if !query.is_empty() { + p!(f, "query: { ", join(", ", query), " },"); + } + if !joined.is_empty() { + p!(f, "joined: [", join(", ", joined), "],"); + } + if let Some(body) = &request.body { + if request.flat { + p!(f, r#"body: "flat","#); + } else { + p!(f, r#"body: "nested","#); + } + if body.media_type != "application/json" { + p!(f, "contentType: ", js_string(body.media_type), ","); + } + let codec = codec(body.ty, true); + if !codec.is_empty() { + p!(f, "request: ", codec, ","); + } + } + let (types, _) = response(m); + if !types.is_empty() { + let codec = members_codec(types, "union", true); + p!( + f, + "response: ", + if codec.is_empty() { frag!("null") } else { codec }, + "," + ); + } + error_codecs(f, m); + }); + p!(f, "};"); +} + +/// Prints the declared error responses of an operation, by status: the +/// codec of a JSON body, true for a JSON body with nothing to convert, and +/// false for a body that is not JSON. Error responses that are all not JSON +/// are read like undeclared ones, so they are left out. +fn error_codecs(f: &mut File, m: Method<'_>) { + let mut json = false; + let errors = join( + ", ", + m.errors().map(|r| { + let status = if r.status() == "default" { + "default".to_owned() + } else { + js_string(r.status()) + }; + let Some(content) = r.json() else { + return frag!(status, ": false"); + }; + json = true; + let codec = codec(content.ty(), true); + frag!(status, ": ", if codec.is_empty() { frag!("true") } else { codec }) + }), + ); + if json { + p!(f, "errors: { ", errors, " },"); + } +} + +/// Returns the path of a method with each path parameter as the name of its +/// field in braces. +fn route(m: Method<'_>, params: &[Param<'_>]) -> String { + let mut path = m.path().to_owned(); + for param in params { + if param.location == Location::Path { + let wire = format!("{{{}}}", param.field.wire_name()); + path = path.replace(&wire, &format!("{{{}}}", param.name)); + } + } + path +} diff --git a/src/bin/forge/ts/types.rs b/src/bin/forge/ts/types.rs new file mode 100644 index 000000000..05970d82e --- /dev/null +++ b/src/bin/forge/ts/types.rs @@ -0,0 +1,257 @@ +//! TypeScript types, following specs/03-schema-types.md. + +use std::collections::HashSet; + +use forge::ts::{Fragment, Ident, frag, join}; +use forge::{EnumValue, Field, Model, Number, Object, Scalar, Type}; + +/// Where a type is printed, which decides whether a union or an +/// intersection needs parentheses. An intersection inside a union has them. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Prec { + Top, + Intersection, + Array, +} + +/// Returns `t` as part of a line. +/// +/// ```text +/// p!(f, "export type ", m, " = ", typ(m.ty(), Prec::Top), ";"); +/// ``` +pub(super) fn typ(t: Type<'_>, prec: Prec) -> Fragment { + match t { + Type::Scalar(scalar) => frag!(scalar_type(scalar)), + Type::Model(m) => frag!(Ident::model(m).ty()), + Type::Array(array) => frag!(typ(array.elem(), Prec::Array), "[]"), + Type::Object(object) => object_type(object), + Type::Enum(e) => { + let values = join(" | ", e.values().map(literal)); + if e.values().len() > 1 && prec > Prec::Top { + frag!("(", values, ")") + } else { + values + } + } + Type::Union(union) => { + let members = alternatives(union.members()); + if prec > Prec::Top { + frag!("(", members, ")") + } else { + members + } + } + Type::Intersection(intersection) => { + let members = join(" & ", intersection.members().map(|m| typ(m, Prec::Intersection))); + if prec >= Prec::Intersection { + frag!("(", members, ")") + } else { + members + } + } + } +} + +fn scalar_type(scalar: Scalar) -> &'static str { + match scalar { + Scalar::Unknown => "unknown", + Scalar::Null => "null", + Scalar::Boolean => "boolean", + Scalar::String => "string", + Scalar::Number(Number::Int64) => "bigint", + Scalar::Number(_) => "number", + Scalar::DateTime => "Temporal.Instant", + Scalar::Date => "Temporal.PlainDate", + Scalar::Bytes => "Uint8Array", + // Only JSON content reaches the SDK, and in JSON a string with + // format `binary` is still a string on the wire, so it is kept + // as one, with no codec. + Scalar::Binary => "string", + } +} + +/// Returns an object type: its fields, and an index signature when it +/// allows other keys, or a map when it keeps no field. +fn object_type(object: Object<'_>) -> Fragment { + if !has_fields(Type::Object(object)) { + return frag!("{ [key: string]: ", typ(elem(object), Prec::Top), " }"); + } + let mut open = object.extra().is_some(); + let mut members = Vec::new(); + for field in fields(object) { + if kept(field) { + members.push(member(field)); + } else { + open = true; + } + } + let mut out = frag!("{ ", join("; ", members)); + if open { + out.text("; [key: string]: unknown"); + } + out.text(" }"); + out +} + +/// Returns `types` joined by ` | `. A union among them, which has a +/// discriminator, and an enum need no parentheses. +pub(super) fn alternatives<'f>(types: impl IntoIterator>) -> Fragment { + join( + " | ", + types.into_iter().map(|t| match t { + Type::Union(_) | Type::Enum(_) => typ(t, Prec::Top), + _ => typ(t, Prec::Intersection), + }), + ) +} + +/// Returns a property of an object type, without the `;` after it. +pub(super) fn member(field: Field<'_>) -> Fragment { + frag!( + key(&field.name().camel()), + if field.is_required() { ": " } else { "?: " }, + typ(field.ty(), Prec::Top) + ) +} + +/// Returns the documentation of a declaration, with `@deprecated` after it +/// when it is deprecated. +pub(super) fn doc(text: &str, deprecated: bool) -> String { + if !deprecated { + return text.to_owned(); + } + let text = text.trim(); + if text.is_empty() { + "@deprecated".to_owned() + } else { + format!("{text}\n\n@deprecated") + } +} + +/// Returns the fields of `object`, sorted by wire name. +pub(super) fn fields(object: Object<'_>) -> Vec> { + let mut fields: Vec> = object.fields().collect(); + fields.sort_by_key(|field| field.wire_name()); + fields +} + +/// Returns the type of the values of a map, which is unknown when the map +/// does not say. +pub(super) fn elem(object: Object<'_>) -> Type<'_> { + object.extra().unwrap_or(Type::Scalar(Scalar::Unknown)) +} + +/// Reports whether `t` is an object type with a property the SDK keeps. An +/// object without one is a map. +pub(super) fn has_fields(t: Type<'_>) -> bool { + matches!(t, Type::Object(object) if object.fields().any(kept)) +} + +/// Reports whether the SDK keeps a property: its name is valid +/// (specs/02-naming.md). An object that leaves out a property allows other +/// keys. +pub(super) fn kept(field: Field<'_>) -> bool { + valid_name(field.wire_name()) +} + +/// Reports whether `t` is an object type with fields, a model of one, or a +/// composition of them. `seen` has the models already followed. +pub(super) fn object_like<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { + match t { + Type::Object(_) => has_fields(t), + Type::Model(m) => seen.insert(m) && object_like(m.ty(), seen), + Type::Union(union) => union.members().all(|m| object_like(m, seen)), + Type::Intersection(intersection) => intersection.members().all(|m| object_like(m, seen)), + _ => false, + } +} + +/// Reports whether an empty object is a value of `t`. +pub(super) fn all_optional(t: Type<'_>) -> bool { + fn walk<'f>(t: Type<'f>, seen: &mut HashSet>) -> bool { + match t { + Type::Object(object) => !object.fields().any(|f| kept(f) && f.is_required()), + Type::Scalar(Scalar::Unknown) => true, + Type::Model(m) => seen.insert(m) && walk(m.ty(), seen), + Type::Intersection(intersection) => intersection.members().all(|m| walk(m, seen)), + Type::Union(union) => union.members().any(|m| walk(m, seen)), + _ => false, + } + } + walk(t, &mut HashSet::new()) +} + +/// Prefixes `s` with `_` if it starts with a digit, so it's a valid +/// identifier. +pub(super) fn identifier(s: String) -> String { + if s.starts_with(|c: char| c.is_ascii_digit()) { + format!("_{s}") + } else { + s + } +} + +/// Reports whether `s` is made only of letters, digits, `-` and `_`, and +/// has at least one letter or digit. +pub(super) fn valid_name(s: &str) -> bool { + let mut alnum = false; + for c in s.chars() { + if c.is_alphabetic() || c.is_numeric() { + alnum = true; + } else if c != '-' && c != '_' { + return false; + } + } + alnum +} + +/// Reports whether `s` is a JavaScript identifier made of ASCII letters, +/// digits, `_` and `$`. +pub(super) fn is_identifier(s: &str) -> bool { + let mut chars = s.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +/// Returns a property name, quoted when it is not an identifier. +pub(super) fn key(name: &str) -> String { + if is_identifier(name) { + name.to_owned() + } else { + js_string(name) + } +} + +/// Returns `s` as a string literal, escaped as Go's `encoding/json` does +/// without HTML escaping. +pub(super) fn js_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\u{2028}' | '\u{2029}' => out.push_str(&format!("\\u{:04x}", c as u32)), + c if c < ' ' => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Returns an enum value as a literal type. +fn literal(value: EnumValue<'_>) -> String { + match value { + EnumValue::String(s) => js_string(s), + EnumValue::Number(n) => n.to_string(), + EnumValue::Boolean(b) => b.to_string(), + } +} diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 000000000..cccd1ab2f --- /dev/null +++ b/src/json.rs @@ -0,0 +1,251 @@ +//! A JSON value, for the parts of a document that forge keeps without +//! reading them, such as the extensions of a [`Method`](crate::Method). + +use std::fmt; + +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; + +/// A JSON value, read the way JavaScript's `JSON.parse` reads one. +/// +/// It can be read from any format serde can read, such as YAML, as long as +/// the value is one JSON could write. +#[derive(Clone, Debug, PartialEq)] +pub enum Json { + Null, + Bool(bool), + /// A number, as a 64-bit float, as in JavaScript: an integer beyond + /// 2^53 in magnitude loses precision. + Number(f64), + String(String), + Array(Vec), + /// An object's members, in the order they are written. A key written + /// more than once has the last value written, in the place of the + /// first, as with `JSON.parse`, so each key is there once. + /// + /// Two objects are equal only when their members are in the same + /// order. + Object(Vec<(String, Json)>), +} + +impl Json { + /// Reports whether the value is `null`. + pub fn is_null(&self) -> bool { + matches!(self, Json::Null) + } + + /// Returns the value if it is a boolean, or else `None`. + pub fn as_bool(&self) -> Option { + match *self { + Json::Bool(b) => Some(b), + _ => None, + } + } + + /// Returns the value if it is a number, or else `None`. + pub fn as_f64(&self) -> Option { + match *self { + Json::Number(n) => Some(n), + _ => None, + } + } + + /// Returns the value if it is a string, or else `None`. + pub fn as_str(&self) -> Option<&str> { + match self { + Json::String(s) => Some(s), + _ => None, + } + } + + /// Returns the elements if the value is an array, or else `None`. + pub fn as_array(&self) -> Option<&[Json]> { + match self { + Json::Array(elements) => Some(elements), + _ => None, + } + } + + /// Returns the members if the value is an object, in the order they + /// are written, or else `None`. + pub fn as_object(&self) -> Option<&[(String, Json)]> { + match self { + Json::Object(members) => Some(members), + _ => None, + } + } + + /// Returns the value of the member `key` if the value is an object + /// that has one, or else `None`. A key written more than once has the + /// last value written. + pub fn get(&self, key: &str) -> Option<&Json> { + let members = self.as_object()?; + members.iter().find(|(k, _)| k == key).map(|(_, value)| value) + } +} + +/// Sets the member `key` of `members` to `value`, in the place of the +/// member already there if there is one. +pub(crate) fn insert(members: &mut Vec<(String, Json)>, key: String, value: Json) { + match members.iter_mut().find(|(k, _)| *k == key) { + Some((_, old)) => *old = value, + None => members.push((key, value)), + } +} + +impl<'de> Deserialize<'de> for Json { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(JsonVisitor) + } +} + +struct JsonVisitor; + +impl<'de> Visitor<'de> for JsonVisitor { + type Value = Json; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("any JSON value") + } + + fn visit_unit(self) -> Result { + Ok(Json::Null) + } + + fn visit_none(self) -> Result { + Ok(Json::Null) + } + + fn visit_some>(self, deserializer: D) -> Result { + Json::deserialize(deserializer) + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Json::Bool(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(Json::Number(v as f64)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Json::Number(v as f64)) + } + + fn visit_f64(self, v: f64) -> Result { + Ok(Json::Number(v)) + } + + fn visit_str(self, v: &str) -> Result { + Ok(Json::String(v.to_owned())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Json::String(v)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut elements = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(element) = seq.next_element()? { + elements.push(element); + } + Ok(Json::Array(elements)) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut members = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some((key, value)) = map.next_entry()? { + insert(&mut members, key, value); + } + Ok(Json::Object(members)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reads `json`, which must succeed. + #[track_caller] + fn read(json: &str) -> Json { + match serde_json::from_str(json) { + Ok(value) => value, + Err(err) => panic!("read {json}: {err}"), + } + } + + /// Returns the keys of the object `value` is, in order. + fn keys(value: &Json) -> Vec<&str> { + value.as_object().unwrap().iter().map(|(k, _)| k.as_str()).collect() + } + + #[test] + fn scalars_are_their_variants() { + assert_eq!(read("null"), Json::Null); + assert_eq!(read("true"), Json::Bool(true)); + assert_eq!(read(r#""a""#), Json::String("a".into())); + } + + #[test] + fn every_number_is_a_float() { + assert_eq!(read("-3"), Json::Number(-3.0)); + assert_eq!(read("3"), Json::Number(3.0)); + assert_eq!(read("1.5e2"), Json::Number(150.0)); + } + + #[test] + fn integers_beyond_2_to_the_53_lose_precision() { + assert_eq!(read("9007199254740993"), Json::Number(9007199254740992.0)); + } + + #[test] + fn object_keeps_its_keys_in_the_order_they_are_written() { + assert_eq!(keys(&read(r#"{"b": 1, "a": 2, "c": 3}"#)), ["b", "a", "c"]); + } + + #[test] + fn repeated_key_has_the_last_value_in_the_place_of_the_first() { + let value = read(r#"{"a": 1, "b": 2, "a": 3}"#); + + assert_eq!(keys(&value), ["a", "b"]); + assert_eq!(value.get("a"), Some(&Json::Number(3.0))); + } + + #[test] + fn values_nest() { + let value = read(r#"{"a": [1, {"b": null}], "c": {}}"#); + + let a = value.get("a").and_then(Json::as_array).unwrap(); + assert_eq!(a[0], Json::Number(1.0)); + assert!(a[1].get("b").unwrap().is_null()); + assert_eq!(value.get("c"), Some(&Json::Object(Vec::new()))); + } + + #[test] + fn accessors_return_the_value_of_their_variant() { + assert_eq!(read("true").as_bool(), Some(true)); + assert_eq!(read("2").as_f64(), Some(2.0)); + assert_eq!(read(r#""s""#).as_str(), Some("s")); + assert_eq!(read("[null]").as_array(), Some(&[Json::Null][..])); + let members = [("k".to_string(), Json::String("v".into()))]; + assert_eq!(read(r#"{"k": "v"}"#).as_object(), Some(&members[..])); + assert!(read("null").is_null()); + } + + #[test] + fn accessors_of_another_variant_are_none() { + let value = read(r#""true""#); + + assert_eq!(value.as_bool(), None); + assert_eq!(value.as_f64(), None); + assert_eq!(value.as_array(), None); + assert_eq!(value.as_object(), None); + assert!(!value.is_null()); + assert_eq!(read("1").as_str(), None); + } + + #[test] + fn get_is_none_for_a_missing_key_or_a_value_that_is_not_an_object() { + assert_eq!(read(r#"{"a": 1}"#).get("b"), None); + assert_eq!(read(r#"[{"a": 1}]"#).get("a"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..55e1611d5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,814 @@ +//! Forge builds a language-neutral SDK tree from an OpenAPI document. + +use std::{iter, ptr}; + +mod arena; +mod json; +mod link; +mod method; +mod name; +mod plugin; +mod types; +pub use json::Json; +pub use method::{Body, Content, Location, Response}; +pub use name::Name; +#[cfg(feature = "wasm")] +pub use plugin::wasm; +pub use plugin::{Err, File, Plugin, run, ts, write}; +pub use types::{Array, Discriminator, Enum, EnumValue, Field, Intersection, Number, Object, Scalar, Type, Union}; + +use arena::{List, Span, Str, Strings}; +use method::{BodyData, ResponseData}; +use types::{EnumData, FieldData, TypeData, TypeId}; + +/// A high-level representation of an OpenAPI document, for generating an +/// SDK tree. +#[derive(Default)] +pub struct Forge { + /// The version of the document. + /// + /// Corresponds to `info.version` in the + /// [Info Object](https://spec.openapis.org/oas/latest.html#fixed-fields-0). + pub version: String, + /// A description of the API. + /// + /// Corresponds to `info.title`, `info.summary` and `info.description` in + /// the [Info Object](https://spec.openapis.org/oas/latest.html#fixed-fields-0), + /// joined as paragraphs with a blank line between them. + pub description: String, + /// The base URL for the API. + /// + /// Corresponds to the `url` of the first entry in `servers` in the + /// [OpenAPI Object](https://spec.openapis.org/oas/latest.html#fixed-fields), + /// with its server variables substituted. + pub base_url: String, // Eventually we will support servers at descendants as well. + + /// Every group, indexed by `GroupId`. + groups: Vec, + /// The top-level groups. + top: Vec, + /// Every method, indexed by `MethodId`. + methods: Vec, + /// Every model, indexed by `ModelId`. + models: Vec, + /// The models at the top level, in no group. + top_models: Vec, + /// Every type, indexed by `TypeId`. + types: Vec, + + // The arenas the data above keeps its strings and lists in. + strings: Strings, + /// Every name of a field, once each, indexed by `NameId`. + names: Vec, + /// The fields of objects. + fields: List, + /// The members of unions and intersections. + members: List, + /// The values of enums. + values: List, + /// The extensions of every operation in the document, including those + /// that are not methods, by name. + extensions: List<(String, Json)>, + /// The `default` of every schema in the document that has one, used + /// by a field or not, indexed by the `Id` a field keeps. + defaults: Vec, +} + +impl Forge { + /// Returns every group, depth first: each group comes before its + /// subgroups, and all of them before its next sibling. Siblings are + /// sorted by name. + pub fn groups(&self) -> impl Iterator> { + self.depth_first(&self.top) + } + + /// Returns the models at the top level, which are in no group, in an + /// unspecified order. These are the models used by methods in more than + /// one top-level group. + pub fn models(&self) -> impl ExactSizeIterator> { + self.top_models.iter().map(|&id| self.model(id)) + } + + fn group(&self, id: GroupId) -> Group<'_> { + Group { forge: self, id } + } + + fn method(&self, id: MethodId) -> Method<'_> { + Method { forge: self, id } + } + + fn model(&self, id: ModelId) -> Model<'_> { + Model { forge: self, id } + } + + /// Returns the groups `ids` and every group inside them, depth first, + /// keeping the order of `ids` and of each group's subgroups. + fn depth_first<'f>(&'f self, ids: &'f [GroupId]) -> impl Iterator> { + // The groups still to visit, the next one last, so each list is + // pushed in reverse. + let mut stack: Vec = ids.iter().rev().copied().collect(); + iter::from_fn(move || { + let group = self.group(stack.pop()?); + stack.extend(group.data().groups.iter().rev()); + Some(group) + }) + } +} + +/// A named collection of subgroups, methods and models in a [`Forge`]. +/// +/// A `Group` is a reference into the `Forge` it came from: it is cheap to +/// copy, and cannot outlive the `Forge`. Two `Group`s are equal when they +/// are the same group of the same `Forge`. +#[derive(Clone, Copy)] +pub struct Group<'f> { + forge: &'f Forge, + id: GroupId, +} + +impl<'f> Group<'f> { + /// Returns the name of the group. + pub fn name(self) -> &'f Name { + &self.data().name + } + + /// Returns a short description of the group, or an empty string if it + /// has none. + /// + /// Comes from the `x-forge-commands` extension at the root of the + /// document, which has an entry for each group, keyed by name. A + /// top-level group's description is the `description` of its entry. A + /// subgroup's entry sits in the `groups` of its parent's entry, or + /// anywhere above it under a key with dots: `a.b` in the root, or `b.c` + /// in the `groups` of `a`, both describe the group `c` in `b` in `a`. + /// Names match when they are equal as [`Name`]s. Two entries for the + /// same group are an error. + pub fn description(self) -> &'f str { + &self.data().description + } + + /// Returns the names of the groups from the top level down to this one, + /// ending with this group's own name. + pub fn path(self) -> impl ExactSizeIterator + DoubleEndedIterator { + self.data() + .path + .iter() + .map(move |&id| &self.forge.groups[id.index()].name) + } + + /// Returns the group this one is inside, or `None` for a top-level + /// group. + pub fn parent(self) -> Option> { + // The path ends with this group, so its parent comes just before. + let [.., parent, _] = self.data().path[..] else { + return None; + }; + Some(self.forge.group(parent)) + } + + /// Returns the groups directly inside this one, sorted by name. + pub fn children(self) -> impl ExactSizeIterator> { + self.data().groups.iter().map(move |&id| self.forge.group(id)) + } + + /// Returns every group below this one, depth first, walked the same way + /// as [`Forge::groups`]. This group is not included. + pub fn descendants(self) -> impl Iterator> { + self.forge.depth_first(&self.data().groups) + } + + /// Returns the methods directly in this group, sorted by name. + pub fn methods(self) -> impl ExactSizeIterator> { + self.data().methods.iter().map(move |&id| self.forge.method(id)) + } + + /// Returns the models placed directly in this group, in an unspecified + /// order. See [`Model::group`] for where a model is placed. + pub fn models(self) -> impl ExactSizeIterator> { + self.data().models.iter().map(move |&id| self.forge.model(id)) + } + + fn data(self) -> &'f GroupData { + &self.forge.groups[self.id.index()] + } +} + +impl PartialEq for Group<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.forge, other.forge) && self.id == other.id + } +} + +impl Eq for Group<'_> {} + +impl std::hash::Hash for Group<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.forge, state); + self.id.hash(state); + } +} + +impl std::fmt::Debug for Group<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Group") + .field("path", &self.path().collect::>()) + .finish() + } +} + +/// A named operation in a [`Forge`]: one OpenAPI operation that an SDK +/// exposes as a function. +/// +/// An operation can instead be exposed as several methods, each under its +/// own group and name, with the `x-forge-aliases` extension: an array of +/// objects, each an alias that is a method of the operation. The +/// operation's own group and name then make no method, and its own +/// `x-fern-ignore` does not stop its aliases. An empty array makes no +/// method, an element that is not an object is ignored, and a value that +/// is not an array is an error. Aliases do not nest: an `x-forge-aliases` +/// in an alias is ignored, as are its keys that are not extensions. +/// +/// An alias says, as an operation would, the method's +/// [`group`](Method::group), [`name`](Method::name), +/// [`availability`](Method::availability), whether it is +/// [hidden](Method::is_hidden), its [`confirmation`](Method::confirmation) +/// and its [`extensions`](Method::extensions), and whether it is ignored +/// with `x-fern-ignore`. None of these come from the operation: an alias +/// without an extension does not have the operation's. The rest, from the +/// HTTP method and path to the responses, is the operation's, and every +/// alias has it. Each alias is a method of its own group, so a model one +/// uses is placed as though each alias were a separate operation. +/// +/// Like a [`Group`], a `Method` is a cheap reference into its `Forge`. Two +/// `Method`s are equal when they are the same method of the same `Forge`. +#[derive(Clone, Copy)] +pub struct Method<'f> { + forge: &'f Forge, + id: MethodId, +} + +impl<'f> Method<'f> { + /// Returns the name of the method, which is unique within its group. + /// + /// Corresponds to the `x-fern-sdk-method-name` extension of the + /// operation, or of the alias the method comes from. An operation or + /// alias without one, or with an empty one, is not a method, and one + /// with no letters or digits is an error. Two methods in a group with + /// equal names are an error, whether they are aliases or not. + pub fn name(self) -> &'f Name { + &self.data().name + } + + /// Returns the group the method is in. + /// + /// Corresponds to the `x-fern-sdk-group-name` extension of the + /// operation, or of the alias the method comes from: a string of group + /// names separated by dots, such as `accounts.members`, or an array + /// with one group name per element, top level first. An operation or + /// alias without one, with an empty one, or with `x-fern-ignore: true` + /// is not a method. A group name with no letters or digits is an error. + pub fn group(self) -> Group<'f> { + self.forge.group(self.data().group) + } + + /// Returns the HTTP method the operation is sent with. + pub fn http_method(self) -> &'f HttpMethod { + &self.data().http_method + } + + /// Returns the path template the operation is declared under, such as + /// `/accounts/{account_id}`. + pub fn path(self) -> &'f str { + self.forge.strings.get(self.data().path) + } + + /// Returns a short summary of what the method does, or an empty string + /// if it has none. + /// + /// Corresponds to `summary` in the + /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object). + pub fn summary(self) -> &'f str { + self.forge.strings.get(self.data().summary) + } + + /// Returns a description of the method, or an empty string if it has + /// none. + /// + /// Corresponds to `description` in the + /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object). + pub fn description(self) -> &'f str { + self.forge.strings.get(self.data().description) + } + + /// Reports whether the method is deprecated. + /// + /// Corresponds to `deprecated` in the + /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object). + /// It is apart from [`availability`](Method::availability): a method + /// can be deprecated without being [`Availability::Deprecated`], and + /// the other way around. + pub fn is_deprecated(self) -> bool { + self.data().deprecated + } + + /// Returns the stage of the API the method is in, such as + /// [`Availability::Beta`], or `None` if the operation does not say. + /// + /// Corresponds to the `x-fern-availability` extension of the + /// operation, a string that is one of `alpha`, `beta`, `preview`, + /// `generally-available` and `deprecated`, as [`Availability`] lists. + /// Any other value, including one that differs only in case, is an + /// error naming the operation's method and path, as is a value that is + /// not a string. + /// + /// It is apart from [`is_deprecated`](Method::is_deprecated), which + /// comes from OpenAPI's own `deprecated`: an operation with + /// `x-fern-availability: deprecated` and without `deprecated: true` is + /// [`Availability::Deprecated`] but not deprecated, and one with + /// `deprecated: true` has whatever availability it says, or none. + pub fn availability(self) -> Option { + self.data().availability + } + + /// Reports whether the method should be left out of listings of + /// methods, such as a CLI's help, although it can still be called. + /// + /// Corresponds to the `x-forge-hidden` extension of the operation, a + /// boolean, which is false without it. A value that is not a boolean + /// is an error, as for `x-fern-ignore`. Unlike `x-fern-ignore`, a + /// hidden operation is still a method. + pub fn is_hidden(self) -> bool { + self.data().hidden + } + + /// Returns a sentence to show before calling the method, which changes + /// or deletes something irreversibly, such as "This operation deletes + /// the database.", or `None` if the method needs no confirmation. + /// + /// Corresponds to the `x-forge-require-confirmation` extension of the + /// operation, a string, as written. An empty string is `Some("")`: the + /// method needs confirmation, with no sentence of its own to show. A + /// value that is not a string is an error. + pub fn confirmation(self) -> Option<&'f str> { + Some(self.forge.strings.get(self.data().confirmation?)) + } + + /// Returns the type of what a caller passes to call the method, or + /// `None` if the method has no parameters and no body. + /// + /// The parameters are an object with a field for each path and query + /// parameter, and [`location`](Method::location) says where each field + /// is sent. The request is: + /// + /// - the body alone, of whatever type, when there are no parameters; + /// - the intersection of the parameters and the body, when the body is + /// an object with fields, or a model, union or intersection of them; + /// - the parameters and a `body` field holding the body, when the body + /// is anything else, or has a field with the name of a parameter; + /// - the parameters alone, when there is no body. + /// + /// A body that is an intersection is one member of the request's + /// intersection. The `body` field is required when the body is. The body + /// is the type of its [JSON content](Body::json), or [`Scalar::Unknown`] + /// when it has no JSON media type, although [`Content::ty`] types its + /// other media types too. + /// + /// The order is path, query, then body. The parameters' fields are the + /// path parameters in the order of the path template, then the query + /// parameters in the order they are declared, the path item's first. + /// The parameters are the first member of the intersection, and the + /// `body` field is their last field. + /// + /// The body keeps its read-only fields, since a model is the same + /// object in requests and responses: a generator leaves out of what a + /// client sends the fields that [`Field::is_read_only`] reports. A + /// parameter's field is never read-only. + /// + /// A parameter's field is named after the `x-fern-parameter-name` + /// extension of the parameter, or else its wire name. Its description + /// is the parameter's, or else its schema's, following `$ref`s, as + /// [`Field::description`] says; from OpenAPI 3.1, the `description` of + /// a Reference Object to the parameter overrides the parameter's. A + /// parameter of the operation replaces one of the path item with the + /// same name and location. A path template placeholder that neither + /// declares is a string, and a path parameter that is not in the + /// template is left out. Two parameters with equal names are an error. + /// + /// Header parameters are not part of the request: see + /// [`headers`](Method::headers). Cookie parameters are ignored. In + /// OpenAPI 3.2, the + /// properties of the object of an `in: querystring` parameter are query + /// parameters; there can be one such parameter, and not together with + /// `in: query` ones. A parameter with `content` instead of `schema` is + /// not covered yet, and is an error. [`Location`] says which styles are + /// covered. + pub fn request(self) -> Option> { + Some(Type::new(self.forge, self.data().request?)) + } + + /// Returns where `field` is sent, or `None` if it is not a parameter + /// of the method. + /// + /// Every field of the parameters in [`request`](Method::request) has a + /// location. The request's other fields, including a `body` field, are + /// sent in the body. A field from anywhere else, such as another + /// method's request, also gives `None`. + pub fn location(self, field: Field<'f>) -> Option { + let data = self.data(); + let TypeData::Object(params) = &self.forge.types[data.params?.index()] else { + return None; + }; + let fields = self.forge.fields.get(params.fields); + let i = fields.iter().position(|f| ptr::eq(f, field.data))?; + // The `body` field, which is last, has no location. + data.locations.get(i).copied() + } + + /// Returns the header parameters of the method, as an object with a + /// field for each, or `None` if it has none. + /// + /// The headers are an [`Object`] rather than a [`Type`], since they are + /// always one, and are apart from [`request`](Method::request): their + /// fields are in neither the request nor [`location`](Method::location). + /// Each field is sent as the header named by its wire name, which is + /// the parameter's name as written. Its name, description, type, and + /// whether it is required, come from the parameter as for a query + /// parameter in the request, and it is deprecated when the parameter + /// is. + /// + /// The fields are in the order the parameters are declared, the path + /// item's first. A parameter of the operation replaces one of the path + /// item, taking its place, when their names are the same ignoring + /// ASCII case, since HTTP header names are case-insensitive: so + /// `x-request-id` replaces `X-Request-ID`, and its field has the wire + /// name `x-request-id`. Two header fields with equal names are an + /// error, as `X-A` and `x-a` declared by the same path item are. + /// + /// Forge keeps header parameters named `Accept`, `Content-Type` and + /// `Authorization` like any other, although OpenAPI says to ignore + /// them, since APIs declare them for a caller to set, such as the + /// content type of a raw upload. A generator that sets these headers + /// itself can leave them out. + /// + /// Only the default style, `simple`, is covered: other styles are + /// errors, and `explode`, which changes only how an object is written, + /// is ignored. A parameter with `content` instead of `schema` is not + /// covered yet, and is an error, as in the request. + pub fn headers(self) -> Option> { + let TypeData::Object(data) = &self.forge.types[self.data().headers?.index()] else { + return None; + }; + Some(Object::new(self.forge, data)) + } + + /// Returns the request body, or `None` if the method has none. + pub fn body(self) -> Option> { + Some(Body::new(self.forge, self.data().body.as_ref()?)) + } + + /// Returns the success responses, those with a `2XX` status, sorted by + /// status. + pub fn responses(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().responses.iter().map(move |data| Response::new(forge, data)) + } + + /// Returns every other response the method declares, sorted by status + /// with `default` last. + pub fn errors(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().errors.iter().map(move |data| Response::new(forge, data)) + } + + /// Returns the value of the extension `name` of the method's operation, + /// or alias, such as `x-forge-params`, or `None` if it has none. Only + /// extensions forge does not read itself are kept, as + /// [`extensions`](Method::extensions) says. + pub fn extension(self, name: &str) -> Option<&'f Json> { + self.extensions().find(|&(n, _)| n == name).map(|(_, value)| value) + } + + /// Returns every extension of the method's operation that forge does + /// not read itself, in the order they are written, for plugins to read + /// as they need. + /// + /// An extension is a key of the + /// [Operation Object](https://spec.openapis.org/oas/latest.html#operation-object) + /// that starts with `x-`. Those forge reads, `x-fern-sdk-method-name`, + /// `x-fern-sdk-group-name`, `x-fern-ignore`, `x-fern-availability`, + /// `x-forge-hidden`, `x-forge-require-confirmation` and + /// `x-forge-aliases`, are left out, and other keys forge does not read + /// are not kept. An extension written more than once is there once, + /// with the last value written, in the place of the first, as with + /// `JSON.parse`. Extensions of the path item, the parameters, the body, + /// the responses and the schemas are not kept. + /// + /// For a method that comes from an alias, these are the extensions of + /// the alias, left out the same way, and none of the operation's. + pub fn extensions(self) -> impl ExactSizeIterator { + let extensions = self.forge.extensions.get(self.data().extensions); + extensions.iter().map(|(name, value)| (name.as_str(), value)) + } + + fn data(self) -> &'f MethodData { + &self.forge.methods[self.id.index()] + } +} + +impl PartialEq for Method<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.forge, other.forge) && self.id == other.id + } +} + +impl Eq for Method<'_> {} + +impl std::hash::Hash for Method<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.forge, state); + self.id.hash(state); + } +} + +impl std::fmt::Debug for Method<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Method") + .field("group", &self.group().path().collect::>()) + .field("name", self.name()) + .field("http_method", self.http_method()) + .field("path", &self.path()) + .finish() + } +} + +/// The HTTP method of a [`Method`]. +/// +/// Each variant but `Other` is the method of one of the fixed fields of a +/// [Path Item Object](https://spec.openapis.org/oas/latest.html#path-item-object), +/// so `get` is [`Get`](HttpMethod::Get). A key in `additionalOperations` that is +/// exactly the method of a fixed field, such as `GET`, is that variant too, +/// so each method has one value. Such a key is ignored when the path item +/// also has the fixed field. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum HttpMethod { + Get, + Put, + Post, + Delete, + Options, + Head, + Patch, + Trace, + Query, + Connect, + /// Any other method, from a key in `additionalOperations`, as written, + /// such as `COPY`. Methods are case-sensitive, so `get` is `Other` too. + Other(String), +} + +impl HttpMethod { + /// Returns the method as it is sent in a request, such as `GET`. + pub fn as_str(&self) -> &str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Put => "PUT", + HttpMethod::Post => "POST", + HttpMethod::Delete => "DELETE", + HttpMethod::Options => "OPTIONS", + HttpMethod::Head => "HEAD", + HttpMethod::Patch => "PATCH", + HttpMethod::Trace => "TRACE", + HttpMethod::Query => "QUERY", + HttpMethod::Connect => "CONNECT", + HttpMethod::Other(method) => method, + } + } +} + +/// The stage of the API a [`Method`] is in, as +/// [`Method::availability`] gives it. +/// +/// Corresponds to the value of the `x-fern-availability` extension of an +/// operation, which each variant names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Availability { + /// `alpha`. + Alpha, + /// `beta`. + Beta, + /// `preview`. + Preview, + /// `generally-available`. + GenerallyAvailable, + /// `deprecated`, which is apart from OpenAPI's `deprecated`, as + /// [`Method::is_deprecated`] says. + Deprecated, +} + +impl Availability { + /// Returns the availability as `x-fern-availability` writes it, such + /// as `generally-available`. + pub fn as_str(self) -> &'static str { + match self { + Availability::Alpha => "alpha", + Availability::Beta => "beta", + Availability::Preview => "preview", + Availability::GenerallyAvailable => "generally-available", + Availability::Deprecated => "deprecated", + } + } +} + +/// A named schema from `components.schemas` in a [`Forge`], or from the +/// `$defs` of one, which an SDK declares as a type. +/// +/// Only schemas that a method uses, directly or through other schemas, are +/// models. A use in the content of the body or of a response counts +/// whatever its media type, JSON or not, and so does a use in a header +/// parameter, one of [`Method::headers`]. Response headers are not read, +/// so a use in one does not count. +/// +/// Like a [`Group`], a `Model` is a cheap reference into its `Forge`. Two +/// `Model`s are equal when they are the same model of the same `Forge`. +#[derive(Clone, Copy)] +pub struct Model<'f> { + forge: &'f Forge, + id: ModelId, +} + +impl<'f> Model<'f> { + /// Returns where the schema is under `components.schemas`: its key, + /// such as `Account`, or for a schema in the `$defs` of another, a path + /// such as `Tree/$defs/Node`. + pub fn schema_name(self) -> &'f str { + self.forge.strings.get(self.data().schema_name) + } + + /// Returns the name of the model. + /// + /// Corresponds to the `x-fern-type-name` extension of the schema. A + /// schema without one is named after its schema name, leaving out + /// `$defs`, so `Tree/$defs/Node` has the name `Tree Node`. + pub fn name(self) -> &'f Name { + &self.data().name + } + + /// Returns the group the model is placed in, or `None` if it is at the + /// top level. + /// + /// A model is placed in the deepest group shared by every method that + /// uses it, directly or through other models: a model used in `a.b.c` + /// and `a.b.d` is in `a.b`, one used only in `a.b.e` is in `a.b.e`, and + /// one used in `a` and `x` is at the top level. + pub fn group(self) -> Option> { + Some(self.forge.group(self.data().group?)) + } + + /// Returns a description of the model, or an empty string if it has + /// none. + /// + /// Corresponds to `description` in the + /// [Schema Object](https://spec.openapis.org/oas/latest.html#schema-object), + /// or `title` if it has no description. + pub fn description(self) -> &'f str { + self.forge.strings.get(self.data().description) + } + + /// Reports whether the model is deprecated. + /// + /// Corresponds to `deprecated` in the + /// [Schema Object](https://spec.openapis.org/oas/latest.html#schema-object). + pub fn is_deprecated(self) -> bool { + self.data().deprecated + } + + /// Returns the type of the model's schema. + pub fn ty(self) -> Type<'f> { + Type::new(self.forge, self.data().ty) + } + + fn data(self) -> &'f ModelData { + &self.forge.models[self.id.index()] + } +} + +impl PartialEq for Model<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.forge, other.forge) && self.id == other.id + } +} + +impl Eq for Model<'_> {} + +impl std::hash::Hash for Model<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.forge, state); + self.id.hash(state); + } +} + +/// Writes the [schema name](Model::schema_name), such as `Account` or +/// `Tree/$defs/Node`. +impl std::fmt::Display for Model<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.schema_name()) + } +} + +impl std::fmt::Debug for Model<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Model") + .field("group", &self.group().map(|group| group.path().collect::>())) + .field("schema_name", &self.schema_name()) + .finish() + } +} + +/// The index of a group's data in its `Forge`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct GroupId(u32); + +impl GroupId { + fn index(self) -> usize { + self.0 as usize + } +} + +/// What a [`Forge`] stores for each group. +struct GroupData { + name: Name, + description: String, + /// The groups directly inside this one. + groups: Vec, + /// The groups from the top level down to this one, ending with this + /// group itself. + path: Vec, + /// The methods directly in this group. + methods: Vec, + /// The models placed directly in this group. + models: Vec, +} + +/// The index of a method's data in its `Forge`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct MethodId(u32); + +impl MethodId { + fn index(self) -> usize { + self.0 as usize + } +} + +/// What a [`Forge`] stores for each method. +struct MethodData { + name: Name, + /// The group the method is in. + group: GroupId, + http_method: HttpMethod, + /// The path template, such as `/accounts/{account_id}`. + path: Str, + summary: Str, + description: Str, + deprecated: bool, + availability: Option, + hidden: bool, + /// The sentence to confirm with, or `None` for none. + confirmation: Option, + /// The type of the request. + request: Option, + /// The object of parameters in `request`. + params: Option, + /// Where each field of `params` is sent, in the same order, except a + /// `body` field, which is last and has none. + locations: Vec, + /// The object of header parameters, if there are any. + headers: Option, + body: Option, + responses: Vec, + errors: Vec, + /// The extensions of the operation that are not read, in + /// `Forge::extensions`. + extensions: Span<(String, Json)>, +} + +/// The index of a model's data in its `Forge`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct ModelId(u32); + +impl ModelId { + fn index(self) -> usize { + self.0 as usize + } +} + +/// What a [`Forge`] stores for each model. +struct ModelData { + /// Where the schema is under `components.schemas`. + schema_name: Str, + name: Name, + /// The group the model is placed in, or none for the top level. + group: Option, + description: Str, + deprecated: bool, + ty: TypeId, +} diff --git a/src/link.rs b/src/link.rs new file mode 100644 index 000000000..454db0d6f --- /dev/null +++ b/src/link.rs @@ -0,0 +1,2305 @@ +//! Linking an OpenAPI document into a [`Forge`]. +//! +//! [`openapi`] reads the document into arenas of its objects, which keep +//! only what a `Forge` is built from and hold no other logic. This module +//! links them: it reads the operations under `paths` in order, follows +//! `$ref`s, builds the types of each method's parameters, body and +//! responses, and makes the schemas they use models as they are reached. + +mod openapi; +mod read; + +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::{fmt, mem, slice}; + +use serde::Deserialize; +use serde::de::{self, Deserializer}; + +use crate::arena::Id; +use crate::arena::{List, Span, Str, Strings}; +use crate::json::Json; +use crate::method::{BodyData, ContentData, ResponseData, is_json}; +use crate::types::{ + ArrayData, DiscriminatorData, EnumData, FieldData, NameId, ObjectData, TypeData, TypeId, UnionData, +}; +use crate::{ + Availability, EnumValue, Forge, GroupData, GroupId, HttpMethod, Location, MethodData, MethodId, ModelData, ModelId, + Name, Number, Scalar, +}; +use openapi::{ + Commands, ContentEncoding, Definition, Discriminator, Doc, Format, Info, Item, Literal, Operation, Parameter, + ParameterLocation, ParameterStyle, PathItem, Referable, RequestBody, Response, Schema, SchemaObject, SchemaType, + Server, +}; + +/// Reads a `Forge` from an OpenAPI document of version 3.0, 3.1 or 3.2, +/// in any format serde can read, such as JSON with +/// `serde_json::from_slice`. Any other version, or a Swagger 2.0 document, +/// is an error. +/// +/// The methods come from the operations under `paths`, including those in +/// `additionalOperations` and in path items reached by `$ref`, and from +/// their aliases, as [`Method`](crate::Method) says. Webhooks, callbacks, +/// response headers, cookie parameters, security, and extensions these +/// docs do not mention are ignored, except that those of an operation or +/// alias are kept for [`Method::extensions`](crate::Method::extensions). +/// +/// The schema of content in every media type is read, JSON or not, so an +/// error in it is reported whatever the media type. A part of the document +/// that cannot be ignored but is not covered yet, such as a parameter with +/// `content`, is an error. +/// Each type and method says what it covers. +/// +/// The first server's `url` using a variable that its `variables` does not +/// define, or having a `{` that is not closed, is an error. +/// +/// Every error in the document is reported, one per line. +impl<'de> Deserialize<'de> for Forge { + fn deserialize>(deserializer: D) -> Result { + let doc = Doc::read(deserializer)?; + match build(doc) { + Ok(forge) => Ok(forge), + Err(errors) => Err(de::Error::custom(errors.join("\n"))), + } + } +} + +/// Returns the `Forge` of `doc`, or every error in it. +fn build(mut doc: Doc) -> Result> { + let version = mem::take(&mut doc.openapi); + if version.is_empty() { + return Err(vec![ + "the document has no openapi version: only OpenAPI 3.0, 3.1 and 3.2 are read".into(), + ]); + } + let v30 = version.starts_with("3.0."); + if !v30 && !version.starts_with("3.1.") && !version.starts_with("3.2.") { + return Err(vec![format!( + "openapi version {version:?} is not read: only OpenAPI 3.0, 3.1 and 3.2 are" + )]); + } + let info = mem::take(&mut doc.info); + let servers = mem::take(&mut doc.servers); + let mut commands = mem::take(&mut doc.commands); + // Taken whole, rather than each method's copied, so that a method keeps + // the span of its operation's extensions. + let extensions = mem::take(&mut doc.extensions); + // Taken whole too: a field keeps the `Id` of its schema's default. + let defaults = mem::take(&mut doc.defaults); + let index = Index::new(&doc); + let mut builder = Builder::new(&doc, &index, v30); + let url = match base_url(&servers) { + Ok(url) => url, + Err(err) => { + builder.errors.push(err); + String::new() + } + }; + builder.add_paths(doc.paths); + let Builder { + mut groups, + mut top, + methods, + models, + types, + errors, + strings, + name_ids, + fields, + members, + values, + .. + } = builder; + if !errors.is_empty() { + return Err(errors); + } + sort(&mut groups, &mut top, &methods); + describe(&mut groups, &top, &mut commands); + let mut names = vec![Name::default(); name_ids.len()]; + for (name, id) in name_ids { + names[id.index()] = name; + } + let mut forge = Forge { + description: description(&info), + version: info.version, + base_url: url, + groups, + top, + methods, + models, + top_models: Vec::new(), + types, + strings, + names, + fields, + members, + values, + extensions, + defaults, + }; + forge.top_models = place_models(&mut forge); + Ok(forge) +} + +/// Returns the title, summary and description of `info` that are not +/// empty, joined as paragraphs. +fn description(info: &Info) -> String { + let len = info.title.len() + info.summary.len() + info.description.len(); + if len == 0 { + return String::new(); + } + let mut description = String::with_capacity(len + 4); + for part in [&info.title, &info.summary, &info.description] { + if part.is_empty() { + continue; + } + if !description.is_empty() { + description.push_str("\n\n"); + } + description.push_str(part); + } + description +} + +/// Returns the URL of `server` with each `{variable}` replaced by its +/// default, or an error if a variable is not defined or a `{` is not +/// closed. +fn base_url(server: &Server) -> Result { + let mut url = String::with_capacity(server.url.len()); + let mut rest = server.url.as_str(); + while let Some(open) = rest.find('{') { + let Some(len) = rest[open..].find('}') else { + return Err(format!("server url {:?}: a '{{' is not closed", server.url)); + }; + let close = open + len; + let name = &rest[open + 1..close]; + let Some(variable) = server.variables.get(name) else { + return Err(format!("server url {:?}: variable {name:?} is not defined", server.url)); + }; + url.push_str(&rest[..open]); + url.push_str(&variable.default); + rest = &rest[close + 1..]; + } + url.push_str(rest); + Ok(url) +} + +/// Sorts the top-level groups `top`, and the subgroups and methods of each +/// group, by name. Only the lists of IDs are sorted, so every ID stays the +/// same. +fn sort(groups: &mut [GroupData], top: &mut [GroupId], methods: &[MethodData]) { + top.sort_by(|a, b| groups[a.index()].name.cmp(&groups[b.index()].name)); + for i in 0..groups.len() { + // Moved out while sorted, since the names are in the same slice. + let mut children = mem::take(&mut groups[i].groups); + children.sort_by(|a, b| groups[a.index()].name.cmp(&groups[b.index()].name)); + groups[i].groups = children; + groups[i] + .methods + .sort_by(|a, b| methods[a.index()].name.cmp(&methods[b.index()].name)); + } +} + +/// Sets the description of the groups `ids` and every group inside them +/// from their entries in `commands`. +fn describe(groups: &mut [GroupData], ids: &[GroupId], commands: &mut Commands) { + for &id in ids { + let data = &mut groups[id.index()]; + let Some(command) = commands.0.get_mut(&data.name) else { + continue; + }; + data.description = mem::take(&mut command.description); + // Moved out while the subgroups are described, since they live in + // the same slice. + let children = mem::take(&mut data.groups); + describe(groups, &children, &mut command.groups); + groups[id.index()].groups = children; + } +} + +/// Places each model in the deepest group shared by every method that uses +/// it, directly or through other models, and returns the models placed at +/// the top level. +fn place_models(forge: &mut Forge) -> Vec { + let Forge { + groups, + methods, + models, + types, + fields, + members, + .. + } = forge; + let types = Types { types, fields, members }; + let mut walk = Vec::new(); + // The models each model uses directly. + let mut uses: Vec> = Vec::with_capacity(models.len()); + for model in models.iter() { + let mut used = Vec::new(); + models_in(&types, model.ty, &mut used, &mut walk); + uses.push(used); + } + // The groups shared by every use of each model so far, from the top + // level down; none for a model not yet reached. + let mut shared: Vec>> = vec![None; models.len()]; + // The last method that reached each model, so a method walks each + // model once. + let mut reached_by: Vec = vec![usize::MAX; models.len()]; + let mut stack = Vec::new(); + for (i, method) in methods.iter().enumerate() { + method_models(&types, method, &mut stack, &mut walk); + let path = &groups[method.group.index()].path; + while let Some(id) = stack.pop() { + let m = id.index(); + if reached_by[m] == i { + continue; + } + reached_by[m] = i; + if let Some(shared) = &mut shared[m] { + let mut len = 0; + while len < shared.len() && len < path.len() && shared[len] == path[len] { + len += 1; + } + shared.truncate(len); + } else { + shared[m] = Some(path.clone()); + } + stack.extend_from_slice(&uses[m]); + } + } + let mut top = Vec::new(); + for (m, shared) in shared.into_iter().enumerate() { + let id = ModelId(m as u32); + let group = match shared { + Some(shared) => shared.last().copied(), + None => None, + }; + models[m].group = group; + match group { + Some(group) => groups[group.index()].models.push(id), + None => top.push(id), + } + } + top +} + +/// The types of a `Forge`, and the arenas of their lists. +struct Types<'a> { + types: &'a [TypeData], + fields: &'a List, + members: &'a List, +} + +/// Adds to `out` the models that `method` uses directly. `stack` is space +/// for [`models_in`]. +fn method_models(types: &Types<'_>, method: &MethodData, out: &mut Vec, stack: &mut Vec) { + if let Some(request) = method.request { + models_in(types, request, out, stack); + } + if let Some(headers) = method.headers { + models_in(types, headers, out, stack); + } + if let Some(body) = &method.body { + for content in &body.contents { + models_in(types, content.ty, out, stack); + } + } + for response in &method.responses { + for content in &response.contents { + models_in(types, content.ty, out, stack); + } + } + for response in &method.errors { + for content in &response.contents { + models_in(types, content.ty, out, stack); + } + } +} + +/// Adds to `out` the models that `ty` uses, without looking inside them. +/// `stack` is space for the types still to look at, which is left empty. +fn models_in(types: &Types<'_>, ty: TypeId, out: &mut Vec, stack: &mut Vec) { + stack.push(ty); + while let Some(ty) = stack.pop() { + match &types.types[ty.index()] { + TypeData::Scalar(_) | TypeData::Enum(_) => {} + TypeData::Array(array) => stack.push(array.elem), + TypeData::Object(object) => { + for field in types.fields.get(object.fields) { + stack.push(field.ty); + } + if let Some(extra) = object.extra { + stack.push(extra); + } + } + TypeData::Union(union) => { + stack.extend_from_slice(types.members.get(union.members)); + if let Some(discriminator) = &union.discriminator { + for case in &discriminator.cases { + stack.push(case.1); + } + if let Some(default) = discriminator.default { + out.push(default); + } + } + } + TypeData::Intersection(members) => stack.extend_from_slice(types.members.get(*members)), + TypeData::Model(id) => out.push(*id), + } + } +} + +/// The objects in `components` of a document, by name. +struct Index<'d> { + schemas: HashMap<&'d str, Schema>, + parameters: HashMap<&'d str, Id>, + request_bodies: HashMap<&'d str, Id>, + responses: HashMap<&'d str, Id>, + path_items: HashMap<&'d str, Id>, +} + +impl<'d> Index<'d> { + fn new(doc: &'d Doc) -> Index<'d> { + let components = doc.components; + Index { + schemas: by_name(doc, components.schemas), + parameters: by_name(doc, components.parameters), + request_bodies: by_name(doc, components.request_bodies), + responses: by_name(doc, components.responses), + path_items: by_name(doc, components.path_items), + } + } +} + +/// Returns the values of `entries` by key. Of two entries with the same +/// key, the last one is kept. +fn by_name(doc: &Doc, entries: Span<(Str, T)>) -> HashMap<&str, T> +where + (Str, T): Item, +{ + let entries = doc.list(entries); + let mut map = HashMap::with_capacity(entries.len()); + for &(name, value) in entries { + map.insert(doc.str(name), value); + } + map +} + +/// What a `Forge` is built from while the document is read. +struct Builder<'d> { + doc: &'d Doc, + index: &'d Index<'d>, + /// Whether the document is OpenAPI 3.0, where the keywords next to a + /// `$ref` are ignored. + v30: bool, + + groups: Vec, + top: Vec, + /// Each group by its parent, or none for a top-level group, and name. + group_index: HashMap<(Option, Name), GroupId>, + methods: Vec, + /// Each method by group and name. + method_index: HashMap<(GroupId, Name), MethodId>, + types: Vec, + /// The type of each scalar, which every use shares. + scalars: Vec<(Scalar, TypeId)>, + models: Vec, + /// The type of each model, indexed by `ModelId`, which every use + /// shares. + model_types: Vec, + /// Each model by schema name. + model_index: HashMap, + /// The `$ref`s being written in place, innermost last, to catch one + /// that leads back to itself. + inline_refs: Vec, + + /// What is being read, to start each error with. + context: Context<'d>, + errors: Vec, + + // The arenas of the `Forge`. + strings: Strings, + /// Every name of a field, once each, and its ID. + name_ids: HashMap, + /// The ID of the name each string is written as. + names_by_text: HashMap<&'d str, NameId>, + /// The ID of the empty name, once there is one. + empty_name: Option, + fields: List, + members: List, + values: List, + /// Lists to build the fields of objects in, kept to be used again. + spare_fields: Vec>, +} + +/// What a [`Builder`] is reading, which is written only for an error. +#[derive(Clone, Copy)] +enum Context<'d> { + None, + /// The path item at a path. + Path(&'d str), + /// The operation of a method and path, such as `GET /a`. + Operation(&'d str, &'d str), + /// The alias at an index in `x-forge-aliases` of the operation of a + /// method and path. + Alias(&'d str, &'d str, usize), + /// The schema of a model. + Schema(ModelId), +} + +impl<'d> Builder<'d> { + fn new(doc: &'d Doc, index: &'d Index<'d>, v30: bool) -> Builder<'d> { + Builder { + doc, + index, + v30, + groups: Vec::new(), + top: Vec::new(), + group_index: HashMap::new(), + methods: Vec::new(), + method_index: HashMap::new(), + types: Vec::new(), + scalars: Vec::new(), + models: Vec::new(), + model_types: Vec::new(), + model_index: HashMap::new(), + inline_refs: Vec::new(), + context: Context::None, + errors: Vec::new(), + strings: Strings::default(), + name_ids: HashMap::new(), + names_by_text: HashMap::new(), + empty_name: None, + fields: List::default(), + members: List::default(), + values: List::default(), + spare_fields: Vec::new(), + } + } + + /// Adds a method for each operation in `paths`. + fn add_paths(&mut self, paths: Span<(Str, Id)>) { + let doc = self.doc; + let index = self.index; + for &(path, item) in doc.list(paths) { + let path = doc.str(path); + self.context = Context::Path(path); + let Some(item) = self.follow(item, &index.path_items, "pathItems") else { + continue; + }; + let fixed = [ + (item.get, HttpMethod::Get, "GET"), + (item.put, HttpMethod::Put, "PUT"), + (item.post, HttpMethod::Post, "POST"), + (item.delete, HttpMethod::Delete, "DELETE"), + (item.options, HttpMethod::Options, "OPTIONS"), + (item.head, HttpMethod::Head, "HEAD"), + (item.patch, HttpMethod::Patch, "PATCH"), + (item.trace, HttpMethod::Trace, "TRACE"), + (item.query, HttpMethod::Query, "QUERY"), + (item.connect, HttpMethod::Connect, "CONNECT"), + ]; + let mut present: Vec = Vec::with_capacity(fixed.len()); + for (operation, http_method, method) in fixed { + if let Some(operation) = operation { + present.push(http_method.clone()); + self.add_operation(path, item, doc.get(operation), http_method, method); + } + } + // HTTP methods are case sensitive: + // https://www.rfc-editor.org/rfc/rfc9110#name-overview + for &(method, operation) in doc.list(item.additional_operations) { + let method = doc.str(method); + let http_method = match fixed_method(method) { + Some(http_method) => http_method, + None => HttpMethod::Other(method.to_string()), + }; + // The fixed field of the same method wins. + if present.contains(&http_method) { + continue; + } + self.add_operation(path, item, doc.get(operation), http_method, method); + } + } + } + + /// Adds the methods of `operation`, of the path item `item` at `path`: + /// one for each of its aliases if it has `x-forge-aliases`, and else + /// one for the operation itself, if it is one. `method` is + /// `http_method` as a request sends it. + fn add_operation( + &mut self, + path: &'d str, + item: &'d PathItem, + operation: &'d Operation, + http_method: HttpMethod, + method: &'d str, + ) { + let doc = self.doc; + let Some(aliases) = operation.aliases else { + self.context = Context::Operation(method, path); + self.add_method(path, item, operation, &operation.definition, http_method); + return; + }; + // The operation's own definition makes no method, so its + // `x-fern-ignore` does not stop its aliases. + for (i, alias) in doc.list(aliases).iter().enumerate() { + self.context = Context::Alias(method, path, i); + self.add_method(path, item, operation, alias, http_method.clone()); + } + } + + /// Adds the method `definition` makes of `operation`, of the path item + /// `item` at `path`, if it makes one. `definition` is the operation's + /// own or one of its aliases, which the context names. + fn add_method( + &mut self, + path: &'d str, + item: &'d PathItem, + operation: &'d Operation, + definition: &'d Definition, + http_method: HttpMethod, + ) { + let doc = self.doc; + if definition.ignore { + return; + } + if definition.method_name.is_empty() { + return; + } + let group_names = doc.list(definition.group_names); + if group_names.is_empty() { + return; + } + let group_names: Vec = group_names.iter().map(|&name| Name::new(doc.str(name))).collect(); + for name in &group_names { + if name.is_empty() { + self.error("x-fern-sdk-group-name has a group name with no letters or digits"); + return; + } + } + let name = Name::new(doc.str(definition.method_name)); + if name.is_empty() { + self.error("x-fern-sdk-method-name has no letters or digits"); + return; + } + let mut availability = None; + if let Some(value) = definition.availability { + let value = doc.str(value); + availability = parse_availability(value); + if availability.is_none() { + self.error(format_args!( + "x-fern-availability {value:?} is not alpha, beta, preview, generally-available or deprecated" + )); + } + } + let mut group = None; + for group_name in group_names { + group = Some(self.child(group, group_name)); + } + let Some(group) = group else { + return; + }; + let id = MethodId(u32::try_from(self.methods.len()).expect("fewer than 2^32 methods")); + match self.method_index.entry((group, name.clone())) { + Entry::Occupied(entry) => { + let other = &self.methods[entry.get().index()]; + let message = format!( + "method {:?} is also {} {} in the same group", + name.words().join(" "), + other.http_method.as_str(), + self.strings.get(other.path) + ); + self.error(message); + return; + } + // The method is added below, with this ID. + Entry::Vacant(entry) => { + entry.insert(id); + } + } + + // The rest is the operation's, which every alias shares, so an + // error in it names the operation, and is reported once. + if let Context::Alias(method, path, _) = self.context { + self.context = Context::Operation(method, path); + } + let declared = self.declared(item, operation); + let (mut fields, locations) = self.params(path, &declared); + let headers = self.headers(&declared); + let mut body = None; + if let Some(request_body) = operation.request_body { + let index = self.index; + if let Some(request_body) = self.follow(request_body, &index.request_bodies, "requestBodies") { + body = Some(self.body(request_body)); + } + } + let mut params = None; + let mut request = None; + if let Some(body) = &body { + let body_ty = match body.json { + Some(i) => body.contents[i as usize].ty, + None => self.scalar(Scalar::Unknown), + }; + if fields.is_empty() { + request = Some(body_ty); + self.spare_fields.push(fields); + } else if self.has_fields(body_ty, 0) && !self.shares_a_name(body_ty, &fields, 0) { + let object = self.params_object(fields); + params = Some(object); + request = Some(self.intersection(&[object, body_ty])); + } else { + fields.push(FieldData { + wire_name: self.strings.push("body"), + name: self.name("body"), + required: body.required, + ty: body_ty, + description: body.description, + deprecated: false, + read_only: false, + sensitive: false, + default: None, + }); + params = Some(self.params_object(fields)); + request = params; + } + } else if !fields.is_empty() { + params = Some(self.params_object(fields)); + request = params; + } else { + self.spare_fields.push(fields); + } + let (responses, errors) = self.responses(operation); + + self.groups[group.index()].methods.push(id); + self.methods.push(MethodData { + name, + group, + http_method, + path: self.strings.push(path), + summary: self.strings.push(doc.str(operation.summary)), + description: self.strings.push(doc.str(operation.description)), + deprecated: operation.deprecated, + availability, + hidden: definition.hidden, + confirmation: definition.confirmation.map(|s| self.strings.push(doc.str(s))), + request, + params, + locations, + headers, + body, + responses, + errors, + extensions: definition.extensions, + }); + } + + /// Returns the group `name` inside `parent`, or at the top level if + /// `parent` is none, adding it if it is missing. + fn child(&mut self, parent: Option, name: Name) -> GroupId { + let key = (parent, name); + if let Some(&id) = self.group_index.get(&key) { + return id; + } + let name = &key.1; + let id = GroupId(u32::try_from(self.groups.len()).expect("fewer than 2^32 groups")); + let mut path = match parent { + Some(parent) => self.groups[parent.index()].path.clone(), + None => Vec::new(), + }; + path.push(id); + self.groups.push(GroupData { + name: name.clone(), + description: String::new(), + groups: Vec::new(), + path, + methods: Vec::new(), + models: Vec::new(), + }); + match parent { + Some(parent) => self.groups[parent.index()].groups.push(id), + None => self.top.push(id), + } + self.group_index.insert(key, id); + id + } + + /// Returns each parameter of `operation` and of its path item `item`, + /// with its own description, as [`param`](Builder::param) gives them: + /// the path item's in the order they are declared, then the + /// operation's. An operation's parameter that is the same as one + /// before it, as [`same_param`] says, takes its place instead. + fn declared(&mut self, item: &'d PathItem, operation: &'d Operation) -> Vec<(&'d Parameter, Str)> { + let doc = self.doc; + let mut declared: Vec<(&'d Parameter, Str)> = Vec::new(); + for ¶m in doc.list(item.parameters) { + if let Some(declaration) = self.param(param) { + declared.push(declaration); + } + } + for ¶m in doc.list(operation.parameters) { + let Some(declaration) = self.param(param) else { + continue; + }; + match declared + .iter_mut() + .find(|other| same_param(doc, other.0, declaration.0)) + { + Some(other) => *other = declaration, + None => declared.push(declaration), + } + } + declared + } + + /// Returns a field for each path and query parameter in `declared`, + /// the parameters of an operation at `path` as + /// [`declared`](Builder::declared) gives them, and where each is sent. + fn params(&mut self, path: &'d str, declared: &[(&'d Parameter, Str)]) -> (Vec, Vec) { + let doc = self.doc; + let mut fields = self.spare_fields.pop().unwrap_or_default(); + let mut locations = Vec::new(); + for placeholder in placeholders(path) { + let mut declaration = None; + for &(param, description) in declared { + if param.location == Some(ParameterLocation::Path) && doc.str(param.name) == placeholder { + declaration = Some((param, description)); + break; + } + } + let field = match declaration { + Some((param, description)) => { + if !self.check_param(param, ParameterStyle::Simple) { + continue; + } + // A caller must always send a path parameter, so it + // is never deprecated. + self.param_field(param, description, true, false) + } + None => FieldData { + wire_name: self.strings.push(placeholder), + name: self.name(placeholder), + required: true, + ty: self.scalar(Scalar::String), + description: Str::default(), + deprecated: false, + read_only: false, + sensitive: false, + default: None, + }, + }; + fields.push(field); + locations.push(Location::Path); + } + + let mut has_query = false; + let mut query_string = None; + for &(param, description) in declared { + match param.location { + Some(ParameterLocation::Query) => { + has_query = true; + if !self.check_param(param, ParameterStyle::Form) { + continue; + } + let exploded = param.explode != Some(false); + let field = self.param_field(param, description, param.required, param.deprecated); + fields.push(field); + locations.push(Location::Query { exploded }); + } + Some(ParameterLocation::Querystring) if query_string.is_some() => { + self.error("has more than one querystring parameter"); + } + Some(ParameterLocation::Querystring) => query_string = Some(param), + // Path parameters, which are read in the order of the path + // template, header parameters, which are read by + // `headers`, and cookie parameters, which are ignored. + _ => {} + } + } + if let Some(param) = query_string { + if has_query { + let message = format!( + "has querystring parameter {:?} and query parameters, \ + but can have only one or the other", + doc.str(param.name) + ); + self.error(message); + } else if let Some(&(_, schema)) = doc.list(param.content).first() + && let Some(Schema::Object(schema)) = self.deref(schema) + { + let mut more = self.fields(doc.get(schema)); + for mut field in more.drain(..) { + // A client sends every parameter. + field.read_only = false; + fields.push(field); + locations.push(Location::Query { exploded: true }); + } + self.spare_fields.push(more); + } + } + (fields, locations) + } + + /// Returns the object of a field for each header parameter in + /// `declared`, the parameters of an operation as + /// [`declared`](Builder::declared) gives them, in the same order, or + /// `None` if there are none. Every header parameter is kept, even one + /// named `Accept`, `Content-Type` or `Authorization`, which OpenAPI + /// says to ignore. + fn headers(&mut self, declared: &[(&'d Parameter, Str)]) -> Option { + let mut fields = self.spare_fields.pop().unwrap_or_default(); + for &(param, description) in declared { + if param.location != Some(ParameterLocation::Header) || !self.check_param(param, ParameterStyle::Simple) { + continue; + } + fields.push(self.param_field(param, description, param.required, param.deprecated)); + } + if fields.is_empty() { + self.spare_fields.push(fields); + return None; + } + self.check_names(&fields, "header parameters"); + Some(self.object_of(fields, None)) + } + + /// Returns `param`, after following its `$ref`, and its own + /// description, or `None` if it has no name or location. + /// + /// From OpenAPI 3.1, a Reference Object's `description` overrides the + /// referenced parameter's, so the description is the first one next + /// to a `$ref` along the way, or else the parameter's. In 3.0 the + /// keywords next to a `$ref` are ignored. + fn param(&mut self, param: Id) -> Option<(&'d Parameter, Str)> { + let index = self.index; + let v30 = self.v30; + let mut description = Str::default(); + let param = self.follow_visiting(param, &index.parameters, "parameters", |reference| { + if !v30 && description.is_empty() { + description = reference.description; + } + })?; + if param.name.is_empty() || param.location.is_none() { + self.error("a parameter has no name or no in"); + return None; + } + if description.is_empty() { + description = param.description; + } + Some((param, description)) + } + + /// Reports whether `param` is covered: it has a `schema` rather than + /// `content`, and its style is the default, `style`. Adds an error if + /// not. + fn check_param(&mut self, param: &Parameter, style: ParameterStyle) -> bool { + let doc = self.doc; + let location = match param.location { + Some(location) => location.as_str(), + None => "", + }; + if !param.content.is_empty() { + self.error(format_args!( + "{location} parameter {:?} has content, which is not covered: only schema is", + doc.str(param.name) + )); + return false; + } + if let Some(other) = param.style + && other != style + { + self.error(format_args!( + "{location} parameter {:?} has style {:?}, which is not covered: only {} is", + doc.str(param.name), + other.as_str(), + style.as_str() + )); + return false; + } + true + } + + /// Returns the field of `param`, whose own description, as + /// [`param`](Builder::param) gives it, is `description`. Without one, + /// the field has the description of the parameter's schema. Whether + /// the field is required and deprecated is `required` and + /// `deprecated`, since that depends on where the parameter is sent. + /// The field is sensitive when the parameter's schema is, and never + /// read-only, since a client sends every parameter. Its default is + /// the schema's, since a Parameter Object has none of its own. + fn param_field(&mut self, param: &'d Parameter, description: Str, required: bool, deprecated: bool) -> FieldData { + let doc = self.doc; + let ty = self.ty(param.schema); + let mut description = doc.str(description); + if description.is_empty() { + description = self.schema_description(param.schema); + } + let wire_name = doc.str(param.name); + FieldData { + wire_name: self.strings.push(wire_name), + name: self.field_name(param.field_name, wire_name), + required, + ty, + description: self.strings.push(description), + deprecated, + read_only: false, + sensitive: self.schema_flags(param.schema).sensitive, + default: self.schema_default(param.schema), + } + } + + /// Returns the name of the field of a property or parameter written as + /// `wire_name`, whose name extension, such as `x-fern-property-name`, + /// is `extension`. The extension gives the name if it has letters or + /// digits. + fn field_name(&mut self, extension: Str, wire_name: &'d str) -> NameId { + if !extension.is_empty() { + let name = self.name(self.doc.str(extension)); + if Some(name) != self.empty_name { + return name; + } + } + self.name(wire_name) + } + + /// Returns the name written as `text`. + fn name(&mut self, text: &'d str) -> NameId { + if let Some(&id) = self.names_by_text.get(text) { + return id; + } + let name = Name::new(text); + let empty = name.is_empty(); + let next = NameId(u32::try_from(self.name_ids.len()).expect("fewer than 2^32 names")); + let id = *self.name_ids.entry(name).or_insert(next); + if empty { + self.empty_name = Some(id); + } + self.names_by_text.insert(text, id); + id + } + + /// Returns the object of the parameters `fields`, which must have + /// different names. + fn params_object(&mut self, fields: Vec) -> TypeId { + self.check_names(&fields, "parameters"); + self.object_of(fields, None) + } + + /// Returns the object of `fields` and `extra`. `fields` is emptied, and + /// kept to be used again. + fn object_of(&mut self, mut fields: Vec, extra: Option) -> TypeId { + let span = self.fields.extend(fields.drain(..)); + self.spare_fields.push(fields); + self.push(TypeData::Object(ObjectData { fields: span, extra })) + } + + /// Returns the intersection of `members`. + fn intersection(&mut self, members: &[TypeId]) -> TypeId { + let members = self.members.extend(members.iter().copied()); + self.push(TypeData::Intersection(members)) + } + + /// Returns the union of `members`, which must be different, with + /// `discriminator`, which is `exclusive` if a value is at most one of + /// them. + fn push_union( + &mut self, + members: &[TypeId], + discriminator: Option>, + exclusive: bool, + ) -> TypeId { + let members = self.members.extend(members.iter().copied()); + self.push(TypeData::Union(UnionData { + members, + exclusive, + discriminator, + })) + } + + /// Reports whether `ty` is an object with fields, or a model, union or + /// intersection of them, so that it can be intersected with the + /// parameters. + fn has_fields(&self, ty: TypeId, depth: u32) -> bool { + if depth > 32 { + return false; + } + match &self.types[ty.index()] { + TypeData::Object(object) => !object.fields.is_empty(), + TypeData::Model(id) => self.has_fields(self.models[id.index()].ty, depth + 1), + TypeData::Union(UnionData { members, .. }) | TypeData::Intersection(members) => { + for &member in self.members.get(*members) { + if !self.has_fields(member, depth + 1) { + return false; + } + } + true + } + _ => false, + } + } + + /// Reports whether `ty` has a field with the name of one of `params`. + fn shares_a_name(&self, ty: TypeId, params: &[FieldData], depth: u32) -> bool { + if depth > 32 { + return false; + } + match &self.types[ty.index()] { + TypeData::Object(object) => { + for field in self.fields.get(object.fields) { + for param in params { + if field.name == param.name { + return true; + } + } + } + false + } + TypeData::Model(id) => self.shares_a_name(self.models[id.index()].ty, params, depth + 1), + TypeData::Union(UnionData { members, .. }) | TypeData::Intersection(members) => { + for &member in self.members.get(*members) { + if self.shares_a_name(member, params, depth + 1) { + return true; + } + } + false + } + _ => false, + } + } + + /// Returns the data of the request body `body`. + fn body(&mut self, body: &'d RequestBody) -> BodyData { + let (contents, json) = self.contents(body.content); + BodyData { + required: body.required, + description: self.strings.push(self.doc.str(body.description)), + contents, + json, + } + } + + /// Returns the success responses and the other responses of + /// `operation`, each sorted by status. + fn responses(&mut self, operation: &'d Operation) -> (Vec, Vec) { + let doc = self.doc; + let index = self.index; + let mut responses = Vec::new(); + let mut errors = Vec::new(); + for &(status, response) in doc.list(operation.responses) { + let Some(response) = self.follow(response, &index.responses, "responses") else { + continue; + }; + let status = doc.str(status); + let (contents, json) = self.contents(response.content); + let data = ResponseData { + status: self.strings.push(status), + description: self.strings.push(doc.str(response.description)), + contents, + json, + }; + if status.starts_with('2') { + responses.push(data); + } else { + errors.push(data); + } + } + let strings = &self.strings; + responses.sort_by(|a, b| strings.get(a.status).cmp(strings.get(b.status))); + errors.sort_by(|a, b| strings.get(a.status).cmp(strings.get(b.status))); + (responses, errors) + } + + /// Returns the `content` of a body or response, sorted by media type, + /// each typed by its schema whatever the media type, and the index of + /// its JSON content: `application/json`, or else the first JSON media + /// type. + fn contents(&mut self, content: Span<(Str, Schema)>) -> (Vec, Option) { + let doc = self.doc; + let content = doc.list(content); + let mut entries: Vec<(&'d str, Schema)> = Vec::with_capacity(content.len()); + for &(media_type, schema) in content { + entries.push((doc.str(media_type), schema)); + } + entries.sort_by(|a, b| a.0.cmp(b.0)); + let mut contents = Vec::with_capacity(entries.len()); + let mut json = None; + let mut json_is_exact = false; + for (media_type, schema) in entries { + let i = contents.len() as u32; + let ty = self.ty(schema); + let description = self.schema_description(schema); + if is_json(media_type) && !json_is_exact { + let exact = media_type.split(';').next().unwrap_or_default().trim(); + json_is_exact = exact.eq_ignore_ascii_case("application/json"); + if json.is_none() || json_is_exact { + json = Some(i); + } + } + contents.push(ContentData { + media_type: self.strings.push(media_type), + ty, + description: self.strings.push(description), + }); + } + (contents, json) + } + + /// Adds an error if two of `fields` have equal names. `what` is what + /// the fields are, such as `parameters`. + fn check_names(&mut self, fields: &[FieldData], what: &str) { + // Each field is reported with the last one before it with an equal + // name. Most objects have few fields, which are compared directly. + if fields.len() <= 16 { + for (i, field) in fields.iter().enumerate() { + if let Some(other) = fields[..i].iter().rev().find(|other| other.name == field.name) { + self.equal_names(what, other.wire_name, field.wire_name); + } + } + return; + } + let mut seen: HashMap = HashMap::with_capacity(fields.len()); + for field in fields { + if let Some(other) = seen.insert(field.name, field.wire_name) { + self.equal_names(what, other, field.wire_name); + } + } + } + + /// Adds an error for two `what`, such as fields, with the wire names + /// `a` and `b` and equal names. + fn equal_names(&mut self, what: &str, a: Str, b: Str) { + let message = format!( + "{what} {:?} and {:?} have equal names", + self.strings.get(a), + self.strings.get(b) + ); + self.error(message); + } + + /// Returns the object `id`, or the object in `components.{kind}`, which + /// is `components`, that its `$ref` points to, following `$ref`s until + /// an object has none. Adds an error and returns `None` if one points + /// to anything else. + fn follow(&mut self, id: Id, components: &HashMap<&'d str, Id>, kind: &str) -> Option<&'d T> { + self.follow_visiting(id, components, kind, |_| {}) + } + + /// Returns what [`follow`](Builder::follow) does, calling `visit` with + /// each object along the way that has a `$ref`, from `id` on, so that + /// the keywords next to it can be read. + fn follow_visiting( + &mut self, + id: Id, + components: &HashMap<&'d str, Id>, + kind: &str, + mut visit: impl FnMut(&'d T), + ) -> Option<&'d T> { + let doc = self.doc; + let mut value = doc.get(id); + for _ in 0..32 { + let reference = value.reference(); + if reference.is_empty() { + return Some(value); + } + visit(value); + let reference = doc.str(reference); + let mut segments = match pointer(reference) { + Ok(segments) => segments, + Err(err) => { + self.error(err); + return None; + } + }; + let (Some(first), Some(second), Some(name), None) = + (segments.next(), segments.next(), segments.next(), segments.next()) + else { + self.error(format_args!( + "$ref {reference:?} is not covered: only $refs to components.{kind} are" + )); + return None; + }; + if first != "components" || second != kind { + self.error(format_args!( + "$ref {reference:?} is not covered: only $refs to components.{kind} are" + )); + return None; + } + let Some(&target) = components.get(name.as_ref()) else { + self.error(format_args!("$ref {reference:?} points to nothing")); + return None; + }; + value = doc.get(target); + } + self.error(format_args!("a $ref in components.{kind} leads back to itself")); + None + } + + /// Adds an error, starting with what is being read. + fn error(&mut self, message: impl fmt::Display) { + let message = match self.context { + Context::None => message.to_string(), + Context::Path(path) => format!("path {path}: {message}"), + Context::Operation(method, path) => format!("{method} {path}: {message}"), + Context::Alias(method, path, i) => format!("{method} {path}: x-forge-aliases[{i}]: {message}"), + Context::Schema(id) => { + let schema_name = self.strings.get(self.models[id.index()].schema_name); + format!("schema {schema_name:?}: {message}") + } + }; + if !self.errors.contains(&message) { + self.errors.push(message); + } + } +} + +/// Returns the method of the fixed field of a Path Item Object that is +/// `method` as a request sends it, such as `GET`, or `None` if there is +/// none. +fn fixed_method(method: &str) -> Option { + match method { + "GET" => Some(HttpMethod::Get), + "PUT" => Some(HttpMethod::Put), + "POST" => Some(HttpMethod::Post), + "DELETE" => Some(HttpMethod::Delete), + "OPTIONS" => Some(HttpMethod::Options), + "HEAD" => Some(HttpMethod::Head), + "PATCH" => Some(HttpMethod::Patch), + "TRACE" => Some(HttpMethod::Trace), + "QUERY" => Some(HttpMethod::Query), + "CONNECT" => Some(HttpMethod::Connect), + _ => None, + } +} + +/// Returns the availability `value` is as `x-fern-availability` writes +/// it, such as `beta`, or `None` if it is none of them. Case matters. +fn parse_availability(value: &str) -> Option { + match value { + "alpha" => Some(Availability::Alpha), + "beta" => Some(Availability::Beta), + "preview" => Some(Availability::Preview), + "generally-available" => Some(Availability::GenerallyAvailable), + "deprecated" => Some(Availability::Deprecated), + _ => None, + } +} + +/// Reports whether `a` and `b` are the same parameter, so that one replaces +/// the other: they have the same location and name. Header names are +/// compared ignoring ASCII case, since HTTP field names are +/// case-insensitive: . +fn same_param(doc: &Doc, a: &Parameter, b: &Parameter) -> bool { + if a.location != b.location { + return false; + } + let (a_name, b_name) = (doc.str(a.name), doc.str(b.name)); + if a.location == Some(ParameterLocation::Header) { + a_name.eq_ignore_ascii_case(b_name) + } else { + a_name == b_name + } +} + +/// Returns the names of the placeholders in the path template `path`, such +/// as `id` in `/a/{id}`, in order. +fn placeholders(path: &str) -> Vec<&str> { + let mut names = Vec::new(); + let mut rest = path; + while let Some(open) = rest.find('{') { + let Some(len) = rest[open..].find('}') else { + break; + }; + names.push(&rest[open + 1..open + len]); + rest = &rest[open + len + 1..]; + } + names +} + +/// Building types from schemas. +impl<'d> Builder<'d> { + /// Returns the type of `schema`. + fn ty(&mut self, schema: Schema) -> TypeId { + let Schema::Object(schema) = schema else { + // `true` allows any value. `false` allows none, which no type + // says, and is left out where it matters, as in a property. + return self.scalar(Scalar::Unknown); + }; + let doc = self.doc; + let schema = doc.get(schema); + if schema.reference.is_empty() { + self.object_ty(schema, schema.nullable) + } else { + self.ref_with_siblings(schema, doc.str(schema.reference)) + } + } + + /// Returns the type of a schema with a `$ref`. In OpenAPI 3.0 the + /// keywords next to it are ignored. From 3.1, `nullable` and the + /// keywords that give a shape are kept. + fn ref_with_siblings(&mut self, schema: &'d SchemaObject, reference: &str) -> TypeId { + let ty = self.ref_ty(reference); + if self.v30 { + return ty; + } + let ty = if is_shapeless(self.doc, schema) { + ty + } else { + // The rest of the schema, as if it had no `$ref` and was not + // nullable. + let rest = self.object_ty(schema, false); + self.intersection(&[ty, rest]) + }; + self.nullable(ty, schema.nullable) + } + + /// Returns the type of `schema`, leaving out its `$ref`, made nullable + /// by `nullable`. + fn object_ty(&mut self, schema: &'d SchemaObject, mut nullable: bool) -> TypeId { + let doc = self.doc; + let null_type = doc.list(schema.types).contains(&SchemaType::Null); + if null_type { + nullable = true; + } + let values = doc.list(schema.values); + if !values.is_empty() { + return self.enum_ty(values, nullable); + } + if let Some(value) = &schema.constant { + return self.enum_ty(slice::from_ref(value), nullable); + } + let ty = if is_composed(schema) { + self.composed(schema, null_type) + } else { + self.typed(schema, null_type) + }; + self.nullable(ty, nullable) + } + + /// Returns the type `reference` points to: a model for a schema in + /// `components.schemas` or in the `$defs` of one, or else the type of + /// what it points to, written in place. + fn ref_ty(&mut self, reference: &str) -> TypeId { + let (target, schema_name) = match self.resolve(reference) { + Ok(resolved) => resolved, + Err(err) => { + self.error(err); + return self.scalar(Scalar::Unknown); + } + }; + if let Some(schema_name) = schema_name { + return self.model(schema_name, target); + } + if self.inline_refs.iter().any(|r| r == reference) { + self.error(format_args!("$ref {reference:?} leads back to itself")); + return self.scalar(Scalar::Unknown); + } + self.inline_refs.push(reference.to_string()); + let ty = self.ty(target); + self.inline_refs.pop(); + ty + } + + /// Returns the type of the model with schema name `schema_name`, + /// whose schema is `schema`, adding the model if it is new. + fn model(&mut self, schema_name: Cow<'_, str>, schema: Schema) -> TypeId { + if let Some(&id) = self.model_index.get(schema_name.as_ref()) { + return self.model_types[id.index()]; + } + let schema_name = schema_name.into_owned(); + let doc = self.doc; + let id = ModelId(u32::try_from(self.models.len()).expect("fewer than 2^32 models")); + let mut name = None; + let mut description = ""; + let mut deprecated = false; + if let Schema::Object(object) = schema { + let object = doc.get(object); + name = name_of(doc, object.model_name); + description = doc.str(object.description); + if description.is_empty() { + description = doc.str(object.title); + } + deprecated = object.deprecated; + } + let name = match name { + Some(name) => name, + None => model_name(&schema_name), + }; + // The type is set once the schema is read, which can use the model + // itself. + let unknown = self.scalar(Scalar::Unknown); + self.models.push(ModelData { + schema_name: self.strings.push(&schema_name), + name, + group: None, + description: self.strings.push(description), + deprecated, + ty: unknown, + }); + let model_ty = self.push(TypeData::Model(id)); + self.model_types.push(model_ty); + let context = mem::replace(&mut self.context, Context::Schema(id)); + self.model_index.insert(schema_name, id); + // A `$ref` written in place outside the model does not lead back + // to itself through it, since the model is not written in place. + let inline_refs = mem::take(&mut self.inline_refs); + let ty = self.ty(schema); + self.inline_refs = inline_refs; + self.context = context; + self.models[id.index()].ty = ty; + model_ty + } + + /// Returns the type of `schema`, writing a model it refers to in + /// place, so that its fields can be changed. + fn ty_in_place(&mut self, schema: Schema) -> TypeId { + let Schema::Object(object) = schema else { + return self.ty(schema); + }; + let doc = self.doc; + let reference = doc.str(doc.get(object).reference); + if reference.is_empty() { + return self.ty(schema); + } + let Ok((target, Some(_))) = self.resolve(reference) else { + return self.ty(schema); + }; + if self.inline_refs.iter().any(|r| r == reference) { + return self.ty(schema); + } + self.inline_refs.push(reference.to_string()); + let ty = self.ty(target); + self.inline_refs.pop(); + ty + } + + /// Returns the type of an `enum` of `values`, made nullable by `null` + /// or a null value. + fn enum_ty(&mut self, values: &[Literal], mut null: bool) -> TypeId { + let doc = self.doc; + let mut out: Vec = Vec::with_capacity(values.len()); + for &value in values { + let value = match value { + Literal::Null => { + null = true; + continue; + } + Literal::String(s) => EnumValue::String(doc.str(s)), + Literal::Number(n) => EnumValue::Number(n), + Literal::Boolean(b) => EnumValue::Boolean(b), + Literal::Composite => return self.scalar(Scalar::Unknown), + }; + if out.iter().any(|v| v.get(&self.strings) == value) { + continue; + } + out.push(match value { + EnumValue::String(s) => EnumData::String(self.strings.push(s)), + EnumValue::Number(n) => EnumData::Number(n), + EnumValue::Boolean(b) => EnumData::Boolean(b), + }); + } + if out.is_empty() { + return self.scalar(if null { Scalar::Null } else { Scalar::Unknown }); + } + let values = self.values.extend(out); + let ty = self.push(TypeData::Enum(values)); + self.nullable(ty, null) + } + + /// Returns the type of a schema without `allOf`, `anyOf` or `oneOf`, + /// from its `type`. `null_type` says whether `type` lists `null`. + fn typed(&mut self, schema: &'d SchemaObject, null_type: bool) -> TypeId { + if schema.prefix_items { + return self.scalar(Scalar::Unknown); + } + let all = self.doc.list(schema.types); + let mut types = all.iter().copied().filter(|&ty| ty != SchemaType::Null); + match (types.next(), types.next()) { + (None, _) if null_type => self.scalar(Scalar::Null), + (None, _) if !schema.properties.is_empty() || schema.additional_properties.is_some() => self.object(schema), + (None, _) if schema.items.is_some() => self.array(schema), + (None, _) => self.scalar(Scalar::Unknown), + (Some(ty), None) => self.single(schema, ty), + (Some(_), Some(_)) => { + let mut members = Vec::with_capacity(all.len()); + for &ty in all { + if ty != SchemaType::Null { + members.push(self.single(schema, ty)); + } + } + self.union_of(&members, None, true) + } + } + } + + /// Returns the type of `schema` as the one type `ty`. + fn single(&mut self, schema: &'d SchemaObject, ty: SchemaType) -> TypeId { + let scalar = match ty { + SchemaType::String => match schema.format { + Format::DateTime => Scalar::DateTime, + Format::Date => Scalar::Date, + Format::Byte => Scalar::Bytes, + Format::Binary => Scalar::Binary, + _ if schema.content_encoding == ContentEncoding::Base64 => Scalar::Bytes, + _ => Scalar::String, + }, + SchemaType::Integer | SchemaType::Number => Scalar::Number(match schema.format { + Format::Int8 => Number::Int8, + Format::Int16 => Number::Int16, + Format::Int32 => Number::Int32, + Format::Int64 => Number::Int64, + Format::Uint8 => Number::Uint8, + Format::Uint16 => Number::Uint16, + Format::Uint32 => Number::Uint32, + Format::Uint64 => Number::Uint64, + Format::Float => Number::Float, + Format::Double => Number::Double, + _ if ty == SchemaType::Integer => Number::Int, + _ => Number::Double, + }), + SchemaType::Boolean => Scalar::Boolean, + SchemaType::Null => Scalar::Null, + SchemaType::Array => return self.array(schema), + SchemaType::Object => return self.object(schema), + }; + self.scalar(scalar) + } + + fn array(&mut self, schema: &'d SchemaObject) -> TypeId { + let elem = match schema.items { + Some(items) => self.ty(items), + None => self.scalar(Scalar::Unknown), + }; + self.push(TypeData::Array(ArrayData { + elem, + max_items: schema.max_items, + })) + } + + fn object(&mut self, schema: &'d SchemaObject) -> TypeId { + let fields = self.fields(schema); + let extra = match schema.additional_properties { + None if schema.properties.is_empty() => Some(self.scalar(Scalar::Unknown)), + None | Some(Schema::Bool(false)) => None, + Some(Schema::Bool(true)) => Some(self.scalar(Scalar::Unknown)), + Some(extra) => Some(self.ty(extra)), + }; + self.object_of(fields, extra) + } + + /// Returns a field for each property of `schema`. + fn fields(&mut self, schema: &'d SchemaObject) -> Vec { + let doc = self.doc; + let properties = doc.list(schema.properties); + let mut fields = self.spare_fields.pop().unwrap_or_default(); + fields.reserve(properties.len()); + for &(wire_name, property) in properties { + let wire_name = doc.str(wire_name); + let mut extension = Str::default(); + let mut deprecated = false; + match property { + Schema::Bool(false) => continue, + Schema::Bool(true) => {} + Schema::Object(object) => { + let object = doc.get(object); + if object.skip { + continue; + } + extension = object.field_name; + deprecated = object.deprecated; + } + } + let description = self.schema_description(property); + let flags = self.schema_flags(property); + let name = self.field_name(extension, wire_name); + let ty = self.ty(property); + fields.push(FieldData { + wire_name: self.strings.push(wire_name), + name, + required: false, + ty, + description: self.strings.push(description), + deprecated, + read_only: flags.read_only, + sensitive: flags.sensitive, + default: self.schema_default(property), + }); + } + for &name in doc.list(schema.required) { + let name = doc.str(name); + for field in &mut fields { + if self.strings.get(field.wire_name) == name { + field.required = true; + } + } + } + self.check_names(&fields, "fields"); + fields + } + + /// Returns the type of a schema with `allOf`, `anyOf` or `oneOf`: the + /// intersection of its own object, its `allOf` members, its + /// `unevaluatedProperties`, and a union for each of `anyOf` and + /// `oneOf`. With none of them, its `type` gives the type. + fn composed(&mut self, schema: &'d SchemaObject, null_type: bool) -> TypeId { + let doc = self.doc; + let mut parts = Vec::new(); + if !schema.properties.is_empty() || schema.additional_properties.is_some() { + parts.push(self.object(schema)); + } + self.all_of(doc.list(schema.all_of), &mut parts); + if let extra @ Schema::Object(_) = schema.unevaluated_properties { + let extra = self.ty(extra); + let object = ObjectData { + fields: Span::default(), + extra: Some(extra), + }; + parts.push(self.push(TypeData::Object(object))); + } + for (members, exclusive) in [(schema.any_of, false), (schema.one_of, true)] { + let members = doc.list(members); + if !members.is_empty() + && let Some(union) = self.union(schema, members, exclusive) + { + parts.push(union); + } + } + match parts[..] { + [] => self.typed(schema, null_type), + [part] => part, + _ => self.intersection(&parts), + } + } + + /// Adds the types of the `allOf` `members` to `parts`, the members of + /// one that is an intersection, and makes the fields that a member + /// with only `required` lists required. + fn all_of(&mut self, members: &'d [Schema], parts: &mut Vec) { + let doc = self.doc; + let mut required: Vec<&str> = Vec::new(); + for &member in members { + if let Schema::Object(object) = member + && is_required_only(doc, member) + { + for &name in doc.list(doc.get(object).required) { + required.push(doc.str(name)); + } + } + } + for &member in members { + if is_required_only(doc, member) || is_shapeless_member(doc, member) { + continue; + } + // A model's fields cannot be made required without changing + // the model, so it is written in place. + let ty = if required.is_empty() { + self.ty(member) + } else { + self.ty_in_place(member) + }; + if let TypeData::Intersection(inner) = self.types[ty.index()] { + parts.extend_from_slice(self.members.get(inner)); + } else { + parts.push(ty); + } + } + if required.is_empty() { + return; + } + for &part in parts.iter() { + if let TypeData::Object(object) = self.types[part.index()] { + for field in self.fields.get_mut(object.fields) { + if required.contains(&self.strings.get(field.wire_name)) { + field.required = true; + } + } + } + } + } + + /// Returns the union of the `anyOf` or `oneOf` `members` of `schema`, + /// which is `exclusive` for `oneOf`, or `None` if every member only + /// constrains the value. + fn union(&mut self, schema: &'d SchemaObject, members: &'d [Schema], exclusive: bool) -> Option { + let doc = self.doc; + let mut kept: Vec<(Schema, TypeId)> = Vec::with_capacity(members.len()); + for &member in members { + if !is_constraint_only(doc, member) { + let ty = self.ty(member); + kept.push((member, ty)); + } + } + if kept.is_empty() { + return None; + } + let discriminator = schema + .discriminator + .map(|discriminator| self.discriminator(doc.get(discriminator), &kept)); + let mut types = Vec::with_capacity(kept.len()); + for &(_, ty) in &kept { + types.push(ty); + } + Some(self.union_of(&types, discriminator, exclusive)) + } + + /// Returns the union of `members`, which is `exclusive` if a value is + /// at most one of them. A member that is a union without a + /// discriminator adds its own members, and the union is exclusive only + /// if that one is too; enums merge into one, and members that are the + /// same type are one member. A single member is the type itself. Null + /// is a member of a union around a discriminated one, so that the + /// discriminated union has only its cases. + fn union_of( + &mut self, + members: &[TypeId], + discriminator: Option>, + mut exclusive: bool, + ) -> TypeId { + let mut out = Vec::with_capacity(members.len()); + let mut values = Vec::new(); + let mut null = false; + for &member in members { + if let TypeData::Union(inner) = &self.types[member.index()] + && inner.discriminator.is_none() + { + exclusive &= inner.exclusive; + for &inner_member in self.members.get(inner.members) { + self.add_member(inner_member, &mut out, &mut values, &mut null); + } + } else { + self.add_member(member, &mut out, &mut values, &mut null); + } + } + let unknown = self.scalar(Scalar::Unknown); + if out.contains(&unknown) { + // Any value, null included. + return unknown; + } + if !values.is_empty() { + let values = self.values.extend(values); + out.push(self.push(TypeData::Enum(values))); + } + let null_ty = self.scalar(Scalar::Null); + match out[..] { + [] if null => return null_ty, + [] => return unknown, + [one] if !null => return one, + // Null is not a value of the other member. + [one] => return self.push_union(&[one, null_ty], None, true), + _ => {} + } + if discriminator.is_some() && null { + let inner = self.push_union(&out, discriminator, exclusive); + return self.push_union(&[inner, null_ty], None, true); + } + if null { + out.push(null_ty); + } + self.push_union(&out, discriminator, exclusive) + } + + /// Adds `member` to the union being built: to `out`, unless it is + /// already there, to `values` if it is an enum, or to `null`. + fn add_member(&self, member: TypeId, out: &mut Vec, values: &mut Vec, null: &mut bool) { + match &self.types[member.index()] { + TypeData::Scalar(Scalar::Null) => *null = true, + TypeData::Enum(enum_values) => { + for &value in self.values.get(*enum_values) { + let new = value.get(&self.strings); + if !values.iter().any(|v| v.get(&self.strings) == new) { + values.push(value); + } + } + } + _ => { + if !out.contains(&member) { + out.push(member); + } + } + } + } + + /// Returns the discriminator of a union of `members`, each with its + /// schema. + fn discriminator( + &mut self, + discriminator: &'d Discriminator, + members: &[(Schema, TypeId)], + ) -> Box { + let doc = self.doc; + let wire_name = doc.str(discriminator.property_name); + let mut name = Name::new(wire_name); + for &(schema, _) in members { + if let Some(Schema::Object(property)) = self.find_property(schema, wire_name, 0) + && let Some(field_name) = name_of(doc, doc.get(property).field_name) + { + name = field_name; + break; + } + } + let mut cases: Vec<(Str, TypeId)> = Vec::new(); + for &(value, target) in doc.list(discriminator.mapping) { + let ty = self.mapping_ty(doc.str(target)); + cases.push((self.strings.push(doc.str(value)), ty)); + } + for &(schema, ty) in members { + let mut mapped = false; + for case in &cases { + if case.1 == ty { + mapped = true; + } + } + if mapped { + continue; + } + let mut value = None; + if let Some(property) = self.find_property(schema, wire_name, 0) + && let Some(only) = only_value(doc, property) + { + value = Some(self.strings.push(only)); + } + if value.is_none() + && let TypeData::Model(id) = &self.types[ty.index()] + { + value = Some(self.models[id.index()].schema_name); + } + if let Some(value) = value { + cases.push((value, ty)); + } + } + let mut default = None; + if !discriminator.default_mapping.is_empty() { + let ty = self.mapping_ty(doc.str(discriminator.default_mapping)); + if let TypeData::Model(id) = self.types[ty.index()] { + default = Some(id); + } + } + let wire_name = self.strings.push(wire_name); + Box::new(DiscriminatorData { + wire_name, + name, + cases, + default, + }) + } + + /// Returns the type a discriminator's mapping names: a `$ref`, or the + /// name of a schema in `components.schemas`. + fn mapping_ty(&mut self, target: &str) -> TypeId { + if target.contains('#') || target.contains('/') { + self.ref_ty(target) + } else { + self.ref_ty(&format!("#/components/schemas/{target}")) + } + } + + /// Returns the schema of the property `name` of `schema`, looking in + /// its `allOf` members too, or `None` if it has none. + fn find_property(&self, schema: Schema, name: &str, depth: u32) -> Option { + if depth > 8 { + return None; + } + let doc = self.doc; + let Schema::Object(object) = self.deref(schema)? else { + return None; + }; + let object = doc.get(object); + for &(wire_name, property) in doc.list(object.properties) { + if doc.str(wire_name) == name { + return self.deref(property); + } + } + for &member in doc.list(object.all_of) { + if let Some(property) = self.find_property(member, name, depth + 1) { + return Some(property); + } + } + None + } + + /// Returns the description of `schema`, as a field gives it: its own + /// `description`, or else that of the schema its `$ref` points to, + /// following `$ref`s, and from OpenAPI 3.1, that of its first `allOf` + /// member with one. Never the `title`, which names a schema rather + /// than describing a use of it. + /// + /// In 3.0, a `$ref` stands for the schema it points to, every keyword + /// next to it is ignored, and the JSON Schema of 3.0 gives an `allOf` + /// member's `description` no meaning for the schema around it. From + /// 3.1, a `description` is an annotation, which applies to a value + /// from the schema and from every schema applied to it in place: the + /// one a `$ref` points to, and each `allOf` member. So a description + /// next to a `$ref` is the schema's own, and with none, the first + /// description of the `$ref`'s schema and then of the `allOf` members, + /// in order, each read the same way, is the schema's. The members of + /// `anyOf` and `oneOf` apply only to the values that match them, so + /// none of their descriptions is. + /// + /// Returns an empty string if there is none, if a `$ref` does not + /// point to a schema, which reading the schema's type reports, or if + /// `$ref`s lead back to themselves. + fn schema_description(&self, schema: Schema) -> &'d str { + // Each schema read, which bounds schemas that lead back to + // themselves through `allOf` as well as `$ref`. + let mut budget = 64; + self.description_in(schema, &mut budget) + } + + fn description_in(&self, schema: Schema, budget: &mut u32) -> &'d str { + if *budget == 0 { + return ""; + } + *budget -= 1; + let doc = self.doc; + let Schema::Object(object) = schema else { + return ""; + }; + let object = doc.get(object); + let has_ref = !object.reference.is_empty(); + if !object.description.is_empty() && !(has_ref && self.v30) { + return doc.str(object.description); + } + if has_ref { + if let Ok((target, _)) = self.resolve(doc.str(object.reference)) { + let description = self.description_in(target, budget); + if !description.is_empty() || self.v30 { + return description; + } + } else { + return ""; + } + } + if self.v30 { + return ""; + } + for &member in doc.list(object.all_of) { + let description = self.description_in(member, budget); + if !description.is_empty() { + return description; + } + } + "" + } + + /// Returns the default of `schema`, as a field gives it: its own + /// `default`, or else that of the schema its `$ref` points to, + /// following `$ref`s, in `Doc::defaults`. + /// + /// From OpenAPI 3.1, a `default` next to a `$ref` is the schema's own; + /// in 3.0 it is ignored, as every keyword next to a `$ref` is. Returns + /// `None` if there is none, if a `$ref` does not point to a schema, + /// which reading the schema's type reports, or if `$ref`s lead back to + /// themselves. + fn schema_default(&self, mut schema: Schema) -> Option> { + let doc = self.doc; + for _ in 0..32 { + let Schema::Object(object) = schema else { + return None; + }; + let object = doc.get(object); + let has_ref = !object.reference.is_empty(); + if object.default.is_some() && !(has_ref && self.v30) { + return object.default; + } + if !has_ref { + return None; + } + schema = self.resolve(doc.str(object.reference)).ok()?.0; + } + // `$ref`s that lead back to themselves, with no default on the + // way. + None + } + + /// Returns whether `schema` is read-only and sensitive, as a field + /// gives them: whether it or any schema along its chain of `$ref`s has + /// `readOnly: true`, and `x-sensitive: true`. + /// + /// A `readOnly` next to a `$ref` counts from OpenAPI 3.1, and is + /// ignored in 3.0, as every keyword next to a `$ref` is. An + /// `x-sensitive` next to a `$ref` counts in every version, since tools + /// commonly honour extensions there. The chain stops at a `$ref` that + /// does not point to a schema, which reading the schema's type + /// reports, and after 32 `$ref`s, which lead back to themselves. + fn schema_flags(&self, mut schema: Schema) -> SchemaFlags { + let doc = self.doc; + let mut flags = SchemaFlags::default(); + for _ in 0..32 { + let Schema::Object(object) = schema else { + break; + }; + let object = doc.get(object); + let has_ref = !object.reference.is_empty(); + flags.read_only |= object.read_only && !(has_ref && self.v30); + flags.sensitive |= object.sensitive; + if !has_ref || (flags.read_only && flags.sensitive) { + break; + } + let Ok((target, _)) = self.resolve(doc.str(object.reference)) else { + break; + }; + schema = target; + } + flags + } + + /// Returns `schema`, or the schema its `$ref` points to, following + /// `$ref`s, or `None` if one does not point to a schema. + fn deref(&self, mut schema: Schema) -> Option { + let doc = self.doc; + for _ in 0..32 { + let Schema::Object(object) = schema else { + return Some(schema); + }; + let reference = doc.get(object).reference; + if reference.is_empty() { + return Some(schema); + } + schema = self.resolve(doc.str(reference)).ok()?.0; + } + None + } + + /// Returns the schema `reference` points to, and its schema name if it + /// is a model: a schema in `components.schemas`, or in the `$defs` of + /// one. Only `$ref`s into `components.schemas` are covered. + fn resolve<'r>(&self, reference: &'r str) -> Result<(Schema, Option>), String> { + let doc = self.doc; + let mut segments = pointer(reference)?; + let not_covered = || format!("$ref {reference:?} is not covered: only $refs into components.schemas are"); + let (Some(components), Some(schemas), Some(name)) = (segments.next(), segments.next(), segments.next()) else { + return Err(not_covered()); + }; + if components != "components" || schemas != "schemas" { + return Err(not_covered()); + } + let points_to_nothing = || format!("$ref {reference:?} points to nothing"); + let Some(&(mut schema)) = self.index.schemas.get(name.as_ref()) else { + return Err(points_to_nothing()); + }; + // The schema name, while every segment so far is in `$defs`. + let mut schema_name = Some(name); + let mut rest = segments.peekable(); + while let Some(key) = rest.next() { + let Schema::Object(object) = schema else { + return Err(points_to_nothing()); + }; + let object = doc.get(object); + let next = rest.peek().map(|next| next.as_ref()); + // The child, and whether it is named by the next segment. + let (child, named) = match (key.as_ref(), next) { + ("properties", Some(next)) => (entry(doc, object.properties, next), true), + ("$defs", Some(next)) => (entry(doc, object.defs, next), true), + ("allOf", Some(next)) => (item(doc.list(object.all_of), next), true), + ("anyOf", Some(next)) => (item(doc.list(object.any_of), next), true), + ("oneOf", Some(next)) => (item(doc.list(object.one_of), next), true), + ("items", _) => (object.items, false), + ("additionalProperties", _) => (object.additional_properties, false), + ("unevaluatedProperties", _) => (Some(object.unevaluated_properties), false), + _ => (None, false), + }; + let Some(child) = child else { + return Err(points_to_nothing()); + }; + let next = if named { rest.next() } else { None }; + if let Some(path) = &mut schema_name { + if key == "$defs" + && let Some(next) = &next + { + let path = path.to_mut(); + path.push_str("/$defs/"); + path.push_str(next); + } else { + schema_name = None; + } + } + schema = child; + } + Ok((schema, schema_name)) + } + + /// Returns `ty`, or the union of it and null if `nullable`. + fn nullable(&mut self, ty: TypeId, nullable: bool) -> TypeId { + let data = &self.types[ty.index()]; + if !nullable || matches!(data, TypeData::Scalar(Scalar::Unknown | Scalar::Null)) { + return ty; + } + let null = self.scalar(Scalar::Null); + self.union_of(&[ty, null], None, true) + } + + /// Returns the type of `scalar`, which every use shares. + fn scalar(&mut self, scalar: Scalar) -> TypeId { + for &(s, id) in &self.scalars { + if s == scalar { + return id; + } + } + let id = self.push(TypeData::Scalar(scalar)); + self.scalars.push((scalar, id)); + id + } + + fn push(&mut self, data: TypeData) -> TypeId { + let id = TypeId(u32::try_from(self.types.len()).expect("fewer than 2^32 types")); + self.types.push(data); + id + } +} + +/// What [`Builder::schema_flags`] reads of a schema and its `$ref`s. +#[derive(Clone, Copy, Default)] +struct SchemaFlags { + read_only: bool, + sensitive: bool, +} + +/// Returns the schema of `key` in `entries`, such as `properties`. +fn entry(doc: &Doc, entries: Span<(Str, Schema)>, key: &str) -> Option { + for &(k, schema) in doc.list(entries) { + if doc.str(k) == key { + return Some(schema); + } + } + None +} + +/// Returns the schema at `index` in `list`, such as `allOf`. +fn item(list: &[Schema], index: &str) -> Option { + list.get(index.parse::().ok()?).copied() +} + +/// Returns the name an extension such as `x-fern-type-name` gives, or +/// `None` if there is none, or it has no letters or digits. +fn name_of(doc: &Doc, extension: Str) -> Option { + if extension.is_empty() { + return None; + } + let name = Name::new(doc.str(extension)); + if name.is_empty() { None } else { Some(name) } +} + +/// Returns the segments of `reference`, a JSON pointer into this document +/// such as `#/components/schemas/Pet`, decoded, or an error. +fn pointer(reference: &str) -> Result>, String> { + let Some(fragment) = reference.strip_prefix('#') else { + return Err(format!( + "$ref {reference:?} is to another document, which is not covered" + )); + }; + let Some(pointer) = fragment.strip_prefix('/') else { + return Err(format!("$ref {reference:?} is not a JSON pointer")); + }; + Ok(pointer.split('/').map(decode)) +} + +/// Decodes a segment of a JSON pointer in a URI fragment: `%XX` escapes, +/// then `~1` for `/` and `~0` for `~`. +fn decode(segment: &str) -> Cow<'_, str> { + if !segment.contains(['%', '~']) { + return Cow::Borrowed(segment); + } + let bytes = segment.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 3 <= bytes.len() + && let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) + && let Ok(byte) = u8::from_str_radix(hex, 16) + { + out.push(byte); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + let decoded = String::from_utf8(out).unwrap_or_else(|_| segment.to_string()); + Cow::Owned(decoded.replace("~1", "/").replace("~0", "~")) +} + +/// Returns the name of a model without `x-fern-type-name`: its schema +/// name without `$defs`. +fn model_name(schema_name: &str) -> Name { + let mut words = String::with_capacity(schema_name.len()); + for segment in schema_name.split('/') { + if segment == "$defs" { + continue; + } + if !words.is_empty() { + words.push(' '); + } + words.push_str(segment); + } + Name::new(&words) +} + +/// Returns the one value an `enum` or `const` allows a property, if it is +/// a string. +fn only_value(doc: &Doc, property: Schema) -> Option<&str> { + let Schema::Object(object) = property else { + return None; + }; + let object = doc.get(object); + if let Some(Literal::String(value)) = object.constant { + return Some(doc.str(value)); + } + match doc.list(object.values) { + [Literal::String(value)] => Some(doc.str(*value)), + _ => None, + } +} + +/// Reports whether `schema`, leaving out its `$ref`, gives a shape rather +/// than only constraining or describing a value. +fn has_shape(schema: &SchemaObject) -> bool { + !schema.properties.is_empty() + || schema.additional_properties.is_some() + || schema.items.is_some() + || schema.prefix_items + || !schema.values.is_empty() + || schema.constant.is_some() + || is_composed(schema) +} + +/// Reports whether `schema` has members in `allOf`, `anyOf` or `oneOf`. +fn is_composed(schema: &SchemaObject) -> bool { + !schema.all_of.is_empty() || !schema.any_of.is_empty() || !schema.one_of.is_empty() +} + +/// Reports whether `schema`, leaving out its `$ref`, gives no shape: no +/// shape keyword, and no type or only `object`. +fn is_shapeless(doc: &Doc, schema: &SchemaObject) -> bool { + !has_shape(schema) && matches!(doc.list(schema.types), [] | [SchemaType::Object]) +} + +/// Reports whether an `allOf` member gives no shape, such as `{}`, +/// `true`, `{type: object}` or one with only a `description`. +fn is_shapeless_member(doc: &Doc, member: Schema) -> bool { + match member { + Schema::Bool(_) => true, + Schema::Object(object) => { + let object = doc.get(object); + object.reference.is_empty() && is_shapeless(doc, object) + } + } +} + +/// Reports whether an `allOf` member only lists `required` properties. +fn is_required_only(doc: &Doc, member: Schema) -> bool { + match member { + Schema::Bool(_) => false, + Schema::Object(object) => !doc.get(object).required.is_empty() && is_shapeless_member(doc, member), + } +} + +/// Reports whether a union member only constrains the value, with +/// `required` or `not`, rather than giving it a shape. +fn is_constraint_only(doc: &Doc, member: Schema) -> bool { + let Schema::Object(object) = member else { + return false; + }; + let object = doc.get(object); + object.reference.is_empty() && !has_shape(object) && (!object.required.is_empty() || object.not) +} diff --git a/src/link/openapi.rs b/src/link/openapi.rs new file mode 100644 index 000000000..f79c7d837 --- /dev/null +++ b/src/link/openapi.rs @@ -0,0 +1,1869 @@ +//! The objects of an OpenAPI document that a `Forge` is built from, as +//! the [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) +//! names them, with only the fields a `Forge` reads. Every other field is +//! skipped as it is read. +//! +//! The objects are not nested in each other. A [`Doc`] keeps each kind of +//! object in an arena, and an object refers to those inside it by [`Id`]. +//! Its lists are [`Span`]s of one [`List`] for each kind of item, and its +//! strings are [`Str`]s of one buffer. So reading a document allocates +//! only as the arenas grow, rather than for every object, list and string +//! in it. Extensions that name something are kept as they are written, +//! and read as [`Name`]s when they are used. +//! +//! The extensions of an Operation Object, and of each alias in its +//! `x-forge-aliases`, that are not read are kept too, as [`Json`], for +//! plugins to read. They are the one part of a document kept without being +//! read. + +use std::collections::HashMap; +use std::fmt; +use std::marker::PhantomData; + +use serde::Deserialize; +use serde::de::{self, DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; + +use super::read::{first, keys}; +use crate::Name; +use crate::arena::{Id, List, Span, Str, Strings}; +use crate::json::{self, Json}; + +/// An OpenAPI document: the fields of the OpenAPI Object at its root, and +/// the arenas of the objects in it. +#[derive(Default)] +pub(super) struct Doc { + /// The version of the OpenAPI Specification the document uses, such + /// as `3.1.0`, or empty for none, as in a Swagger 2.0 document. + pub(super) openapi: String, + pub(super) info: Info, + /// The first server, or one with an empty URL for none. The others are + /// not read. + pub(super) servers: Server, + /// Each path item by path, in the order they are written, leaving out + /// extensions. + pub(super) paths: Span<(Str, Id)>, + pub(super) components: Components, + pub(super) commands: Commands, + /// The extensions of every operation, by name. They own their names, + /// rather than keeping them in `strings`, so that a `Forge` can take + /// them whole. + pub(super) extensions: List<(String, Json)>, + /// The `default` of every schema that has one, which a schema refers + /// to by [`Id`], so that a schema without one costs only the `None`. + /// A `Forge` takes them whole, as it does `extensions`. + pub(super) defaults: Vec, + + strings: Strings, + path_items: Vec, + operations: Vec, + parameters: Vec, + request_bodies: Vec, + responses: Vec, + schemas: Vec, + discriminators: Vec, + + path_item_entries: List<(Str, Id)>, + operation_entries: List<(Str, Id)>, + parameter_lists: List>, + parameter_entries: List<(Str, Id)>, + request_body_entries: List<(Str, Id)>, + response_entries: List<(Str, Id)>, + schema_lists: List, + /// Properties, `$defs`, `content` and `components.schemas`. + schema_entries: List<(Str, Schema)>, + types: List, + literals: List, + str_lists: List, + str_entries: List<(Str, Str)>, + /// The aliases in `x-forge-aliases`. + aliases: List, +} + +impl Doc { + /// Reads a document. + pub(super) fn read<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let mut doc = Doc::default(); + deserializer.deserialize_map(DocumentVisitor(&mut doc))?; + Ok(doc) + } + + pub(super) fn str(&self, s: Str) -> &str { + self.strings.get(s) + } + + pub(super) fn get(&self, id: Id) -> &T { + &T::arena(self)[id.index()] + } + + pub(super) fn list(&self, span: Span) -> &[T] { + T::list(self).get(span) + } + + fn add(&mut self, node: T) -> Id { + let arena = T::arena_mut(self); + arena.push(node); + Id::new(arena.len() - 1) + } +} + +/// An object kept in an arena of a [`Doc`]. +pub(super) trait Node: Sized { + fn arena(doc: &Doc) -> &Vec; + fn arena_mut(doc: &mut Doc) -> &mut Vec; +} + +/// An item of the lists in a [`Doc`]. +pub(super) trait Item: Sized { + fn list(doc: &Doc) -> &List; + fn list_mut(doc: &mut Doc) -> &mut List; +} + +macro_rules! arenas { + ($trait:ident, $get:ident, $get_mut:ident, $holder:ident { $($ty:ty => $field:ident,)* }) => { + $(impl $trait for $ty { + fn $get(doc: &Doc) -> &$holder { + &doc.$field + } + + fn $get_mut(doc: &mut Doc) -> &mut $holder { + &mut doc.$field + } + })* + }; +} + +arenas!(Node, arena, arena_mut, Vec { + PathItem => path_items, + Operation => operations, + Parameter => parameters, + RequestBody => request_bodies, + Response => responses, + SchemaObject => schemas, + Discriminator => discriminators, + Json => defaults, +}); + +arenas!(Item, list, list_mut, List { + (Str, Id) => path_item_entries, + (Str, Id) => operation_entries, + Id => parameter_lists, + (Str, Id) => parameter_entries, + (Str, Id) => request_body_entries, + (Str, Id) => response_entries, + Schema => schema_lists, + (Str, Schema) => schema_entries, + SchemaType => types, + Literal => literals, + Str => str_lists, + (Str, Str) => str_entries, + Definition => aliases, +}); + +/// The Info Object. +#[derive(Deserialize, Default)] +#[serde(default)] +pub(super) struct Info { + pub(super) title: String, + pub(super) summary: String, + pub(super) description: String, + pub(super) version: String, +} + +/// The Server Object. +#[derive(Deserialize, Default)] +pub(super) struct Server { + pub(super) url: String, + #[serde(default)] + pub(super) variables: HashMap, +} + +/// The Server Variable Object. +#[derive(Deserialize)] +pub(super) struct ServerVariable { + pub(super) default: String, +} + +/// The Components Object: what the rest of the document can refer to with +/// a `$ref`, by name, in the order they are written. +#[derive(Clone, Copy, Default)] +pub(super) struct Components { + pub(super) schemas: Span<(Str, Schema)>, + pub(super) parameters: Span<(Str, Id)>, + pub(super) request_bodies: Span<(Str, Id)>, + pub(super) responses: Span<(Str, Id)>, + pub(super) path_items: Span<(Str, Id)>, +} + +/// A Path Item Object, or a `$ref` to one. +#[derive(Default)] +pub(super) struct PathItem { + /// The `$ref`, or empty for none. An empty `$ref` is an error. + pub(super) reference: Str, + /// The parameters of every operation, which an operation's own + /// parameter replaces. + pub(super) parameters: Span>, + pub(super) get: Option>, + pub(super) put: Option>, + pub(super) post: Option>, + pub(super) delete: Option>, + pub(super) options: Option>, + pub(super) head: Option>, + pub(super) patch: Option>, + pub(super) trace: Option>, + pub(super) query: Option>, + pub(super) connect: Option>, + /// The operations of other methods, by method, in the order they are + /// written. + pub(super) additional_operations: Span<(Str, Id)>, +} + +/// An Operation Object. +#[derive(Default)] +pub(super) struct Operation { + /// The extensions that make the operation a method. + pub(super) definition: Definition, + /// The `x-forge-aliases`: a definition for each element, in the order + /// they are written, or `None` without it. An element that is not an + /// object is an empty definition, which makes no method. + pub(super) aliases: Option>, + pub(super) summary: Str, + pub(super) description: Str, + pub(super) deprecated: bool, + pub(super) parameters: Span>, + pub(super) request_body: Option>, + /// Each response by status, in the order they are written, leaving + /// out extensions. + pub(super) responses: Span<(Str, Id)>, +} + +/// The extensions that make an Operation Object, or an alias of one in its +/// `x-forge-aliases`, a method. +#[derive(Default)] +pub(super) struct Definition { + /// The `x-fern-sdk-method-name`, or empty for none. + pub(super) method_name: Str, + /// The `x-fern-sdk-group-name`: the group path, top level first, or + /// empty for none. + pub(super) group_names: Span, + /// `x-fern-ignore`. + pub(super) ignore: bool, + /// `x-forge-hidden`. + pub(super) hidden: bool, + /// The `x-forge-require-confirmation`, or `None` for none. An empty + /// one is `Some`. + pub(super) confirmation: Option, + /// The `x-fern-availability` as written, or `None` for none. It is + /// checked as the method is built, so that an error can name the + /// operation. + pub(super) availability: Option, + /// Each other extension, which is not read, in `Doc::extensions`, in + /// the order they are written. One written more than once has the last + /// value written, in the place of the first. + pub(super) extensions: Span<(String, Json)>, +} + +/// A Parameter Object, or a `$ref` to one. +#[derive(Default)] +pub(super) struct Parameter { + /// The `$ref`, or empty for none. An empty `$ref` is an error. + pub(super) reference: Str, + pub(super) name: Str, + /// `in`. + pub(super) location: Option, + pub(super) description: Str, + pub(super) required: bool, + pub(super) deprecated: bool, + pub(super) style: Option, + pub(super) explode: Option, + /// The schema, or `true` for any value if there is none. + pub(super) schema: Schema, + /// The schema of each media type of a parameter described by + /// `content` rather than `schema`, in the order they are written. + pub(super) content: Span<(Str, Schema)>, + /// The `x-fern-parameter-name`, or empty for none. + pub(super) field_name: Str, +} + +/// Where a parameter is sent: the `in` of a Parameter Object. +#[derive(Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(super) enum ParameterLocation { + Path, + Query, + Querystring, + Header, + Cookie, +} + +/// How a parameter's value is written: the `style` of a Parameter Object. +#[derive(Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(super) enum ParameterStyle { + Matrix, + Label, + Simple, + Form, + SpaceDelimited, + PipeDelimited, + DeepObject, +} + +/// A Request Body Object, or a `$ref` to one. +#[derive(Default)] +pub(super) struct RequestBody { + /// The `$ref`, or empty for none. An empty `$ref` is an error. + pub(super) reference: Str, + pub(super) description: Str, + pub(super) required: bool, + /// The schema of each media type, in the order they are written. + pub(super) content: Span<(Str, Schema)>, +} + +/// A Response Object, or a `$ref` to one. +#[derive(Default)] +pub(super) struct Response { + /// The `$ref`, or empty for none. An empty `$ref` is an error. + pub(super) reference: Str, + pub(super) description: Str, + /// The schema of each media type, in the order they are written. + pub(super) content: Span<(Str, Schema)>, +} + +/// A Schema Object, or, from OpenAPI 3.1, `true` for any value or `false` +/// for none. +#[derive(Clone, Copy)] +pub(super) enum Schema { + Bool(bool), + Object(Id), +} + +/// A schema written where none is, which allows any value. +impl Default for Schema { + fn default() -> Schema { + Schema::Bool(true) + } +} + +/// A Schema Object that is an object, rather than `true` or `false`. +#[derive(Default)] +pub(super) struct SchemaObject { + /// The `$ref`, or empty for none. An empty `$ref` is an error. + pub(super) reference: Str, + /// The types the schema allows: one for a string, any number for an + /// array, and none without `type`. + pub(super) types: Span, + pub(super) format: Format, + pub(super) content_encoding: ContentEncoding, + pub(super) nullable: bool, + /// The values of `enum`, or none without it. An empty `enum`, which + /// allows no value, is read as none. + pub(super) values: Span, + /// The one value the schema allows, which is `Some(Literal::Null)` for + /// `const: null`. + pub(super) constant: Option, + /// Each property, in the order they are written. + pub(super) properties: Span<(Str, Schema)>, + pub(super) additional_properties: Option, + /// The `unevaluatedProperties`, or `true` without it. + pub(super) unevaluated_properties: Schema, + pub(super) required: Span, + pub(super) items: Option, + /// The `maxItems`, or `None` without it. + pub(super) max_items: Option, + /// Whether the schema has `prefixItems`: it is a tuple, which is not + /// covered, so its items are not kept. + pub(super) prefix_items: bool, + /// The members of `allOf`, `anyOf` and `oneOf`, or none without them. + /// An empty one has no members, as it has none without it. + pub(super) all_of: Span, + pub(super) any_of: Span, + pub(super) one_of: Span, + /// Whether the schema has `not`, which only constrains the value, so + /// its schema is not kept. + pub(super) not: bool, + pub(super) discriminator: Option>, + /// The schemas in `$defs`, in the order they are written. + pub(super) defs: Span<(Str, Schema)>, + pub(super) title: Str, + pub(super) description: Str, + /// The `default`, in `Doc::defaults`, which is `Some` for `default: + /// null` too, or `None` without it. It is kept as written, without + /// being checked against the schema. + pub(super) default: Option>, + pub(super) deprecated: bool, + /// `readOnly`: the value appears in responses, and a client should not + /// send it. + pub(super) read_only: bool, + /// Whether the schema is marked `x-sensitive: true`: the value is a + /// secret, such as a password or a token. + pub(super) sensitive: bool, + /// The `x-fern-type-name`: the name of the model, for a schema in + /// `components.schemas`, or empty for none. + pub(super) model_name: Str, + /// The `x-fern-property-name`: the name of the field, for a property's + /// schema, or empty for none. + pub(super) field_name: Str, + /// Whether a property's schema is marked `x-stainless-skip: true`. A + /// list of the targets to skip it for is ignored. + pub(super) skip: bool, +} + +/// A value of `type` in a Schema Object. +#[derive(Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(super) enum SchemaType { + String, + Integer, + Number, + Boolean, + Array, + Object, + Null, +} + +/// A `format` in a Schema Object that decides a type. Every other format, +/// and no format, is `Other`. +#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) enum Format { + DateTime, + Date, + Byte, + Binary, + Int8, + Int16, + Int32, + Int64, + Uint8, + Uint16, + Uint32, + Uint64, + Float, + Double, + #[default] + #[serde(other)] + Other, +} + +/// A `contentEncoding` in a Schema Object. Every encoding but base64, and +/// no encoding, is `Other`. +#[derive(Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(super) enum ContentEncoding { + Base64, + #[default] + #[serde(other)] + Other, +} + +/// A value in an `enum` or `const`. +#[derive(Clone, Copy)] +pub(super) enum Literal { + Null, + Boolean(bool), + Number(f64), + String(Str), + /// An array or object, which is not covered, so it is not kept. + Composite, +} + +/// A Discriminator Object. +pub(super) struct Discriminator { + /// `propertyName`. + pub(super) property_name: Str, + /// Each value of the property and the `$ref` or schema name it maps + /// to, in the order they are written. + pub(super) mapping: Span<(Str, Str)>, + /// The `$ref` or schema name of the default, or empty for none. + pub(super) default_mapping: Str, +} + +/// The `x-forge-commands` extension of the OpenAPI Object: an entry for +/// each top-level group, by name. +#[derive(Default)] +pub(super) struct Commands(pub(super) HashMap); + +/// An entry in `x-forge-commands`. +#[derive(Deserialize, Default)] +pub(super) struct Command { + #[serde(default)] + pub(super) description: String, + /// The entries of the group's subgroups. + #[serde(default)] + pub(super) groups: Commands, + /// Whether an entry describes this group, rather than it only being on + /// the way to a group a dotted name describes. + #[serde(skip)] + pub(super) described: bool, +} + +/// An object that can instead be a `$ref` to one in `components`. +pub(super) trait Referable: Node { + /// Returns the `$ref`, which is empty if this is the object itself. + fn reference(&self) -> Str; +} + +impl Referable for PathItem { + fn reference(&self) -> Str { + self.reference + } +} + +impl Referable for Parameter { + fn reference(&self) -> Str { + self.reference + } +} + +impl Referable for RequestBody { + fn reference(&self) -> Str { + self.reference + } +} + +impl Referable for Response { + fn reference(&self) -> Str { + self.reference + } +} + +impl ParameterLocation { + /// Returns the location as the document writes it, such as `query`. + pub(super) fn as_str(self) -> &'static str { + match self { + ParameterLocation::Path => "path", + ParameterLocation::Query => "query", + ParameterLocation::Querystring => "querystring", + ParameterLocation::Header => "header", + ParameterLocation::Cookie => "cookie", + } + } +} + +impl ParameterStyle { + /// Returns the style as the document writes it, such as `form`. + pub(super) fn as_str(self) -> &'static str { + match self { + ParameterStyle::Matrix => "matrix", + ParameterStyle::Label => "label", + ParameterStyle::Simple => "simple", + ParameterStyle::Form => "form", + ParameterStyle::SpaceDelimited => "spaceDelimited", + ParameterStyle::PipeDelimited => "pipeDelimited", + ParameterStyle::DeepObject => "deepObject", + } + } +} + +// Reading. +// +// Each value is read into the `Doc` by a `Read`, which says what it is +// read as: an `Id` of the object it adds, a `Span` of the list it adds, +// or a value such as a `Str`. + +/// A way of reading a value into a `Doc`, as an `Out`. +trait Read { + type Out; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result; +} + +/// What reads a value with `T` into a `Doc`. +struct Seed<'a, T>(&'a mut Doc, PhantomData); + +fn seed(doc: &mut Doc) -> Seed<'_, T> { + Seed(doc, PhantomData) +} + +impl<'de, T: Read> DeserializeSeed<'de> for Seed<'_, T> { + type Value = T::Out; + + fn deserialize>(self, deserializer: D) -> Result { + T::read(self.0, deserializer) + } +} + +/// An object read from a map, key by key. +trait ReadObject { + type Out; + /// What the object is, for errors, such as `a Parameter Object`. + const EXPECTING: &'static str; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, map: A) -> Result; +} + +struct ObjectVisitor<'a, T>(&'a mut Doc, PhantomData); + +impl<'de, T: ReadObject> Visitor<'de> for ObjectVisitor<'_, T> { + type Value = T::Out; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(T::EXPECTING) + } + + fn visit_map>(self, map: A) -> Result { + T::read_object(self.0, map) + } +} + +impl Read for T { + type Out = T::Out; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result { + deserializer.deserialize_map(ObjectVisitor::(doc, PhantomData)) + } +} + +/// Reads a string. +struct ReadStr; + +impl Read for ReadStr { + type Out = Str; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result { + struct StrVisitor<'a>(&'a mut Strings); + + impl Visitor<'_> for StrVisitor<'_> { + type Value = Str; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a string") + } + + fn visit_str(self, v: &str) -> Result { + Ok(self.0.push(v)) + } + } + + deserializer.deserialize_str(StrVisitor(&mut doc.strings)) + } +} + +/// Reads a `$ref`, which cannot be empty: the empty reference points to the +/// whole document, which is never what a `$ref` can stand for here. +struct Reference; + +impl Read for Reference { + type Out = Str; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result { + struct ReferenceVisitor<'a>(&'a mut Strings); + + impl Visitor<'_> for ReferenceVisitor<'_> { + type Value = Str; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a $ref") + } + + fn visit_str(self, v: &str) -> Result { + if v.is_empty() { + return Err(E::custom( + "$ref \"\" points to the whole document, which is not covered", + )); + } + Ok(self.0.push(v)) + } + } + + deserializer.deserialize_str(ReferenceVisitor(&mut doc.strings)) + } +} + +/// Reads a value that can be `null`, which is `None`, as is a missing one. +struct Nullable(PhantomData); + +impl Read for Nullable { + type Out = Option; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct NullableVisitor<'a, T>(&'a mut Doc, PhantomData); + + impl<'de, T: Read> Visitor<'de> for NullableVisitor<'_, T> { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a value or null") + } + + fn visit_none(self) -> Result, E> { + Ok(None) + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_some>(self, deserializer: D) -> Result, D::Error> { + T::read(self.0, deserializer).map(Some) + } + } + + deserializer.deserialize_option(NullableVisitor::(doc, PhantomData)) + } +} + +/// Reads an array, each element with `T`, as a list. +struct Seq(PhantomData); + +impl Read for Seq +where + T::Out: Item, +{ + type Out = Span; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct SeqVisitor<'a, T>(&'a mut Doc, PhantomData); + + impl<'de, T: Read> Visitor<'de> for SeqVisitor<'_, T> + where + T::Out: Item, + { + type Value = Span; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a sequence") + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let doc = self.0; + let start = T::Out::list_mut(doc).start(); + while let Some(item) = seq.next_element_seed(seed::(doc))? { + T::Out::list_mut(doc).push(item); + } + Ok(T::Out::list_mut(doc).finish(start)) + } + } + + deserializer.deserialize_seq(SeqVisitor::(doc, PhantomData)) + } +} + +/// Reads an object as its entries, each value with `T`, in the order they +/// are written, leaving out extensions, keys that start with `x-`, if +/// `SKIP_EXTENSIONS`. Their values are skipped without being read. +struct Entries(PhantomData); + +/// Reads an object as its entries; see [`Entries`]. +type AllEntries = Entries; + +impl Read for Entries +where + (Str, T::Out): Item, +{ + type Out = Span<(Str, T::Out)>; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct EntriesVisitor<'a, T, const S: bool>(&'a mut Doc, PhantomData); + + impl<'de, T: Read, const S: bool> Visitor<'de> for EntriesVisitor<'_, T, S> + where + (Str, T::Out): Item, + { + type Value = Span<(Str, T::Out)>; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("an object") + } + + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let doc = self.0; + let start = <(Str, T::Out)>::list_mut(doc).start(); + while let Some(key) = map.next_key_seed(KeySeed::(&mut doc.strings))? { + let Some(key) = key else { + map.next_value::()?; + continue; + }; + let value = map.next_value_seed(seed::(doc))?; + <(Str, T::Out)>::list_mut(doc).push((key, value)); + } + Ok(<(Str, T::Out)>::list_mut(doc).finish(start)) + } + } + + deserializer.deserialize_map(EntriesVisitor::(doc, PhantomData)) + } +} + +/// Reads the key of an entry, or `None` for an extension if +/// `SKIP_EXTENSIONS`, which is not kept. +struct KeySeed<'a, const SKIP_EXTENSIONS: bool>(&'a mut Strings); + +impl<'de, const SKIP_EXTENSIONS: bool> DeserializeSeed<'de> for KeySeed<'_, SKIP_EXTENSIONS> { + type Value = Option; + + fn deserialize>(self, deserializer: D) -> Result, D::Error> { + deserializer.deserialize_str(self) + } +} + +impl Visitor<'_> for KeySeed<'_, SKIP_EXTENSIONS> { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a string") + } + + fn visit_str(self, v: &str) -> Result, E> { + if SKIP_EXTENSIONS && v.starts_with("x-") { + return Ok(None); + } + Ok(Some(self.0.push(v))) + } +} + +/// Reads a `type`: one type, or an array of them. +struct Types; + +impl Read for Types { + type Out = Span; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct TypesVisitor<'a>(&'a mut List); + + impl<'de> Visitor<'de> for TypesVisitor<'_> { + type Value = Span; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a type or an array of types") + } + + fn visit_str(self, v: &str) -> Result, E> { + let ty = SchemaType::deserialize(de::value::StrDeserializer::new(v))?; + let start = self.0.start(); + self.0.push(ty); + Ok(self.0.finish(start)) + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let start = self.0.start(); + while let Some(ty) = seq.next_element()? { + self.0.push(ty); + } + Ok(self.0.finish(start)) + } + } + + deserializer.deserialize_any(TypesVisitor(&mut doc.types)) + } +} + +/// Reads an `x-fern-sdk-group-name`: a string split on dots, or an array of +/// strings that are not split. The empty string is no group. +struct GroupNames; + +impl Read for GroupNames { + type Out = Span; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct GroupNamesVisitor<'a>(&'a mut Doc); + + impl<'de> Visitor<'de> for GroupNamesVisitor<'_> { + type Value = Span; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a string or an array of strings") + } + + fn visit_str(self, v: &str) -> Result, E> { + if v.is_empty() { + return Ok(Span::default()); + } + let doc = self.0; + Ok(doc.strings.push_split(v, '.', &mut doc.str_lists)) + } + + fn visit_seq>(self, seq: A) -> Result, A::Error> { + // The same as reading the array as a `Seq` of strings. + SeqOfStrs(self.0).visit_seq(seq) + } + } + + deserializer.deserialize_any(GroupNamesVisitor(doc)) + } +} + +/// Reads an array of strings. +struct SeqOfStrs<'a>(&'a mut Doc); + +impl<'de> Visitor<'de> for SeqOfStrs<'_> { + type Value = Span; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("an array of strings") + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let doc = self.0; + let start = doc.str_lists.start(); + while let Some(s) = seq.next_element_seed(seed::(doc))? { + doc.str_lists.push(s); + } + Ok(doc.str_lists.finish(start)) + } +} + +/// Reads a value in an `enum` or `const`. +impl Read for Literal { + type Out = Literal; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result { + struct LiteralVisitor<'a>(&'a mut Strings); + + impl<'de> Visitor<'de> for LiteralVisitor<'_> { + type Value = Literal; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("any value") + } + + fn visit_unit(self) -> Result { + Ok(Literal::Null) + } + + fn visit_none(self) -> Result { + Ok(Literal::Null) + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Literal::Boolean(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(Literal::Number(v as f64)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Literal::Number(v as f64)) + } + + fn visit_f64(self, v: f64) -> Result { + Ok(Literal::Number(v)) + } + + fn visit_str(self, v: &str) -> Result { + Ok(Literal::String(self.0.push(v))) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while let Some(IgnoredAny) = seq.next_element()? {} + Ok(Literal::Composite) + } + + fn visit_map>(self, mut map: A) -> Result { + while let Some((IgnoredAny, IgnoredAny)) = map.next_entry()? {} + Ok(Literal::Composite) + } + } + + deserializer.deserialize_any(LiteralVisitor(&mut doc.strings)) + } +} + +/// Reads a Schema Object, or `true` or `false`. +impl Read for Schema { + type Out = Schema; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result { + struct SchemaVisitor<'a>(&'a mut Doc); + + impl<'de> Visitor<'de> for SchemaVisitor<'_> { + type Value = Schema; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a Schema Object or a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Schema::Bool(v)) + } + + fn visit_map>(self, map: A) -> Result { + SchemaObject::read_object(self.0, map).map(Schema::Object) + } + } + + deserializer.deserialize_any(SchemaVisitor(doc)) + } +} + +/// Reads a Media Type Object as its schema, or `true` if it has none. +struct MediaType; + +keys! { + MediaTypeKey { Schema = "schema", } +} + +impl ReadObject for MediaType { + type Out = Schema; + const EXPECTING: &'static str = "a Media Type Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result { + let mut schema = Schema::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + MediaTypeKey::Schema => schema = map.next_value_seed(seed::(doc))?, + MediaTypeKey::Other => skip(&mut map)?, + } + } + Ok(schema) + } +} + +/// Skips the value of the entry being read. +fn skip<'de, A: MapAccess<'de>>(map: &mut A) -> Result<(), A::Error> { + map.next_value::()?; + Ok(()) +} + +/// Reads that a key is there, skipping its value. +fn present<'de, A: MapAccess<'de>>(map: &mut A) -> Result { + skip(map)?; + Ok(true) +} + +/// The first of `servers`. +struct FirstServer(Server); + +impl<'de> Deserialize<'de> for FirstServer { + fn deserialize>(deserializer: D) -> Result { + first(deserializer).map(FirstServer) + } +} + +keys! { + DocumentKey { + Openapi = "openapi", + Info = "info", + Servers = "servers", + Paths = "paths", + Components = "components", + Commands = "x-forge-commands", + } +} + +struct DocumentVisitor<'a>(&'a mut Doc); + +impl<'de> Visitor<'de> for DocumentVisitor<'_> { + type Value = (); + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("an OpenAPI Object") + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + let doc = self.0; + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + DocumentKey::Openapi => doc.openapi = map.next_value()?, + DocumentKey::Info => doc.info = map.next_value()?, + DocumentKey::Servers => doc.servers = map.next_value::()?.0, + DocumentKey::Paths => { + doc.paths = map.next_value_seed(seed::>(doc))?; + } + DocumentKey::Components => { + doc.components = map.next_value_seed(seed::(doc))?; + } + DocumentKey::Commands => doc.commands = map.next_value()?, + DocumentKey::Other => skip(&mut map)?, + } + } + Ok(()) + } +} + +keys! { + ComponentsKey { + Schemas = "schemas", + Parameters = "parameters", + RequestBodies = "requestBodies", + Responses = "responses", + PathItems = "pathItems", + } +} + +impl ReadObject for Components { + type Out = Components; + const EXPECTING: &'static str = "a Components Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result { + let mut components = Components::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + ComponentsKey::Schemas => { + components.schemas = map.next_value_seed(seed::>(doc))?; + } + ComponentsKey::Parameters => { + components.parameters = map.next_value_seed(seed::>(doc))?; + } + ComponentsKey::RequestBodies => { + components.request_bodies = map.next_value_seed(seed::>(doc))?; + } + ComponentsKey::Responses => { + components.responses = map.next_value_seed(seed::>(doc))?; + } + ComponentsKey::PathItems => { + components.path_items = map.next_value_seed(seed::>(doc))?; + } + ComponentsKey::Other => skip(&mut map)?, + } + } + Ok(components) + } +} + +keys! { + PathItemKey { + Ref = "$ref", + Parameters = "parameters", + Get = "get", + Put = "put", + Post = "post", + Delete = "delete", + Options = "options", + Head = "head", + Patch = "patch", + Trace = "trace", + Query = "query", + Connect = "connect", + AdditionalOperations = "additionalOperations", + } +} + +impl ReadObject for PathItem { + type Out = Id; + const EXPECTING: &'static str = "a Path Item Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut item = PathItem::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + let operation = match key { + PathItemKey::Ref => { + item.reference = map.next_value_seed(seed::(doc))?; + continue; + } + PathItemKey::Parameters => { + item.parameters = map.next_value_seed(seed::>(doc))?; + continue; + } + PathItemKey::AdditionalOperations => { + item.additional_operations = map.next_value_seed(seed::>(doc))?; + continue; + } + PathItemKey::Other => { + skip(&mut map)?; + continue; + } + PathItemKey::Get => &mut item.get, + PathItemKey::Put => &mut item.put, + PathItemKey::Post => &mut item.post, + PathItemKey::Delete => &mut item.delete, + PathItemKey::Options => &mut item.options, + PathItemKey::Head => &mut item.head, + PathItemKey::Patch => &mut item.patch, + PathItemKey::Trace => &mut item.trace, + PathItemKey::Query => &mut item.query, + PathItemKey::Connect => &mut item.connect, + }; + *operation = map.next_value_seed(seed::>(doc))?; + } + Ok(doc.add(item)) + } +} + +keys! { + /// The keys of a [`Definition`], in an Operation Object or an alias. + DefinitionKey { + MethodName = "x-fern-sdk-method-name", + GroupNames = "x-fern-sdk-group-name", + Ignore = "x-fern-ignore", + Hidden = "x-forge-hidden", + Confirmation = "x-forge-require-confirmation", + Availability = "x-fern-availability", + } +} + +impl Definition { + /// Reads the value of `key`, the key of the entry `map` is reading. + fn read_value<'de, A: MapAccess<'de>>( + &mut self, + doc: &mut Doc, + map: &mut A, + key: DefinitionKey, + ) -> Result<(), A::Error> { + match key { + DefinitionKey::MethodName => self.method_name = map.next_value_seed(seed::(doc))?, + DefinitionKey::GroupNames => self.group_names = map.next_value_seed(seed::(doc))?, + DefinitionKey::Ignore => self.ignore = map.next_value()?, + DefinitionKey::Hidden => self.hidden = map.next_value()?, + DefinitionKey::Confirmation => { + self.confirmation = Some(map.next_value_seed(seed::(doc))?); + } + DefinitionKey::Availability => { + self.availability = Some(map.next_value_seed(seed::(doc))?); + } + DefinitionKey::Other => skip(map)?, + } + Ok(()) + } +} + +/// Reads an alias in `x-forge-aliases`: an object of the keys of a +/// definition and other extensions. Its keys that are not extensions are +/// skipped, and so is an `x-forge-aliases` in it, since aliases do not +/// nest. +struct Alias; + +impl ReadObject for Alias { + type Out = Definition; + const EXPECTING: &'static str = "an alias"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result { + let mut definition = Definition::default(); + let mut seen = 0; + // As for an operation. + let mut extensions = Vec::new(); + while let Some(field) = map.next_key::()? { + match field { + AliasField::Definition(key) => { + key.once(&mut seen)?; + definition.read_value(doc, &mut map, key)?; + } + AliasField::Extension(name) => json::insert(&mut extensions, name, map.next_value()?), + AliasField::Other => skip(&mut map)?, + } + } + definition.extensions = doc.extensions.extend(extensions); + Ok(definition) + } +} + +/// Reads an `x-forge-aliases`: an array, each object in which is read as +/// an [`Alias`]. Each other element is read as an empty definition, which +/// makes no method, so that every alias is at its index in the array. +struct Aliases; + +impl Read for Aliases { + type Out = Span; + + fn read<'de, D: Deserializer<'de>>(doc: &mut Doc, deserializer: D) -> Result, D::Error> { + struct AliasesVisitor<'a>(&'a mut Doc); + + impl<'de> Visitor<'de> for AliasesVisitor<'_> { + type Value = Span; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("an array of aliases") + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let doc = self.0; + let start = doc.aliases.start(); + while let Some(alias) = seq.next_element_seed(MaybeAlias(doc))? { + doc.aliases.push(alias.unwrap_or_default()); + } + Ok(doc.aliases.finish(start)) + } + } + + deserializer.deserialize_seq(AliasesVisitor(doc)) + } +} + +/// What reads an element of `x-forge-aliases`: an [`Alias`] if it is an +/// object, or else `None`. +struct MaybeAlias<'a>(&'a mut Doc); + +impl<'de> DeserializeSeed<'de> for MaybeAlias<'_> { + type Value = Option; + + fn deserialize>(self, deserializer: D) -> Result, D::Error> { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for MaybeAlias<'_> { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("any value") + } + + fn visit_map>(self, map: A) -> Result, A::Error> { + Alias::read_object(self.0, map).map(Some) + } + + fn visit_unit(self) -> Result, E> { + Ok(None) + } + + fn visit_none(self) -> Result, E> { + Ok(None) + } + + fn visit_some>(self, deserializer: D) -> Result, D::Error> { + deserializer.deserialize_any(self) + } + + fn visit_bool(self, _: bool) -> Result, E> { + Ok(None) + } + + fn visit_i64(self, _: i64) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, _: u64) -> Result, E> { + Ok(None) + } + + fn visit_f64(self, _: f64) -> Result, E> { + Ok(None) + } + + fn visit_str(self, _: &str) -> Result, E> { + Ok(None) + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + while let Some(IgnoredAny) = seq.next_element()? {} + Ok(None) + } +} + +/// A key of an alias: one of a definition, an extension that is not read, +/// which is kept by its name, or any other key, which is skipped, as +/// [`Alias`] says. +enum AliasField { + Definition(DefinitionKey), + Extension(String), + Other, +} + +impl<'de> Deserialize<'de> for AliasField { + fn deserialize>(deserializer: D) -> Result { + struct FieldVisitor; + + impl Visitor<'_> for FieldVisitor { + type Value = AliasField; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a key") + } + + fn visit_str(self, v: &str) -> Result { + Ok(match DefinitionKey::from_key(v) { + DefinitionKey::Other if !v.starts_with("x-") => AliasField::Other, + DefinitionKey::Other => match OperationKey::from_key(v) { + OperationKey::Aliases => AliasField::Other, + _ => AliasField::Extension(v.to_owned()), + }, + key => AliasField::Definition(key), + }) + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } +} + +keys! { + OperationKey { + Aliases = "x-forge-aliases", + Summary = "summary", + Description = "description", + Deprecated = "deprecated", + Parameters = "parameters", + RequestBody = "requestBody", + Responses = "responses", + } +} + +impl ReadObject for Operation { + type Out = Id; + const EXPECTING: &'static str = "an Operation Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut operation = Operation::default(); + let mut seen = 0; + let mut seen_definition = 0; + // Kept apart until the operation is read, so that an extension + // written again can replace the first. Empty, it allocates nothing. + let mut extensions = Vec::new(); + while let Some(field) = map.next_key::()? { + let key = match field { + OperationField::Key(key) => key, + OperationField::Definition(key) => { + key.once(&mut seen_definition)?; + operation.definition.read_value(doc, &mut map, key)?; + continue; + } + OperationField::Extension(name) => { + json::insert(&mut extensions, name, map.next_value()?); + continue; + } + }; + key.once(&mut seen)?; + match key { + OperationKey::Aliases => { + operation.aliases = Some(map.next_value_seed(seed::(doc))?); + } + OperationKey::Summary => operation.summary = map.next_value_seed(seed::(doc))?, + OperationKey::Description => { + operation.description = map.next_value_seed(seed::(doc))?; + } + OperationKey::Deprecated => operation.deprecated = map.next_value()?, + OperationKey::Parameters => { + operation.parameters = map.next_value_seed(seed::>(doc))?; + } + OperationKey::RequestBody => { + operation.request_body = map.next_value_seed(seed::>(doc))?; + } + OperationKey::Responses => { + operation.responses = map.next_value_seed(seed::>(doc))?; + } + OperationKey::Other => skip(&mut map)?, + } + } + operation.definition.extensions = doc.extensions.extend(extensions); + Ok(doc.add(operation)) + } +} + +/// A key of an Operation Object: one of its definition, another that is +/// read, or an extension that is not, which is kept by its name. Any other +/// key is `OperationKey::Other`. +enum OperationField { + Definition(DefinitionKey), + Key(OperationKey), + Extension(String), +} + +impl<'de> Deserialize<'de> for OperationField { + fn deserialize>(deserializer: D) -> Result { + struct FieldVisitor; + + impl Visitor<'_> for FieldVisitor { + type Value = OperationField; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a key") + } + + fn visit_str(self, v: &str) -> Result { + match DefinitionKey::from_key(v) { + DefinitionKey::Other => {} + key => return Ok(OperationField::Definition(key)), + } + Ok(match OperationKey::from_key(v) { + OperationKey::Other if v.starts_with("x-") => OperationField::Extension(v.to_owned()), + key => OperationField::Key(key), + }) + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } +} + +keys! { + ParameterKey { + Ref = "$ref", + Name = "name", + In = "in", + Description = "description", + Required = "required", + Deprecated = "deprecated", + Style = "style", + Explode = "explode", + Schema = "schema", + Content = "content", + FieldName = "x-fern-parameter-name", + } +} + +impl ReadObject for Parameter { + type Out = Id; + const EXPECTING: &'static str = "a Parameter Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut param = Parameter::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + ParameterKey::Ref => param.reference = map.next_value_seed(seed::(doc))?, + ParameterKey::Name => param.name = map.next_value_seed(seed::(doc))?, + ParameterKey::In => param.location = map.next_value()?, + ParameterKey::Description => { + param.description = map.next_value_seed(seed::(doc))?; + } + ParameterKey::Required => param.required = map.next_value()?, + ParameterKey::Deprecated => param.deprecated = map.next_value()?, + ParameterKey::Style => param.style = map.next_value()?, + ParameterKey::Explode => param.explode = map.next_value()?, + ParameterKey::Schema => param.schema = map.next_value_seed(seed::(doc))?, + ParameterKey::Content => { + param.content = map.next_value_seed(seed::>(doc))?; + } + ParameterKey::FieldName => param.field_name = map.next_value_seed(seed::(doc))?, + ParameterKey::Other => skip(&mut map)?, + } + } + Ok(doc.add(param)) + } +} + +keys! { + RequestBodyKey { + Ref = "$ref", + Description = "description", + Required = "required", + Content = "content", + } +} + +impl ReadObject for RequestBody { + type Out = Id; + const EXPECTING: &'static str = "a Request Body Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut body = RequestBody::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + RequestBodyKey::Ref => body.reference = map.next_value_seed(seed::(doc))?, + RequestBodyKey::Description => { + body.description = map.next_value_seed(seed::(doc))?; + } + RequestBodyKey::Required => body.required = map.next_value()?, + RequestBodyKey::Content => { + body.content = map.next_value_seed(seed::>(doc))?; + } + RequestBodyKey::Other => skip(&mut map)?, + } + } + Ok(doc.add(body)) + } +} + +keys! { + ResponseKey { + Ref = "$ref", + Description = "description", + Content = "content", + } +} + +impl ReadObject for Response { + type Out = Id; + const EXPECTING: &'static str = "a Response Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut response = Response::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + ResponseKey::Ref => response.reference = map.next_value_seed(seed::(doc))?, + ResponseKey::Description => { + response.description = map.next_value_seed(seed::(doc))?; + } + ResponseKey::Content => { + response.content = map.next_value_seed(seed::>(doc))?; + } + ResponseKey::Other => skip(&mut map)?, + } + } + Ok(doc.add(response)) + } +} + +keys! { + SchemaKey { + Ref = "$ref", + Type = "type", + Format = "format", + ContentEncoding = "contentEncoding", + Nullable = "nullable", + Enum = "enum", + Const = "const", + Properties = "properties", + AdditionalProperties = "additionalProperties", + UnevaluatedProperties = "unevaluatedProperties", + Required = "required", + Items = "items", + MaxItems = "maxItems", + PrefixItems = "prefixItems", + AllOf = "allOf", + AnyOf = "anyOf", + OneOf = "oneOf", + Not = "not", + Discriminator = "discriminator", + Defs = "$defs", + Title = "title", + Description = "description", + Default = "default", + Deprecated = "deprecated", + ReadOnly = "readOnly", + Sensitive = "x-sensitive", + ModelName = "x-fern-type-name", + FieldName = "x-fern-property-name", + Skip = "x-stainless-skip", + } +} + +impl ReadObject for SchemaObject { + type Out = Id; + const EXPECTING: &'static str = "a Schema Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut schema = SchemaObject::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + SchemaKey::Ref => schema.reference = map.next_value_seed(seed::(doc))?, + SchemaKey::Type => schema.types = map.next_value_seed(seed::(doc))?, + SchemaKey::Format => schema.format = map.next_value()?, + SchemaKey::ContentEncoding => schema.content_encoding = map.next_value()?, + SchemaKey::Nullable => schema.nullable = map.next_value()?, + SchemaKey::Enum => schema.values = map.next_value_seed(seed::>(doc))?, + SchemaKey::Const => { + schema.constant = Some(map.next_value_seed(seed::(doc))?); + } + SchemaKey::Properties => { + schema.properties = map.next_value_seed(seed::>(doc))?; + } + SchemaKey::AdditionalProperties => { + schema.additional_properties = map.next_value_seed(seed::>(doc))?; + } + SchemaKey::UnevaluatedProperties => { + schema.unevaluated_properties = map.next_value_seed(seed::(doc))?; + } + SchemaKey::Required => { + schema.required = map.next_value_seed(SeqOfStrsSeed(doc))?; + } + SchemaKey::Items => schema.items = map.next_value_seed(seed::>(doc))?, + SchemaKey::MaxItems => schema.max_items = Some(map.next_value::()?.0), + SchemaKey::PrefixItems => schema.prefix_items = present(&mut map)?, + SchemaKey::AllOf => schema.all_of = map.next_value_seed(seed::>(doc))?, + SchemaKey::AnyOf => schema.any_of = map.next_value_seed(seed::>(doc))?, + SchemaKey::OneOf => schema.one_of = map.next_value_seed(seed::>(doc))?, + SchemaKey::Not => schema.not = present(&mut map)?, + SchemaKey::Discriminator => { + schema.discriminator = map.next_value_seed(seed::>(doc))?; + } + SchemaKey::Defs => schema.defs = map.next_value_seed(seed::>(doc))?, + SchemaKey::Title => schema.title = map.next_value_seed(seed::(doc))?, + SchemaKey::Description => { + schema.description = map.next_value_seed(seed::(doc))?; + } + SchemaKey::Default => schema.default = Some(doc.add(map.next_value::()?)), + SchemaKey::Deprecated => schema.deprecated = map.next_value()?, + SchemaKey::ReadOnly => schema.read_only = map.next_value()?, + SchemaKey::Sensitive => schema.sensitive = map.next_value()?, + SchemaKey::ModelName => schema.model_name = map.next_value_seed(seed::(doc))?, + SchemaKey::FieldName => schema.field_name = map.next_value_seed(seed::(doc))?, + SchemaKey::Skip => schema.skip = map.next_value::()?.0, + SchemaKey::Other => skip(&mut map)?, + } + } + Ok(doc.add(schema)) + } +} + +/// What reads an array of strings into a `Doc`. +struct SeqOfStrsSeed<'a>(&'a mut Doc); + +impl<'de> DeserializeSeed<'de> for SeqOfStrsSeed<'_> { + type Value = Span; + + fn deserialize>(self, deserializer: D) -> Result, D::Error> { + deserializer.deserialize_seq(SeqOfStrs(self.0)) + } +} + +keys! { + DiscriminatorKey { + PropertyName = "propertyName", + Mapping = "mapping", + DefaultMapping = "defaultMapping", + } +} + +impl ReadObject for Discriminator { + type Out = Id; + const EXPECTING: &'static str = "a Discriminator Object"; + + fn read_object<'de, A: MapAccess<'de>>(doc: &mut Doc, mut map: A) -> Result, A::Error> { + let mut property_name = None; + let mut mapping = Span::default(); + let mut default_mapping = Str::default(); + let mut seen = 0; + while let Some(key) = map.next_key::()? { + key.once(&mut seen)?; + match key { + DiscriminatorKey::PropertyName => { + property_name = Some(map.next_value_seed(seed::(doc))?); + } + DiscriminatorKey::Mapping => { + mapping = map.next_value_seed(seed::>(doc))?; + } + DiscriminatorKey::DefaultMapping => { + default_mapping = map.next_value_seed(seed::(doc))?; + } + DiscriminatorKey::Other => skip(&mut map)?, + } + } + let Some(property_name) = property_name else { + return Err(de::Error::missing_field("propertyName")); + }; + Ok(doc.add(Discriminator { + property_name, + mapping, + default_mapping, + })) + } +} + +/// A count, such as `maxItems`: a non-negative integer. A number with no +/// fractional part, such as `2.0`, is one, as JSON Schema says; any other +/// number, such as `-1` or `2.5`, and any value that is not a number, is +/// an error. +struct Count(u64); + +impl<'de> Deserialize<'de> for Count { + fn deserialize>(deserializer: D) -> Result { + struct CountVisitor; + + impl Visitor<'_> for CountVisitor { + type Value = Count; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a non-negative integer") + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Count(v)) + } + + fn visit_i64(self, v: i64) -> Result { + match u64::try_from(v) { + Ok(v) => Ok(Count(v)), + Err(_) => Err(E::invalid_value(de::Unexpected::Signed(v), &self)), + } + } + + fn visit_f64(self, v: f64) -> Result { + // 2^64 is the first float that does not fit in a `u64`. + if v >= 0.0 && v.fract() == 0.0 && v < 18_446_744_073_709_551_616.0 { + Ok(Count(v as u64)) + } else { + Err(E::invalid_value(de::Unexpected::Float(v), &self)) + } + } + } + + deserializer.deserialize_any(CountVisitor) + } +} + +/// An `x-stainless-skip`: `true` or `false`, or a list of the targets to +/// skip for, which is not `true`. +struct StainlessSkip(bool); + +impl<'de> Deserialize<'de> for StainlessSkip { + fn deserialize>(deserializer: D) -> Result { + struct SkipVisitor; + + impl<'de> Visitor<'de> for SkipVisitor { + type Value = StainlessSkip; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a boolean or an array of targets") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(StainlessSkip(v)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while let Some(IgnoredAny) = seq.next_element()? {} + Ok(StainlessSkip(false)) + } + } + + deserializer.deserialize_any(SkipVisitor) + } +} + +/// Reads `x-forge-commands`. A key with dots, such as +/// `dynamic-routing.deployments`, is a path: it describes the group +/// `deployments` inside `dynamic-routing`, the same as nesting the entry in +/// the `groups` of a `dynamic-routing` entry. +impl<'de> Deserialize<'de> for Commands { + fn deserialize>(deserializer: D) -> Result { + struct CommandsVisitor; + + impl<'de> Visitor<'de> for CommandsVisitor { + type Value = Commands; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("an object of commands") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut commands = Commands(HashMap::with_capacity(map.size_hint().unwrap_or(0))); + while let Some((key, mut command)) = map.next_entry::()? { + command.described = true; + let path: Vec = key.split('.').map(Name::new).collect(); + commands.insert(&path, command).map_err(de::Error::custom)?; + } + Ok(commands) + } + } + + deserializer.deserialize_map(CommandsVisitor) + } +} + +impl Commands { + /// Places `command` at `path`, adding the groups on the way that no + /// entry describes. Two entries that describe the same group, such as + /// `abuse_reports` and `abuse-reports`, or `a.b` and `b` in the `groups` + /// of `a`, are an error. + fn insert(&mut self, path: &[Name], command: Command) -> Result<(), String> { + let (name, rest) = path.split_first().expect("a key has at least one name"); + let entry = self.0.entry(name.clone()).or_default(); + if !rest.is_empty() { + return entry.groups.insert(rest, command); + } + if entry.described && command.described { + let words = name.words().join(" "); + return Err(format!("two entries in x-forge-commands have the name {words:?}")); + } + let Command { + description, + groups, + described, + } = command; + if described { + entry.description = description; + entry.described = true; + } + for (name, group) in groups.0 { + entry.groups.insert(&[name], group)?; + } + Ok(()) + } +} diff --git a/src/link/read.rs b/src/link/read.rs new file mode 100644 index 000000000..22add113d --- /dev/null +++ b/src/link/read.rs @@ -0,0 +1,167 @@ +//! Ways of reading values with serde that know nothing of OpenAPI, for the +//! objects in [`openapi`](super::openapi). + +use std::fmt; +use std::marker::PhantomData; + +use serde::Deserialize; +use serde::de::{Deserializer, IgnoredAny, SeqAccess, Visitor}; + +/// Declares `$name`, an enum of the keys of an object that are read, +/// each with the string it is written as, and `Other` for any other key. +/// It is read as an identifier, without allocating. +macro_rules! keys { + ($(#[$meta:meta])* $name:ident { $($variant:ident = $key:literal,)* }) => { + $(#[$meta])* + #[derive(Clone, Copy)] + enum $name { + $($variant,)* + Other, + } + + impl $name { + /// Returns the key written as `key`. + fn from_key(key: &str) -> $name { + match key { + $($key => $name::$variant,)* + _ => $name::Other, + } + } + + /// Returns an error if the key is in `seen`, which has a bit + /// for each key read so far, and else adds it. Other keys can + /// be repeated. + fn once(self, seen: &mut u64) -> Result<(), E> { + let name = match self { + $($name::$variant => $key,)* + $name::Other => return Ok(()), + }; + let bit = 1u64 << (self as u32); + if *seen & bit != 0 { + return Err(E::duplicate_field(name)); + } + *seen |= bit; + Ok(()) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result<$name, D::Error> { + struct KeyVisitor; + + impl serde::de::Visitor<'_> for KeyVisitor { + type Value = $name; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a key") + } + + fn visit_str(self, v: &str) -> Result<$name, E> { + Ok($name::from_key(v)) + } + } + + deserializer.deserialize_identifier(KeyVisitor) + } + } + }; +} + +pub(super) use keys; + +/// Reads the first element of a sequence, or the default if it is empty. +/// The rest are skipped. +pub(super) fn first<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + struct First(PhantomData); + + impl<'de, T: Deserialize<'de> + Default> Visitor<'de> for First { + type Value = T; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a sequence") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let first = seq.next_element()?; + while let Some(IgnoredAny) = seq.next_element()? {} + Ok(first.unwrap_or_default()) + } + } + + deserializer.deserialize_seq(First(PhantomData)) +} + +#[cfg(test)] +mod tests { + use serde::de; + use serde_json::de::StrRead; + + use super::*; + + /// Reads `json` with `read`, which must succeed. + #[track_caller] + fn read<'a, T>( + json: &'a str, + read: impl FnOnce(&mut serde_json::Deserializer>) -> serde_json::Result, + ) -> T { + match read(&mut serde_json::Deserializer::from_str(json)) { + Ok(value) => value, + Err(err) => panic!("read {json}: {err}"), + } + } + + keys! { + /// The keys of a test object. + TestKey { A = "a", B = "b", } + } + + #[test] + fn first_is_the_first_element() { + let first: u32 = read("[1, 2]", |d| first(d)); + + assert_eq!(first, 1); + } + + #[test] + fn first_skips_the_rest_whatever_they_are() { + let first: u32 = read(r#"[1, "two", {"three": 3}]"#, |d| first(d)); + + assert_eq!(first, 1); + } + + #[test] + fn first_of_an_empty_sequence_is_the_default() { + let first: String = read("[]", |d| first(d)); + + assert_eq!(first, ""); + } + + #[test] + fn keys_are_read_by_how_they_are_written() { + let keys: Vec = read(r#"["a", "b", "c"]"#, |d| Vec::deserialize(d)); + + assert!(matches!(keys[..], [TestKey::A, TestKey::B, TestKey::Other])); + } + + #[test] + fn a_key_can_be_read_once() { + let mut seen = 0; + TestKey::A.once::(&mut seen).unwrap(); + TestKey::B.once::(&mut seen).unwrap(); + let err = TestKey::A.once::(&mut seen).unwrap_err(); + + assert_eq!(err.to_string(), "duplicate field `a`"); + } + + #[test] + fn other_keys_can_be_read_again() { + let mut seen = 0; + TestKey::Other.once::(&mut seen).unwrap(); + + assert!(TestKey::Other.once::(&mut seen).is_ok()); + } +} diff --git a/src/method.rs b/src/method.rs new file mode 100644 index 000000000..6cdab361a --- /dev/null +++ b/src/method.rs @@ -0,0 +1,241 @@ +//! The locations of a method's parameters, and its body and responses. + +use std::fmt; + +use crate::arena::Str; +use crate::types::TypeId; +use crate::{Forge, Type}; + +/// Where a parameter of a [`Method`](crate::Method) is sent, as +/// [`Method::location`](crate::Method::location) gives it. +/// +/// Corresponds to `in` in the +/// [Parameter Object](https://spec.openapis.org/oas/latest.html#parameter-object). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Location { + /// In the path, in place of `{name}` in the path template, where + /// `name` is the field's wire name. + /// + /// Only the default style, `simple`, is covered. The `label` and + /// `matrix` styles are errors. + Path, + /// In the query string. + /// + /// Only the default style, `form`, is covered. The `spaceDelimited`, + /// `pipeDelimited` and `deepObject` styles are errors. + Query { + /// Whether each item of an array is sent as its own parameter, as + /// in `ids=1&ids=2`, rather than joined with commas, as in + /// `ids=1,2`, and likewise each property of an object. + /// + /// Corresponds to `explode` in the parameter, which is true by + /// default. + exploded: bool, + }, +} + +/// The request body of a [`Method`](crate::Method). +/// +/// Corresponds to a +/// [Request Body Object](https://spec.openapis.org/oas/latest.html#request-body-object). +#[derive(Clone, Copy)] +pub struct Body<'f> { + forge: &'f Forge, + data: &'f BodyData, +} + +/// A response that a [`Method`](crate::Method) declares. +/// +/// Corresponds to a +/// [Response Object](https://spec.openapis.org/oas/latest.html#response-object). +#[derive(Clone, Copy)] +pub struct Response<'f> { + forge: &'f Forge, + data: &'f ResponseData, +} + +/// The type of a [`Body`] or [`Response`] in one media type. +/// +/// Corresponds to an entry of `content`, a +/// [Media Type Object](https://spec.openapis.org/oas/latest.html#media-type-object). +#[derive(Clone, Copy)] +pub struct Content<'f> { + forge: &'f Forge, + data: &'f ContentData, +} + +impl<'f> Body<'f> { + /// Reports whether a request must have the body. + pub fn is_required(self) -> bool { + self.data.required + } + + /// Returns a description of the body, or an empty string if it has + /// none. + pub fn description(self) -> &'f str { + self.forge.strings.get(self.data.description) + } + + /// Returns each media type the body can be sent as, sorted by media + /// type. + pub fn contents(self) -> impl ExactSizeIterator> { + contents(self.forge, &self.data.contents) + } + + /// Returns the JSON content a client sends, or `None` if the body has + /// no JSON media type. See [`Content::is_json`]. + /// + /// It is the content of `application/json`, or else of the first JSON + /// media type in [`contents`](Body::contents). + pub fn json(self) -> Option> { + json(self.forge, &self.data.contents, self.data.json) + } +} + +impl<'f> Response<'f> { + /// Returns the status the response is for: a code such as `200`, a + /// range such as `4XX`, or `default`. + pub fn status(self) -> &'f str { + self.forge.strings.get(self.data.status) + } + + /// Returns a description of the response, or an empty string if it has + /// none. + pub fn description(self) -> &'f str { + self.forge.strings.get(self.data.description) + } + + /// Returns each media type of the response's body, sorted by media + /// type. A response with no body has none. + pub fn contents(self) -> impl ExactSizeIterator> { + contents(self.forge, &self.data.contents) + } + + /// Returns the content of the response's JSON body, or `None` if the + /// response has no JSON media type, chosen as for [`Body::json`]. + pub fn json(self) -> Option> { + json(self.forge, &self.data.contents, self.data.json) + } +} + +impl<'f> Content<'f> { + /// Returns the media type, such as `application/json`, as the document + /// writes it. + pub fn media_type(self) -> &'f str { + self.forge.strings.get(self.data.media_type) + } + + /// Returns a description of the content's schema, or an empty string + /// if it has none. + /// + /// As for a [`Field::description`](crate::Field::description), it is + /// the schema's own `description`, or else that of the schema its + /// `$ref` points to, following `$ref`s through any chain of them, or + /// from OpenAPI 3.1, that of its first `allOf` member with one; never + /// the `title`. The [`Body::description`] or + /// [`Response::description`] describes the body or response as a + /// whole, and this the value in one media type. + pub fn description(self) -> &'f str { + self.forge.strings.get(self.data.description) + } + + /// Reports whether the media type is JSON: `application/json` or a + /// type ending in `+json`, ignoring case and parameters. + pub fn is_json(self) -> bool { + is_json(self.media_type()) + } + + /// Returns the type of the body in this media type: the type of its + /// schema, read the same way whether the media type is JSON or not. So + /// an object schema of `multipart/form-data` is an object with a field + /// for each part, and a string schema with format `binary` of + /// `application/octet-stream` is + /// [`Scalar::Binary`](crate::Scalar::Binary). + /// + /// A media type with no schema allows any value, and is + /// [`Scalar::Unknown`](crate::Scalar::Unknown). + pub fn ty(self) -> Type<'f> { + Type::new(self.forge, self.data.ty) + } +} + +fn contents<'f>(forge: &'f Forge, contents: &'f [ContentData]) -> impl ExactSizeIterator> { + contents.iter().map(move |data| Content { forge, data }) +} + +fn json<'f>(forge: &'f Forge, contents: &'f [ContentData], json: Option) -> Option> { + let data = &contents[json? as usize]; + Some(Content { forge, data }) +} + +/// Reports whether `media_type` is `application/json` or ends in `+json`, +/// ignoring case and parameters. +pub(crate) fn is_json(media_type: &str) -> bool { + let base = media_type.split(';').next().unwrap_or_default().trim(); + let base = base.to_ascii_lowercase(); + base == "application/json" || base.ends_with("+json") +} + +/// What a [`Forge`] stores for a request body. +pub(crate) struct BodyData { + pub(crate) required: bool, + pub(crate) description: Str, + /// Sorted by media type. + pub(crate) contents: Vec, + /// The index in `contents` of the JSON content a client sends. + pub(crate) json: Option, +} + +pub(crate) struct ResponseData { + pub(crate) status: Str, + pub(crate) description: Str, + /// Sorted by media type. + pub(crate) contents: Vec, + /// The index in `contents` of the JSON content. + pub(crate) json: Option, +} + +pub(crate) struct ContentData { + pub(crate) media_type: Str, + pub(crate) ty: TypeId, + pub(crate) description: Str, +} + +impl<'f> Body<'f> { + pub(crate) fn new(forge: &'f Forge, data: &'f BodyData) -> Body<'f> { + Body { forge, data } + } +} + +impl<'f> Response<'f> { + pub(crate) fn new(forge: &'f Forge, data: &'f ResponseData) -> Response<'f> { + Response { forge, data } + } +} + +impl fmt::Debug for Body<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Body") + .field("required", &self.is_required()) + .field("contents", &self.contents().collect::>()) + .finish() + } +} + +impl fmt::Debug for Response<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Response") + .field("status", &self.status()) + .field("contents", &self.contents().collect::>()) + .finish() + } +} + +impl fmt::Debug for Content<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Content") + .field("media_type", &self.media_type()) + .field("ty", &self.ty()) + .finish() + } +} diff --git a/src/name.rs b/src/name.rs new file mode 100644 index 000000000..05788029e --- /dev/null +++ b/src/name.rs @@ -0,0 +1,189 @@ +//! Names that a generator spells in the casing of its language. + +/// A name from the description, normalized into lower-case words so that a +/// generator can spell it in any casing. Words are split at `-`, `_`, every +/// other character that is not a letter or digit, and case changes: +/// `XMLDocument` is "xml document", `getByID` is "get by id", and +/// `ipv4_cidr` is "ipv4 cidr". Digits belong to the word before them. +/// +/// Every word is lower case, so a name has exactly one spelling in each +/// casing, whatever its casing in the description: `GraphQL` is `graphQl` +/// in camelCase. +/// +/// A name is only its words. Names written differently but with the same +/// words, such as `get_by_id` and `getByID`, are equal, since every casing +/// spells them the same. Names order by their words. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Name { + words: Vec, +} + +impl Name { + /// Returns the name written as `s`. + pub fn new(s: &str) -> Name { + let mut words = Vec::new(); + // Where the word being read starts in `s`, and its last character. + let mut word: Option<(usize, char)> = None; + let mut chars = s.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if !c.is_alphabetic() && !c.is_numeric() { + flush(&mut words, s, &mut word, i); + continue; + } + if c.is_uppercase() + && let Some((_, prev)) = word + { + // A lower-case letter or digit ends a word before an + // upper-case one ("getBy" and "ipv4Cidr"), and so does the + // last letter of an acronym before a new word + // ("XMLDocument"). + let next_lower = chars.peek().is_some_and(|&(_, next)| next.is_lowercase()); + if prev.is_lowercase() || prev.is_numeric() || (prev.is_uppercase() && next_lower) { + flush(&mut words, s, &mut word, i); + } + } + let start = match word { + Some((start, _)) => start, + None => i, + }; + word = Some((start, c)); + } + flush(&mut words, s, &mut word, s.len()); + Name { words } + } + + /// Returns the lower-case words of the name. + pub fn words(&self) -> &[String] { + &self.words + } + + /// Reports whether the name has no words: what was written has no + /// letters or digits, such as `-`. + pub fn is_empty(&self) -> bool { + self.words.is_empty() + } + + /// Returns the name in camelCase: `petStore`. + pub fn camel(&self) -> String { + let mut out = String::with_capacity(self.words_len()); + for (i, word) in self.words.iter().enumerate() { + if i == 0 { + out.push_str(word); + } else { + push_upper_first(&mut out, word); + } + } + out + } + + /// Returns the name in PascalCase: `PetStore`. + pub fn pascal(&self) -> String { + let mut out = String::with_capacity(self.words_len()); + for word in &self.words { + push_upper_first(&mut out, word); + } + out + } + + /// Returns the name in snake_case: `pet_store`. + pub fn snake(&self) -> String { + self.words.join("_") + } + + /// Returns the name in kebab-case: `pet-store`. + pub fn kebab(&self) -> String { + self.words.join("-") + } + + /// Returns the length in bytes of the words without separators: the + /// exact length of the camelCase and PascalCase spellings, unless + /// upper-casing a letter changes its length, as `ʼn` to `ʼN` does. + fn words_len(&self) -> usize { + self.words.iter().map(String::len).sum() + } +} + +/// Ends the word being read, if there is one, at byte `end` of `s`, and +/// adds it to `words` in lower case. +fn flush(words: &mut Vec, s: &str, word: &mut Option<(usize, char)>, end: usize) { + if let Some((start, _)) = word.take() { + words.push(s[start..end].to_lowercase()); + } +} + +fn push_upper_first(out: &mut String, word: &str) { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn casings() { + for (written, camel, pascal, snake, kebab) in [ + ("pet-store", "petStore", "PetStore", "pet_store", "pet-store"), + ("created_at", "createdAt", "CreatedAt", "created_at", "created-at"), + ( + "XMLDocument", + "xmlDocument", + "XmlDocument", + "xml_document", + "xml-document", + ), + ("GraphQL", "graphQl", "GraphQl", "graph_ql", "graph-ql"), + ("ipv4_cidr", "ipv4Cidr", "Ipv4Cidr", "ipv4_cidr", "ipv4-cidr"), + ("getByID", "getById", "GetById", "get_by_id", "get-by-id"), + ("name.eq", "nameEq", "NameEq", "name_eq", "name-eq"), + ("2fa", "2fa", "2fa", "2fa", "2fa"), + ("__x__", "x", "X", "x", "x"), + ] { + let name = Name::new(written); + assert_eq!( + [name.camel(), name.pascal(), name.snake(), name.kebab()], + [camel, pascal, snake, kebab], + "casings of {written:?}" + ); + } + } + + #[test] + fn words() { + assert_eq!(Name::new("XMLDocument").words(), ["xml", "document"]); + assert_eq!(Name::new("getByID").words(), ["get", "by", "id"]); + } + + #[test] + fn equal_when_the_words_are() { + for (a, b) in [ + ("get_by_id", "getByID"), + ("get-by-id", "GetById"), + ("pet.store", "PET_STORE"), + ("__x__", "x"), + ] { + assert_eq!(Name::new(a), Name::new(b), "{a:?} and {b:?}"); + } + assert_ne!(Name::new("ipv4"), Name::new("ip_v4")); + assert_ne!(Name::new("getby"), Name::new("getBy")); + } + + #[test] + fn ordered_by_words() { + let mut names = ["pet_store", "Pet", "apiKey", "API"].map(Name::new); + names.sort(); + assert_eq!(names.map(|n| n.snake()), ["api", "api_key", "pet", "pet_store"]); + } + + #[test] + fn empty() { + assert!(Name::new("").is_empty()); + assert!(Name::new("-").is_empty()); + assert_eq!(Name::new("-"), Name::new("")); + assert_eq!(Name::default(), Name::new("")); + assert!(!Name::new("x").is_empty()); + } +} diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 000000000..913d30072 --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,129 @@ +use std::borrow::Cow; +use std::collections::HashSet; +use std::fs; +use std::path::{Component, Path}; + +use rayon::prelude::*; + +use crate::{Forge, Group}; + +pub mod ts; +#[cfg(feature = "wasm")] +pub mod wasm; + +/// The error that plugins, files, [`run`] and [`write()`] return. +pub type Err = Box; + +/// A plugin that generates files. +pub trait Plugin: Sync { + type File: File; // Might be too restrictive, but we can box the file if needed. + + /// Generates files for the given forge. + /// + /// This will be called exactly once. + fn generate(&self, _: &Forge) -> Result, Err> { + Ok(std::iter::empty()) + } + + /// Generates files for the given group. + /// + /// This will be called exactly once for each group and in parallel with other groups. + fn generate_group(&self, _: Group<'_>) -> Result, Err> { + Ok(std::iter::empty()) + } +} + +/// A file generated by a plugin. +pub trait File: Send { + /// Path of the file relative to the output directory, using `/` as a separator. + fn path(&self) -> &str; + /// Content of the file. [`write()`] skips a file with no content. + fn content(&self) -> Result, Err>; +} + +/// Runs `plugin` on `forge` and returns every file it generates. +/// +/// Calls [`Plugin::generate`] once, then [`Plugin::generate_group`] for each +/// group in parallel. The files from `generate` come first, then each +/// group's files in the order of [`Forge::groups`]. +pub fn run(forge: &Forge, plugin: &P) -> Result, Err> { + let mut files: Vec = plugin.generate(forge)?.collect(); + + let groups: Vec> = forge.groups().collect(); + let per_group = groups + .par_iter() + .map(|&group| Ok(plugin.generate_group(group)?.collect::>())) + .collect::, Err>>()?; + + files.extend(per_group.into_iter().flatten()); + Ok(files) +} + +/// Writes `files` into `dir`, creating `dir` and any subdirectories the +/// files need. It skips files with no content, overwrites files that +/// already exist, and leaves everything else in `dir` alone. +/// +/// Before writing anything, it checks that every path is relative, uses `/` +/// as a separator, and has no empty, `.` or `..` parts, and that no two +/// files share a path. +/// +/// It renders every file's content in parallel before writing any of them, +/// so a failing [`File::content`] leaves `dir` untouched. The writes run in +/// parallel too, and a failed write can leave other files already written. +/// If several calls fail, it returns one of their errors. +pub fn write(dir: impl AsRef, files: Vec) -> Result<(), Err> { + let dir = dir.as_ref(); + + let mut paths = HashSet::with_capacity(files.len()); + for file in &files { + let path = file.path(); + check(path)?; + if !paths.insert(path) { + return Err(format!("{path:?} is generated more than once").into()); + } + } + + let rendered = files + .into_par_iter() + .map(|file| { + let content = file.content().map_err(|err| format!("{}: {err}", file.path()))?; + let content = content.into_owned(); + Ok((file, content)) + }) + .filter(|rendered| !matches!(rendered, Ok((_, content)) if content.is_empty())) + .collect::, Err>>()?; + + // Directories are made first, once each, so files can be written in + // any order. + let parents: HashSet<&str> = rendered + .iter() + .filter_map(|(file, _)| file.path().rsplit_once('/').map(|(parent, _)| parent)) + .collect(); + fs::create_dir_all(dir).map_err(|err| format!("{}: {err}", dir.display()))?; + for parent in parents { + fs::create_dir_all(dir.join(parent)).map_err(|err| format!("{parent}: {err}"))?; + } + + rendered.into_par_iter().try_for_each(|(file, content)| { + let path = file.path(); + fs::write(dir.join(path), content).map_err(|err| format!("{path}: {err}"))?; + Ok(()) + }) +} + +/// Checks that `path` is relative, uses `/` as a separator, and has no +/// empty, `.` or `..` parts, so joining it onto a directory can't escape +/// that directory. +fn check(path: &str) -> Result<(), Err> { + // Each part must be a single plain component on this platform, which + // also rules out a `\` separator or a drive prefix on Windows. + let valid = path.split('/').all(|part| { + let mut components = Path::new(part).components(); + matches!(components.next(), Some(Component::Normal(c)) if c == part) && components.next().is_none() + }); + if valid { + Ok(()) + } else { + Err(format!("{path:?} is not a relative path with `/` separators").into()) + } +} diff --git a/src/plugin/ts.rs b/src/plugin/ts.rs new file mode 100644 index 000000000..1447dee62 --- /dev/null +++ b/src/plugin/ts.rs @@ -0,0 +1,248 @@ +//! Generates TypeScript files. You print lines of text and identifiers to +//! a [`File`], and it works out their imports when it renders. +//! +//! ``` +//! use forge::ts::{self, p}; +//! +//! let models = ts::Module::new("pets/models.ts"); +//! let mut f = ts::File::new(ts::Module::new("pets/client.ts")); +//! f.doc("A client of pets."); +//! p!(f, "export interface ", f.ident("Client"), " {"); +//! f.indent(|f| { +//! p!(f, "get(): Promise<", models.ident("Pet").ty(), ">;"); +//! }); +//! p!(f, "}"); +//! ``` + +use std::borrow::Cow; +use std::fmt; + +use crate::{Group, Model}; + +mod file; +mod print; +#[cfg(test)] +mod tests; + +pub use file::{File, PREAMBLE}; +pub use print::{Fragment, Print, join}; + +/// Collects the parts, in order, into a [`Fragment`] to print as part of a +/// line later. Each part must implement [`Print`]. +/// +/// ``` +/// # use forge::ts::{self, frag, p}; +/// let pet = ts::Module::new("models.ts").ident("Pet").ty(); +/// let list = frag!("Promise<", pet, "[]>"); +/// let mut f = ts::File::new(ts::Module::new("a.ts")); +/// p!(f, "list(): ", list, ";"); +/// ``` +#[doc(inline)] +pub use crate::__ts_frag as frag; + +/// Returns the parts with `sep` between each two of them, leaving out the +/// parts that print nothing, as [`join()`] does. The parts can be of +/// different types, as long as each implements [`Print`]. +/// +/// ``` +/// # use forge::ts::{self, frag, join, p}; +/// let codec = frag!(); // nothing to print +/// let entry = frag!("[", join!(", ", "\"a\"", 1, codec), "]"); // ["a", 1] +/// let mut f = ts::File::new(ts::Module::new("a.ts")); +/// p!(f, entry, ","); +/// ``` +#[doc(inline)] +pub use crate::__ts_join as join; + +/// Prints a line to a [`File`]: the file's indentation, each part, then a +/// line break. +/// +/// ``` +/// # use forge::ts::{self, p}; +/// let mut f = ts::File::new(ts::Module::new("a.ts")); +/// let pet = ts::Module::new("models.ts").ident("Pet").ty(); +/// p!(f, "let pet: ", pet, ";"); +/// p!(f); // a blank line +/// ``` +/// +/// Each part must implement [`Print`]. The file prints an [`Ident`] under +/// the name it uses for it, and imports it if it comes from another +/// module. A [`Model`] prints as its identifier, [`Ident::model`]. Anything +/// else prints as its [`Display`](fmt::Display) writes it. +/// If the parts print nothing but empty text, the line is blank, with no +/// indentation. +#[doc(inline)] +pub use crate::__ts_p as p; + +#[doc(hidden)] +#[macro_export] +macro_rules! __ts_p { + ($file:expr $(, $part:expr)* $(,)?) => { + $file.line(&[$(&$part as &dyn $crate::ts::Print),*]) + }; +} + +/// A TypeScript module that files can import: a generated file or a +/// package. +/// +/// A module ending in `.ts` is a generated file, named by its path from the +/// generation root, such as `pets/models.ts`. Other generated files import +/// it by a relative specifier ending in `.js`, such as `./models.js`, which +/// is how TypeScript resolves it. Any other module is a package, such as +/// `zod` or `node:fs`, and files import it by that specifier unchanged. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Module(Cow<'static, str>); + +impl Module { + /// The generation root. Its [`sub`](Module::sub) modules are the + /// top-level files. + pub const ROOT: Module = Module::new(""); + + /// Returns the module at `path`. + pub const fn new(path: &'static str) -> Module { + Module(Cow::Borrowed(path)) + } + + /// Returns the directory that holds `group`'s files, its + /// [`sub`](Module::sub) modules: the group's path, with each name in + /// camelCase and prefixed with `_` if it starts with a digit. Top-level + /// files go in [`Module::ROOT`] instead. + /// + /// A group `list_all` inside `pet-store` gets `petStore/listAll`. + pub fn group(group: Group<'_>) -> Module { + let dirs: Vec = group.path().map(|name| identifier(name.camel())).collect(); + Module::from(dirs.join("/")) + } + + /// Returns the path or specifier of the module. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the identifier called `name` declared in this module. + pub fn ident(&self, name: impl Into>) -> Ident { + Ident { + name: name.into(), + module: self.clone(), + type_only: false, + alias: None, + } + } + + /// Returns the module `name` inside the directory `self`. + /// + /// ``` + /// # use forge::ts::Module; + /// assert_eq!(Module::new("pets").sub("models.ts"), Module::new("pets/models.ts")); + /// assert_eq!(Module::ROOT.sub("models.ts"), Module::new("models.ts")); + /// ``` + pub fn sub(&self, name: &str) -> Module { + if self.0.is_empty() { + Module::from(name.to_owned()) + } else { + Module::from(format!("{}/{name}", self.0)) + } + } + + /// Reports whether the module is a generated file. + fn generated(&self) -> bool { + self.0.ends_with(".ts") + } +} + +impl From<&'static str> for Module { + fn from(path: &'static str) -> Module { + Module::new(path) + } +} + +impl From for Module { + fn from(path: String) -> Module { + Module(Cow::Owned(path)) + } +} + +impl fmt::Display for Module { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// A TypeScript identifier: a name and the module that declares it. +/// +/// A file that prints an identifier from another module imports it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Ident { + pub name: Cow<'static, str>, + pub module: Module, + /// Whether the identifier is only a type. Files import types with + /// `import type`, or with `type` in front of the name when the same + /// declaration also imports values. + pub type_only: bool, + /// The name a file imports the identifier under, as in + /// `import { Name as Alias }`, and uses for it. If the alias is already + /// taken in the file, it still gets `$` appended. A file ignores the + /// alias of its own identifiers, since it doesn't import them. + pub alias: Option>, +} + +impl Ident { + /// Returns the identifier of `model`: its name in PascalCase, prefixed + /// with `_` if it starts with a digit, declared in the `models.ts` of + /// its group's directory ([`Module::group`]), or of the root for a + /// top-level model. Call [`ty`](Ident::ty) on it to import it with + /// `import type`. + /// + /// A generator that names models differently, such as with a prefix, + /// changes the identifier and prints that one everywhere it refers to + /// the model. + pub fn model(model: Model<'_>) -> Ident { + let dir = model.group().map_or(Module::ROOT, Module::group); + dir.sub("models.ts").ident(identifier(model.name().pascal())) + } + + /// Returns the identifier marked as only a type, so files import it + /// with `import type`. + pub fn ty(mut self) -> Ident { + self.type_only = true; + self + } + + /// Returns the identifier imported under `alias`. + pub fn alias(mut self, alias: impl Into>) -> Ident { + self.alias = Some(alias.into()); + self + } +} + +/// Writes the name unchanged, such as `Temporal.Instant`. A file can print +/// the identifier under an alias instead. +impl fmt::Display for Ident { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.name) + } +} + +/// Appends the identifier itself, which the file resolves when it renders. +impl Print for Ident { + fn print(&self, out: &mut Fragment) { + out.ident(self.clone()); + } +} + +/// Appends the model's identifier, as [`Ident::model`] returns it. +impl Print for Model<'_> { + fn print(&self, out: &mut Fragment) { + out.ident(Ident::model(*self)); + } +} + +/// Prefixes `s` with `_` if it starts with a digit, so it's a valid +/// identifier. +fn identifier(s: String) -> String { + if s.starts_with(|c: char| c.is_ascii_digit()) { + format!("_{s}") + } else { + s + } +} diff --git a/src/plugin/ts/file.rs b/src/plugin/ts/file.rs new file mode 100644 index 000000000..39ca29d96 --- /dev/null +++ b/src/plugin/ts/file.rs @@ -0,0 +1,364 @@ +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +use super::print::{Element, Print}; +use super::{Fragment, Ident, Module}; +use crate::plugin::{self, Err}; + +/// One level of indentation. +const INDENT: &str = " "; + +/// The number of columns comments and imports are wrapped at. +const WIDTH: usize = 80; + +/// The first lines of every file. They mark it as generated and turn off +/// ESLint. +pub const PREAMBLE: &str = "// Code generated by forge. DO NOT EDIT.\n/* eslint-disable */\n\n"; + +/// A TypeScript file: its module and the lines printed to it. +/// +/// Print lines with [`p!`](super::p) and [`doc`](File::doc). The file +/// renders them only once it knows every import, so it can give +/// conflicting names aliases. +#[derive(Clone, Debug)] +pub struct File { + module: Module, + body: Fragment, + /// How many levels to indent the lines printed next. + depth: usize, +} + +impl File { + /// Returns an empty file at `module`. + pub fn new(module: Module) -> File { + File { + module, + body: Fragment::new(), + depth: 0, + } + } + + /// Returns the module of the file. + pub fn module(&self) -> &Module { + &self.module + } + + /// Returns an identifier declared in this file. + pub fn ident(&self, name: impl Into>) -> Ident { + self.module.ident(name) + } + + /// Prints a line: the file's indentation, each part, then a line break. + /// If the parts print nothing but empty text, the line is blank, with no + /// indentation. [`p!`](super::p) calls this. + /// + /// Only the start of the line is indented, so parts shouldn't contain + /// line breaks. + pub fn line(&mut self, parts: &[&dyn Print]) { + let line = Fragment::of(parts); + if !line.is_empty() { + self.body.text(&INDENT.repeat(self.depth)); + self.body.append(line); + } + self.body.text("\n"); + } + + /// Calls `f` with the file indented one more level, so the lines and + /// comments it prints get two more spaces. + pub fn indent(&mut self, f: impl FnOnce(&mut File)) { + self.depth += 1; + f(self); + self.depth -= 1; + } + + /// Prints `text` as a JSDoc comment at the file's indentation. It trims + /// the text, prints each line after ` * `, and wraps words to fit in 80 + /// columns, giving a word longer than a line a line of its own. It + /// escapes `*/` so the text can't end the comment early, and prints + /// nothing for empty text. + /// + /// ```text + /// /** + /// * Returns a pet. + /// * + /// * It is looked up by its ID. + /// */ + /// ``` + pub fn doc(&mut self, text: &str) { + let body = text.replace("*/", "*\\/"); + let body = body.trim(); + if body.is_empty() { + return; + } + let tab = INDENT.repeat(self.depth); + let prefix = format!("{tab} * "); + self.body.text(&format!("{tab}/**\n")); + for line in body.replace("\r\n", "\n").split('\n') { + for wrapped in wrap(&prefix, line) { + self.body.text(&wrapped); + self.body.text("\n"); + } + } + self.body.text(&format!("{tab} */\n")); + } + + /// Returns the content of the file as text, as + /// [`content`](plugin::File::content) describes it. + pub(super) fn render(&self) -> String { + if self.body.is_empty() { + return String::new(); + } + let imports = self.imports(); + let aliases = self.aliases(&imports); + let mut b = String::from(PREAMBLE); + let declarations = self.declarations(&imports, &aliases); + if !declarations.is_empty() { + b.push_str(&declarations); + b.push('\n'); + } + for element in self.body.elements() { + match element { + Element::Text(text) => b.push_str(text), + Element::Ident(id) => b.push_str(&self.name(id, &aliases)), + } + } + let len = b.trim_end_matches('\n').len(); + b.truncate(len); + b.push('\n'); + b + } + + /// Reports whether `id` is declared in this file or in no module, so + /// the file doesn't import it. + fn is_local(&self, id: &Ident) -> bool { + id.module == self.module || id.module.as_str().is_empty() + } + + /// Returns the identifiers the file imports, sorted with packages first, + /// then generated files, each by specifier, then by name and alias. + /// Each identifier is reduced to the name it's imported by, such as + /// `Temporal` for `Temporal.Instant`, with `type_only` cleared. + /// `Imports::values` records which ones are used as values. + fn imports(&self) -> Imports { + let mut values = HashSet::new(); + let mut types = HashSet::new(); + for element in self.body.elements() { + let Element::Ident(id) = element else { continue }; + if self.is_local(id) { + continue; + } + let key = import_key(id); + if id.type_only { + types.insert(key); + } else { + values.insert(key); + } + } + let mut sorted: Vec = values.iter().cloned().collect(); + sorted.extend(types.into_iter().filter(|id| !values.contains(id))); + sorted.sort_by_cached_key(|id| { + ( + rank(&id.module), + self.specifier(&id.module), + id.name.clone(), + id.alias.clone(), + ) + }); + Imports { sorted, values } + } + + /// Picks the name the file uses for each imported identifier: its alias + /// if it has one, otherwise its own name. It appends `$` until the name + /// clashes with neither a name the file declares nor an earlier import, + /// going through the imports in sorted order. + fn aliases(&self, imports: &Imports) -> HashMap { + let mut taken: HashSet = HashSet::new(); + for element in self.body.elements() { + if let Element::Ident(id) = element + && self.is_local(id) + { + taken.insert(root(&id.name).to_owned()); + } + } + let mut aliases = HashMap::with_capacity(imports.sorted.len()); + for id in &imports.sorted { + let mut name = id.alias.as_deref().unwrap_or(&id.name).to_owned(); + while taken.contains(&name) { + name.push('$'); + } + taken.insert(name.clone()); + aliases.insert(id.clone(), name); + } + aliases + } + + /// Returns the name the file refers to `id` by. + fn name<'a>(&self, id: &'a Ident, aliases: &HashMap) -> Cow<'a, str> { + if self.is_local(id) { + return Cow::Borrowed(&id.name); + } + let name = &aliases[&import_key(id)]; + match id.name.split_once('.') { + Some((_, rest)) => Cow::Owned(format!("{name}.{rest}")), + None => Cow::Owned(name.clone()), + } + } + + /// Returns the import declarations of the file. + fn declarations(&self, imports: &Imports, aliases: &HashMap) -> String { + let mut b = String::new(); + let mut previous: Option<&Module> = None; + for group in imports.sorted.chunk_by(|a, b| a.module == b.module) { + let module = &group[0].module; + if previous.is_some_and(|previous| rank(previous) != rank(module)) { + b.push('\n'); + } + previous = Some(module); + let all_types = group.iter().all(|id| !imports.values.contains(id)); + let names: Vec = group + .iter() + .map(|id| { + let mut name = id.name.to_string(); + let alias = &aliases[id]; + if *alias != name { + name = format!("{name} as {alias}"); + } + if !all_types && !imports.values.contains(id) { + name = format!("type {name}"); + } + name + }) + .collect(); + let head = if all_types { "import type " } else { "import " }; + let tail = format!(" from \"{}\";", self.specifier(module)); + write_import(&mut b, head, &names, &tail); + } + b + } + + /// Returns how the file imports `module`: a package by its specifier, + /// and a generated file by its path relative to this file, ending with + /// `.js`. + fn specifier(&self, module: &Module) -> String { + if !module.generated() { + return module.as_str().to_owned(); + } + let from: Vec<&str> = match self.module.as_str().rsplit_once('/') { + Some((dir, _)) => dir.split('/').collect(), + None => Vec::new(), + }; + let to: Vec<&str> = module.as_str().split('/').collect(); + let mut shared = 0; + while shared < from.len() && shared < to.len() - 1 && from[shared] == to[shared] { + shared += 1; + } + let mut parts = vec![".."; from.len() - shared]; + parts.extend(&to[shared..]); + let joined = parts.join("/"); + let spec = format!("{}.js", joined.strip_suffix(".ts").unwrap_or(&joined)); + if spec.starts_with("../") { + spec + } else { + format!("./{spec}") + } + } +} + +impl plugin::File for File { + fn path(&self) -> &str { + self.module.as_str() + } + + /// Returns the preamble, the imports, then the lines printed to the + /// file, or nothing if nothing was printed. It never fails. + /// + /// The imports list packages first, then generated files, each sorted by + /// specifier, with one declaration per module. A module whose names are + /// all only types is imported with `import type`, and a name used both + /// as a type and as a value is imported as a value. If an imported name + /// clashes with one the file declares or an earlier import, it gets an + /// alias with `$` appended. + fn content(&self) -> Result, Err> { + Ok(Cow::Owned(self.render().into_bytes())) + } +} + +/// The identifiers a file imports. +struct Imports { + /// Each imported identifier once, sorted. + sorted: Vec, + /// The identifiers used as values, and not only as types. + values: HashSet, +} + +/// Returns the key `id` is imported under: the root of its name, with +/// `type_only` cleared. +fn import_key(id: &Ident) -> Ident { + Ident { + name: Cow::Owned(root(&id.name).to_owned()), + module: id.module.clone(), + type_only: false, + alias: id.alias.clone(), + } +} + +/// Returns the name a dotted name such as `Temporal.Instant` is imported +/// by. +fn root(name: &str) -> &str { + name.split_once('.').map_or(name, |(root, _)| root) +} + +/// Puts packages before generated files. +fn rank(module: &Module) -> u8 { + u8::from(module.generated()) +} + +/// Writes an import declaration: on one line when it fits in 80 columns or +/// has one name, and with a name on each line otherwise. +fn write_import(b: &mut String, head: &str, names: &[String], tail: &str) { + let line = format!("{head}{{ {} }}{tail}", names.join(", ")); + if names.len() == 1 || line.len() <= WIDTH { + b.push_str(&line); + b.push('\n'); + return; + } + b.push_str(head); + b.push_str("{\n"); + for name in names { + b.push_str(INDENT); + b.push_str(name); + b.push_str(",\n"); + } + b.push('}'); + b.push_str(tail); + b.push('\n'); +} + +/// Returns `line` after `prefix`, with words wrapped to fit in `WIDTH` +/// columns. Leading spaces, such as a list item's indent, are repeated on +/// each wrapped line. +fn wrap(prefix: &str, line: &str) -> Vec { + let line = line.trim_end_matches([' ', '\t']); + let lead = &line[..line.len() - line.trim_start_matches([' ', '\t']).len()]; + let mut words = line.split_whitespace(); + let Some(first) = words.next() else { + return vec![prefix.trim_end_matches(' ').to_owned()]; + }; + let mut lines = Vec::new(); + let mut current = format!("{prefix}{lead}{first}"); + let mut columns = current.chars().count(); + for word in words { + let width = word.chars().count(); + if columns + 1 + width <= WIDTH { + current.push(' '); + current.push_str(word); + columns += 1 + width; + continue; + } + lines.push(current); + current = format!("{prefix}{lead}{word}"); + columns = current.chars().count(); + } + lines.push(current); + lines +} diff --git a/src/plugin/ts/print.rs b/src/plugin/ts/print.rs new file mode 100644 index 000000000..661982ece --- /dev/null +++ b/src/plugin/ts/print.rs @@ -0,0 +1,205 @@ +use std::fmt::{self, Display}; + +use super::Ident; + +/// A part of a line of TypeScript: text, an [`Ident`], or a sequence of +/// them. +/// +/// By default, [`print`](Print::print) appends the text that [`Display`] +/// writes, so a `Display` type only needs an empty impl: +/// +/// ``` +/// use std::fmt; +/// use forge::ts::Print; +/// +/// struct Version(u32); +/// +/// impl fmt::Display for Version { +/// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +/// write!(f, "v{}", self.0) +/// } +/// } +/// +/// impl Print for Version {} +/// ``` +pub trait Print: Display { + /// Appends `self` to `out`. + fn print(&self, out: &mut Fragment) { + out.text(&self.to_string()); + } +} + +/// A sequence of text and identifiers to print as part of a line. A file +/// imports the identifiers when it prints the fragment. +/// +/// Build one with [`frag!`](super::frag), [`join()`] or +/// [`push`](Fragment::push) when you need part of a line before printing +/// it. +#[derive(Clone, Debug, Default)] +pub struct Fragment { + elements: Vec, +} + +/// An element of a [`Fragment`]. Adjacent text is always joined into one +/// element, and empty text is never kept. +#[derive(Clone, Debug)] +pub(super) enum Element { + Text(String), + Ident(Ident), +} + +impl Fragment { + /// Returns an empty fragment. + pub fn new() -> Fragment { + Fragment::default() + } + + /// Returns `parts` printed in order, as [`frag!`](super::frag) does. + pub fn of(parts: &[&dyn Print]) -> Fragment { + let mut out = Fragment::new(); + for part in parts { + part.print(&mut out); + } + out + } + + /// Appends `part`. + pub fn push(&mut self, part: impl Print) -> &mut Fragment { + part.print(self); + self + } + + /// Appends text. + pub fn text(&mut self, s: &str) { + if s.is_empty() { + return; + } + match self.elements.last_mut() { + Some(Element::Text(last)) => last.push_str(s), + _ => self.elements.push(Element::Text(s.to_owned())), + } + } + + /// Appends an identifier. + pub fn ident(&mut self, id: Ident) { + self.elements.push(Element::Ident(id)); + } + + /// Reports whether nothing has been appended, other than empty text. + pub fn is_empty(&self) -> bool { + self.elements.is_empty() + } + + /// Appends the elements of `other`, joining text. + pub(super) fn append(&mut self, other: Fragment) { + let mut elements = other.elements.into_iter(); + if let Some(first) = elements.next() { + match first { + Element::Text(text) => self.text(&text), + ident => self.elements.push(ident), + } + } + self.elements.extend(elements); + } + + pub(super) fn elements(&self) -> &[Element] { + &self.elements + } +} + +/// Writes the text and each identifier's name unchanged, without the +/// aliases a file might use. +impl Display for Fragment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for element in &self.elements { + match element { + Element::Text(text) => f.write_str(text)?, + Element::Ident(id) => id.fmt(f)?, + } + } + Ok(()) + } +} + +impl Print for Fragment { + fn print(&self, out: &mut Fragment) { + for element in &self.elements { + match element { + Element::Text(text) => out.text(text), + Element::Ident(id) => out.ident(id.clone()), + } + } + } +} + +/// Returns `parts` with `sep` between each two of them, leaving out the +/// parts that print nothing. [`join!`](super::join!) does the same for +/// parts of different types. +/// +/// ``` +/// # use forge::ts::{self, join, p}; +/// let names = ["a", "", "c"]; +/// let mut f = ts::File::new(ts::Module::new("x.ts")); +/// p!(f, "const all = [", join(", ", names), "];"); // const all = [a, c]; +/// ``` +pub fn join(sep: &str, parts: impl IntoIterator) -> Fragment { + let mut out = Fragment::new(); + for part in parts { + let part = Fragment::of(&[&part]); + if part.is_empty() { + continue; + } + if !out.is_empty() { + out.text(sep); + } + out.append(part); + } + out +} + +impl Print for str { + fn print(&self, out: &mut Fragment) { + out.text(self); + } +} + +impl Print for String { + fn print(&self, out: &mut Fragment) { + out.text(self); + } +} + +impl Print for &T { + fn print(&self, out: &mut Fragment) { + (**self).print(out); + } +} + +macro_rules! print_display { + ($($ty:ty),*) => {$( + impl Print for $ty {} + )*}; +} + +print_display!( + char, bool, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64 +); + +#[doc(hidden)] +#[macro_export] +macro_rules! __ts_join { + ($sep:expr $(,)?) => { + $crate::ts::Fragment::new() + }; + ($sep:expr $(, $part:expr)+ $(,)?) => { + $crate::ts::join($sep, [$(&$part as &dyn $crate::ts::Print),+]) + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __ts_frag { + ($($part:expr),* $(,)?) => { + $crate::ts::Fragment::of(&[$(&$part as &dyn $crate::ts::Print),*]) + }; +} diff --git a/src/plugin/ts/tests.rs b/src/plugin/ts/tests.rs new file mode 100644 index 000000000..f6c095bde --- /dev/null +++ b/src/plugin/ts/tests.rs @@ -0,0 +1,430 @@ +use super::*; +use crate::{Forge, Name}; + +/// `ts::File` implements `forge::File`. +const _: fn() = || { + fn is_file() {} + is_file::(); +}; + +/// Returns the content of `f` without the preamble, and panics if the +/// preamble is missing. +#[track_caller] +fn content(f: &File) -> String { + let rendered = f.render(); + match rendered.strip_prefix(PREAMBLE) { + Some(rest) => rest.to_owned(), + None => panic!("{} does not start with the preamble:\n{rendered}", f.module()), + } +} + +fn lines(lines: &[&str]) -> String { + lines.join("\n") + "\n" +} + +#[test] +fn file() { + let models = Module::new("pets/models.ts"); + let runtime = Module::new("_/index.ts"); + let mut f = File::new(Module::new("pets/client.ts")); + assert_eq!(crate::File::path(&f), "pets/client.ts"); + + f.doc("A client of pets."); + p!(f, "export interface ", f.ident("Client"), " {"); + p!(f, " get(): Promise<", models.ident("Pet").ty(), ">;"); + p!(f, " list(): ", frag!("Promise<", models.ident("Pet").ty(), "[]>"), ";"); + p!(f, "}"); + p!(f); + p!(f, "const codec = ", models.ident("PetCodec"), ";"); + p!(f, "const transport = new ", runtime.ident("Transport"), "();"); + p!( + f, + "const read = ", + Module::new("node:fs/promises").ident("readFile"), + ";" + ); + p!(f, "type S = ", Module::new("@scope/schema").ident("Schema").ty(), ";"); + // A name used as both a value and a type is imported as a value. + p!(f, "type T = ", runtime.ident("Transport").ty(), ";"); + p!( + f, + "let now: ", + Module::new("temporal").ident("Temporal.Instant").ty(), + ";" + ); + + assert_eq!( + content(&f), + lines(&[ + r#"import type { Schema } from "@scope/schema";"#, + r#"import { readFile } from "node:fs/promises";"#, + r#"import type { Temporal } from "temporal";"#, + "", + // Generated files are imported with .js. + r#"import { Transport } from "../_/index.js";"#, + r#"import { type Pet, PetCodec } from "./models.js";"#, + "", + "/**", + " * A client of pets.", + " */", + "export interface Client {", + " get(): Promise;", + " list(): Promise;", + "}", + "", + "const codec = PetCodec;", + "const transport = new Transport();", + "const read = readFile;", + "type S = Schema;", + "type T = Transport;", + "let now: Temporal.Instant;", + ]) + ); +} + +#[test] +fn aliases() { + let mut f = File::new(Module::new("a/b.ts")); + // The file's own `Pet` keeps the name, and each later `Pet` gets another + // `$`. + p!(f, "interface ", f.ident("Pet"), " {}"); + p!(f, "let x: ", Module::new("c.ts").ident("Pet").ty(), ";"); + p!(f, "let y: ", Module::new("d.ts").ident("Pet").ty(), ";"); + p!(f, "let z: ", Module::new("d.ts").ident("Owner").ty(), ";"); + assert_eq!( + content(&f), + lines(&[ + r#"import type { Pet as Pet$ } from "../c.js";"#, + r#"import type { Owner, Pet as Pet$$ } from "../d.js";"#, + "", + "interface Pet {}", + "let x: Pet$;", + "let y: Pet$$;", + "let z: Owner;", + ]) + ); +} + +#[test] +fn ident_helpers() { + let pet = Module::new("pets/models.ts").ident("Pet"); + let plain = Ident { + name: "Pet".into(), + module: "pets/models.ts".into(), + type_only: false, + alias: None, + }; + assert_eq!( + pet.clone().alias("PetModel"), + Ident { + alias: Some("PetModel".into()), + ..plain.clone() + } + ); + assert_eq!( + pet.clone().ty(), + Ident { + type_only: true, + ..plain.clone() + } + ); + assert_eq!( + pet.clone().ty().alias("PetModel"), + Ident { + type_only: true, + alias: Some("PetModel".into()), + ..plain.clone() + } + ); + assert_eq!(pet, plain); + + let mut f = File::new(Module::new("pets/client.ts")); + p!(f, "let a: ", pet.ty(), ";"); + assert_eq!( + content(&f), + lines(&[r#"import type { Pet } from "./models.js";"#, "", "let a: Pet;"]) + ); +} + +#[test] +fn custom_aliases() { + let mut f = File::new(Module::new("pets/client.ts")); + let models = Module::new("pets/models.ts"); + p!(f, "interface ", f.ident("Client").alias("Ignored"), " {}"); + p!(f, "let a: ", models.ident("Pet").ty().alias("PetModel"), ";"); + // The same name can be imported under several aliases. + p!(f, "let b: ", models.ident("Pet").ty().alias("Animal"), ";"); + p!(f, "let c: ", models.ident("Pet").ty(), ";"); + // An alias that is taken still gets "$" added. + p!( + f, + "let d: ", + Module::new("zod").ident("z").alias("Client"), + ".string();" + ); + p!( + f, + "let e: ", + Module::new("temporal").ident("Temporal.Instant").ty().alias("T"), + ";" + ); + assert_eq!( + content(&f), + lines(&[ + r#"import type { Temporal as T } from "temporal";"#, + r#"import { z as Client$ } from "zod";"#, + "", + r#"import type { Pet, Pet as Animal, Pet as PetModel } from "./models.js";"#, + "", + "interface Client {}", + "let a: PetModel;", + "let b: Animal;", + "let c: Pet;", + "let d: Client$.string();", + "let e: T.Instant;", + ]) + ); +} + +#[test] +fn doc() { + let mut f = File::new(Module::new("a.ts")); + f.doc("Returns a pet.\n\nIt is looked up by its ID, and */ is escaped.\n - an indented item\n"); + p!(f, "function get(): void {}"); + p!(f, "interface Client {"); + f.indent(|f| { + f.doc(" Only one line. "); + p!(f, "list(): void;"); + f.doc("Is the second."); + p!(f, "get(): void;"); + }); + p!(f, "}"); + // A comment with nothing in it is not printed. + f.indent(|f| f.indent(|f| f.doc(" \n "))); + assert_eq!( + content(&f), + lines(&[ + "/**", + " * Returns a pet.", + " *", + r" * It is looked up by its ID, and *\/ is escaped.", + " * - an indented item", + " */", + "function get(): void {}", + "interface Client {", + " /**", + " * Only one line.", + " */", + " list(): void;", + " /**", + " * Is the second.", + " */", + " get(): void;", + "}", + ]) + ); +} + +#[test] +fn doc_wraps() { + let mut f = File::new(Module::new("a.ts")); + f.indent(|f| { + f.indent(|f| { + f.doc(concat!( + "The URL the paths of the API are relative to, which defaults to the first server of the description.\n", + " - an item of a list whose words are wrapped under it when they do not fit on the line\n", + "See https://developers.cloudflare.com/fundamentals/api/reference/a-very-long-path-that-does-not-fit/ for more.", + )) + }) + }); + let got = content(&f); + assert_eq!( + got, + lines(&[ + " /**", + " * The URL the paths of the API are relative to, which defaults to the first", + " * server of the description.", + " * - an item of a list whose words are wrapped under it when they do not", + " * fit on the line", + " * See", + " * https://developers.cloudflare.com/fundamentals/api/reference/a-very-long-path-that-does-not-fit/", + " * for more.", + " */", + ]) + ); + for line in got.lines().filter(|line| !line.contains("https://")) { + assert!(line.len() <= 80, "{line:?} is longer than 80 columns"); + } +} + +#[test] +fn indent() { + let mut f = File::new(Module::new("a.ts")); + p!(f, "a {"); + f.indent(|f| { + p!(f, "b {"); + f.indent(|f| { + p!(f, "c;"); + // A blank line has no indentation. + p!(f); + p!(f, "", ""); + }); + p!(f, "}"); + }); + p!(f, "}"); + assert_eq!(content(&f), lines(&["a {", " b {", " c;", "", "", " }", "}"])); +} + +#[test] +fn imports_break() { + let mut f = File::new(Module::new("a.ts")); + let m = Module::new("some/long/module/path.ts"); + for name in ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf"] { + p!(f, "let ", name.to_lowercase(), " = ", m.ident(name), ";"); + } + assert!(content(&f).starts_with(&lines(&[ + "import {", + " Alpha,", + " Bravo,", + " Charlie,", + " Delta,", + " Echo,", + " Foxtrot,", + " Golf,", + r#"} from "./some/long/module/path.js";"#, + ]))); + + let empty = File::new(Module::new("empty.ts")); + assert_eq!(empty.render(), ""); +} + +/// A type a generator prints by its `Display`, with an empty impl. +struct Version(u32); + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "v{}", self.0) + } +} + +impl Print for Version {} + +#[test] +fn parts() { + let models = Module::new("models.ts"); + let pet = models.ident("Pet").ty(); + let mut f = File::new(Module::new("a.ts")); + // A reference to a part prints the part. + p!(f, "let a: ", &pet, ";"); + // Numbers, characters, booleans and joins print by their Display. + p!(f, "let b = [", join(", ", [1, 2, 3]), "]", ';'); + p!(f, "let c = ", 1.5, ", d = ", true, ";"); + // So does a generator's own type that implements Print with no body. + p!(f, "const version = \"", Version(2), "\";"); + // A fragment made before the file carries its imports. + let owner = frag!(models.ident("Owner"), " | null"); + p!(f, "let e: ", owner, ";"); + // A join of identifiers imports each. + let ids = join(" | ", [models.ident("A"), models.ident("B").ty()]); + p!(f, "type F = ", ids, ";"); + assert_eq!( + content(&f), + lines(&[ + r#"import { A, type B, Owner, type Pet } from "./models.js";"#, + "", + "let a: Pet;", + "let b = [1, 2, 3];", + "let c = 1.5, d = true;", + r#"const version = "v2";"#, + "let e: Owner | null;", + "type F = A | B;", + ]) + ); +} + +#[test] +fn joins_leave_out_empty_parts() { + assert_eq!(join(", ", ["a", "", "b", ""]).to_string(), "a, b"); + assert_eq!(join(", ", ["", ""]).to_string(), ""); + // join! takes parts of different types. + let pet = Module::new("models.ts").ident("Pet"); + assert_eq!( + frag!("[", join!(", ", 1, "", pet, frag!()), "]").to_string(), + "[1, Pet]" + ); + assert!(join!(", ").is_empty()); +} + +#[test] +fn display() { + // Display writes names unchanged, ignoring aliases. + let pet = Module::new("models.ts").ident("Temporal.Instant").alias("T"); + assert_eq!(pet.to_string(), "Temporal.Instant"); + let list: Fragment = frag!("Promise<", pet, "[]>"); + assert_eq!(list.to_string(), "Promise"); +} + +#[test] +fn models() { + let forge: Forge = serde_json::from_str( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": ["pet-store", "list_all"], "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/pet_owner"}}}}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "pet-store", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "other", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/2fa"}}}}}}} + }, + "components": {"schemas": {"pet_owner": {"type": "string"}, "2fa": {"type": "string"}}} + }"##, + ) + .unwrap(); + let group = |path: &[&str]| { + let names: Vec = path.iter().map(|name| Name::new(name)).collect(); + forge.groups().find(|group| group.path().eq(names.iter())).unwrap() + }; + let model = |schema_name: &str| { + forge + .groups() + .flat_map(|group| group.models()) + .chain(forge.models()) + .find(|model| model.schema_name() == schema_name) + .unwrap() + }; + let (owner, two_fa) = (model("pet_owner"), model("2fa")); + + assert_eq!(Module::ROOT.sub("models.ts"), Module::new("models.ts")); + assert_eq!(Module::group(group(&["pet-store"])), Module::new("petStore")); + assert_eq!( + Module::group(group(&["pet-store", "list_all"])).sub("client.ts"), + Module::new("petStore/listAll/client.ts") + ); + assert_eq!( + Ident::model(owner), + Module::new("petStore/listAll/models.ts").ident("PetOwner") + ); + assert_eq!(Ident::model(two_fa), Module::new("models.ts").ident("_2fa")); + + let mut f = File::new(Module::new("petStore/client.ts")); + // A model prints as its identifier. Display writes its schema name. + assert_eq!(two_fa.to_string(), "2fa"); + p!(f, "let owner: ", owner, ";"); + p!(f, "let factor: ", Ident::model(two_fa).ty(), ";"); + // A generator that names a model differently prints its own identifier. + let mut err_owner = Ident::model(owner).ty(); + err_owner.name = format!("Err{}", err_owner.name).into(); + p!(f, "let failure: ", err_owner, ";"); + assert_eq!( + content(&f), + lines(&[ + r#"import type { _2fa } from "../models.js";"#, + r#"import { type ErrPetOwner, PetOwner } from "./listAll/models.js";"#, + "", + "let owner: PetOwner;", + "let factor: _2fa;", + "let failure: ErrPetOwner;", + ]) + ); +} diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs new file mode 100644 index 000000000..c099138d5 --- /dev/null +++ b/src/plugin/wasm.rs @@ -0,0 +1,791 @@ +//! Runs plugins compiled to WebAssembly components, in any language that +//! targets the component model. +//! +//! A plugin is a component of the `plugin` world in [`WIT`]. It exports +//! `generate` and `generate-group`, which [`WasmPlugin`] calls where +//! [`Plugin::generate`] and [`Plugin::generate_group`] are, and imports +//! the `forge` interface, which reads the [`Forge`] the host runs it on. +//! +//! The forge is never copied into a plugin. It stays in the host, and the +//! plugin reads it a part at a time through ids: a group, method, model or +//! type is a `u32`, and the plugin calls `get-group`, `get-method`, +//! `get-model` or `get-type` for the part's data. Groups run in parallel, +//! each in an instance of its own, and every instance reads the same +//! forge. +//! +//! A plugin gets no files, network, environment or clock, only standard +//! error, so it can print why it panicked. +//! +//! ```no_run +//! # fn main() -> Result<(), forge::Err> { +//! # let forge = forge::Forge::default(); +//! let plugin = forge::wasm::WasmPlugin::from_file("ts.wasm")?; +//! forge::write("sdk", forge::run(&forge, &plugin)?)?; +//! # Ok(()) +//! # } +//! ``` + +use std::borrow::Cow; +use std::path::Path; +use std::ptr; +use std::sync::Mutex; + +use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; +use wasmtime::{Config, Engine, Store}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; + +use crate::arena::Str; +use crate::method::{ContentData, ResponseData}; +use crate::types::{EnumData, FieldData, TypeData}; +use crate::{Availability, Err, File, Forge, Group, HttpMethod, Json, Location, Name, Number, Plugin, Scalar}; + +/// Defines [`WIT`] and its host bindings from one copy of the text. +macro_rules! wit { + ($wit:literal) => { + /// The WIT package of the `plugin` world that plugins are components + /// of. Plugins in other languages generate their bindings from it. + pub const WIT: &str = $wit; + + mod bindings { + wasmtime::component::bindgen!({ + inline: $wit, + world: "plugin", + // A plugin that passes an id the host never gave out traps, + // rather than panicking the host. + imports: { default: trappable }, + }); + } + }; +} + +wit!( + r#" +package forge:plugin@0.2.0; + +/// Read-only access to the forge a plugin runs on, which the host keeps. +/// Each id is an index the host gave out. An id the host never gave out +/// traps. +/// +/// Everything a part has is returned in one call, and orders and meanings +/// are as the Rust API documents them. +interface forge { + type group-id = u32; + type method-id = u32; + type model-id = u32; + type type-id = u32; + + /// The lower-case words of a `Name`. + type name = list; + + /// A JSON value, which forge keeps without reading, as JSON text that + /// `JSON.parse` reads back: an object's members in the order they are + /// written, each key once. WIT has no recursive types, so a value is + /// its text. + type json = string; + + record forge-info { + version: string, + description: string, + base-url: string, + } + + /// How many of each part the forge has. Ids run from 0 to the count. + record forge-counts { + groups: u32, + methods: u32, + models: u32, + types: u32, + } + + record group-info { + name: name, + description: string, + /// The groups from the top level down to this one, ending with it. + path: list, + /// The groups directly inside, sorted by name. + children: list, + /// The methods directly inside, sorted by name. + methods: list, + models: list, + } + + variant http-method { + get, put, post, delete, options, head, patch, trace, query, connect, + other(string), + } + + variant location { + path, + /// Whether the parameter is exploded. + query(bool), + } + + record content-info { + media-type: string, + ty: type-id, + /// The description of the content's schema. + description: string, + } + + record body-info { + required: bool, + description: string, + contents: list, + /// The index in `contents` of the JSON content. + json: option, + } + + record response-info { + status: string, + description: string, + contents: list, + /// The index in `contents` of the JSON content. + json: option, + } + + /// The stage of the API a method is in, from `x-fern-availability`. + enum availability { + alpha, beta, preview, generally-available, deprecated, + } + + record method-info { + name: name, + group: group-id, + http-method: http-method, + path: string, + summary: string, + description: string, + deprecated: bool, + availability: option, + /// Whether to leave the method out of listings, from `x-forge-hidden`. + hidden: bool, + /// The sentence to show before calling the method, from + /// `x-forge-require-confirmation`, or none if it needs no + /// confirmation. + confirmation: option, + request: option, + /// The header parameters: an object type, with a field for each. + /// Its fields have no location. + headers: option, + body: option, + responses: list, + errors: list, + /// The extensions of the operation, or alias, that forge does not + /// read itself, in the order they are written. + extensions: list>, + } + + record model-info { + schema-name: string, + name: name, + group: option, + description: string, + deprecated: bool, + ty: type-id, + } + + enum number { + int, int8, int16, int32, int64, uint8, uint16, uint32, uint64, float, double, + } + + variant scalar { + unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, + } + + variant enum-value { + %string(string), + number(f64), + boolean(bool), + } + + record field-info { + wire-name: string, + name: name, + required: bool, + ty: type-id, + description: string, + deprecated: bool, + /// Whether the server sets the property, so a client doesn't send it. + read-only: bool, + /// Whether the value is a secret, such as a password or a token. + sensitive: bool, + /// The `default` of the property's schema. + default: option, + } + + record array-info { + elem: type-id, + /// The `maxItems`, or none if the array has no limit. + max-items: option, + } + + record object-info { + fields: list, + extra: option, + } + + record discriminator-info { + wire-name: string, + name: name, + cases: list>, + default: option, + } + + record union-info { + members: list, + /// Whether a value is at most one of the members. + exclusive: bool, + discriminator: option, + } + + variant type-info { + scalar(scalar), + %enum(list), + array(array-info), + object(object-info), + union(union-info), + intersection(list), + model(model-id), + } + + info: func() -> forge-info; + counts: func() -> forge-counts; + /// Every group, depth first. + groups: func() -> list; + /// The models at the top level, in no group. + models: func() -> list; + get-group: func(id: group-id) -> group-info; + get-method: func(id: method-id) -> method-info; + get-model: func(id: model-id) -> model-info; + get-type: func(id: type-id) -> type-info; + /// Where method `id` sends field `index` of the object type `object`, + /// or none if it is not one of its parameters. + get-location: func(id: method-id, object: type-id, index: u32) -> option; +} + +world plugin { + import forge; + use forge.{group-id}; + + /// A generated file: its path relative to the output directory, using + /// `/` as a separator, and its content. A file with no content is not + /// written. + record file { + path: string, + content: list, + } + + /// Generates files for the forge. Called exactly once. + export generate: func() -> result, string>; + /// Generates files for a group. Called exactly once for each group, in + /// parallel with other groups, each in an instance of its own. + export generate-group: func(group: group-id) -> result, string>; +} +"# +); + +use bindings::forge::plugin::forge as wit; + +/// A plugin compiled to a WebAssembly component of the `plugin` world in +/// [`WIT`]. +/// +/// It compiles the component once, then keeps a pool of instances, so +/// groups run in parallel without compiling or instantiating it again. A +/// call takes an instance from the pool, or makes one if none is free, and +/// returns it unless the plugin trapped. +pub struct WasmPlugin { + engine: Engine, + pre: bindings::PluginPre, + pool: Mutex>, +} + +/// A file a [`WasmPlugin`] generated, with its content already rendered. +pub struct WasmFile { + pub path: String, + pub content: Vec, +} + +impl WasmPlugin { + /// Compiles the component in the file at `path`. + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let wasm = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + WasmPlugin::new(&wasm).map_err(|err| format!("{}: {err}", path.display()).into()) + } + + /// Compiles the component `wasm`, in the binary format. + pub fn new(wasm: &[u8]) -> Result { + let mut config = Config::new(); + config.wasm_component_model(true); + let engine = Engine::new(&config).map_err(error)?; + let component = Component::new(&engine, wasm).map_err(error)?; + + let mut linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(error)?; + bindings::Plugin::add_to_linker::>(&mut linker, |state| state).map_err(error)?; + let pre = linker.instantiate_pre(&component).map_err(error)?; + let pre = bindings::PluginPre::new(pre).map_err(error)?; + + Ok(WasmPlugin { + engine, + pre, + pool: Mutex::new(Vec::new()), + }) + } + + /// Calls `f` with an instance that reads `forge`. + fn call( + &self, + forge: &Forge, + f: impl FnOnce(&bindings::Plugin, &mut Store) -> wasmtime::Result, String>>, + ) -> Result, Err> { + let pooled = self.pool.lock().unwrap_or_else(|err| err.into_inner()).pop(); + let mut instance = match pooled { + Some(instance) => instance, + None => self.instantiate()?, + }; + + instance.store.data_mut().forge = forge; + let result = f(&instance.plugin, &mut instance.store); + instance.store.data_mut().forge = ptr::null(); + + // A trap can leave the instance broken, so it is dropped instead. + let files = result.map_err(error)?; + self.pool.lock().unwrap_or_else(|err| err.into_inner()).push(instance); + + let files = files?.into_iter().map(|file| WasmFile { + path: file.path, + content: file.content, + }); + Ok(files.collect::>().into_iter()) + } + + fn instantiate(&self) -> Result { + let state = State { + forge: ptr::null(), + wasi: WasiCtx::builder().inherit_stderr().build(), + table: ResourceTable::new(), + }; + let mut store = Store::new(&self.engine, state); + let plugin = self.pre.instantiate(&mut store).map_err(error)?; + Ok(Instance { store, plugin }) + } +} + +impl Plugin for WasmPlugin { + type File = WasmFile; + + fn generate(&self, forge: &Forge) -> Result, Err> { + self.call(forge, |plugin, store| plugin.call_generate(store)) + } + + fn generate_group(&self, group: Group<'_>) -> Result, Err> { + self.call(group.forge, |plugin, store| { + plugin.call_generate_group(store, group.id.0) + }) + } +} + +impl File for WasmFile { + fn path(&self) -> &str { + &self.path + } + + fn content(&self) -> Result, Err> { + Ok(Cow::Borrowed(&self.content)) + } +} + +/// An instance of a plugin, and the store it lives in. +struct Instance { + store: Store, + plugin: bindings::Plugin, +} + +/// What an instance's host functions get. +struct State { + /// The forge of the call running in the instance, or null between + /// calls. + forge: *const Forge, + wasi: WasiCtx, + table: ResourceTable, +} + +// SAFETY: `forge` is only read through `&Forge`, which is `Send` since +// `Forge` is `Sync`. Everything else in `State` is `Send`. +unsafe impl Send for State {} + +impl WasiView for State { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + +impl State { + /// Returns the forge of the call running in the instance. + fn forge(&self) -> wasmtime::Result<&Forge> { + // SAFETY: `WasmPlugin::call` sets `forge` from a `&Forge` that + // outlives the call, and resets it to null before returning. Host + // functions only run during the call, while the plugin runs. + unsafe { self.forge.as_ref() }.ok_or_else(|| wasmtime::format_err!("no forge outside a call")) + } +} + +impl wit::Host for State { + fn info(&mut self) -> wasmtime::Result { + let forge = self.forge()?; + Ok(wit::ForgeInfo { + version: forge.version.clone(), + description: forge.description.clone(), + base_url: forge.base_url.clone(), + }) + } + + fn counts(&mut self) -> wasmtime::Result { + let forge = self.forge()?; + Ok(wit::ForgeCounts { + groups: forge.groups.len() as u32, + methods: forge.methods.len() as u32, + models: forge.models.len() as u32, + types: forge.types.len() as u32, + }) + } + + fn groups(&mut self) -> wasmtime::Result> { + Ok(self.forge()?.groups().map(|group| group.id.0).collect()) + } + + fn models(&mut self) -> wasmtime::Result> { + Ok(self.forge()?.top_models.iter().map(|id| id.0).collect()) + } + + fn get_group(&mut self, id: u32) -> wasmtime::Result { + let forge = self.forge()?; + let data = get(&forge.groups, id, "group")?; + Ok(wit::GroupInfo { + name: words(&data.name), + description: data.description.clone(), + path: data.path.iter().map(|id| id.0).collect(), + children: data.groups.iter().map(|id| id.0).collect(), + methods: data.methods.iter().map(|id| id.0).collect(), + models: data.models.iter().map(|id| id.0).collect(), + }) + } + + fn get_method(&mut self, id: u32) -> wasmtime::Result { + let forge = self.forge()?; + let data = get(&forge.methods, id, "method")?; + let s = |s: Str| forge.strings.get(s).to_owned(); + Ok(wit::MethodInfo { + name: words(&data.name), + group: data.group.0, + http_method: match &data.http_method { + HttpMethod::Get => wit::HttpMethod::Get, + HttpMethod::Put => wit::HttpMethod::Put, + HttpMethod::Post => wit::HttpMethod::Post, + HttpMethod::Delete => wit::HttpMethod::Delete, + HttpMethod::Options => wit::HttpMethod::Options, + HttpMethod::Head => wit::HttpMethod::Head, + HttpMethod::Patch => wit::HttpMethod::Patch, + HttpMethod::Trace => wit::HttpMethod::Trace, + HttpMethod::Query => wit::HttpMethod::Query, + HttpMethod::Connect => wit::HttpMethod::Connect, + HttpMethod::Other(method) => wit::HttpMethod::Other(method.clone()), + }, + path: s(data.path), + summary: s(data.summary), + description: s(data.description), + deprecated: data.deprecated, + availability: data.availability.map(|availability| match availability { + Availability::Alpha => wit::Availability::Alpha, + Availability::Beta => wit::Availability::Beta, + Availability::Preview => wit::Availability::Preview, + Availability::GenerallyAvailable => wit::Availability::GenerallyAvailable, + Availability::Deprecated => wit::Availability::Deprecated, + }), + hidden: data.hidden, + confirmation: data.confirmation.map(s), + request: data.request.map(|id| id.0), + headers: data.headers.map(|id| id.0), + body: data.body.as_ref().map(|body| wit::BodyInfo { + required: body.required, + description: s(body.description), + contents: contents(forge, &body.contents), + json: body.json, + }), + responses: data.responses.iter().map(|data| response(forge, data)).collect(), + errors: data.errors.iter().map(|data| response(forge, data)).collect(), + extensions: forge + .extensions + .get(data.extensions) + .iter() + .map(|(name, value)| (name.clone(), json(value))) + .collect(), + }) + } + + fn get_model(&mut self, id: u32) -> wasmtime::Result { + let forge = self.forge()?; + let data = get(&forge.models, id, "model")?; + Ok(wit::ModelInfo { + schema_name: forge.strings.get(data.schema_name).to_owned(), + name: words(&data.name), + group: data.group.map(|id| id.0), + description: forge.strings.get(data.description).to_owned(), + deprecated: data.deprecated, + ty: data.ty.0, + }) + } + + fn get_type(&mut self, id: u32) -> wasmtime::Result { + let forge = self.forge()?; + let s = |s: Str| forge.strings.get(s).to_owned(); + Ok(match get(&forge.types, id, "type")? { + TypeData::Scalar(scalar) => wit::TypeInfo::Scalar(match *scalar { + Scalar::Unknown => wit::Scalar::Unknown, + Scalar::Null => wit::Scalar::Null, + Scalar::Boolean => wit::Scalar::Boolean, + Scalar::String => wit::Scalar::String, + Scalar::Number(number) => wit::Scalar::Number(match number { + Number::Int => wit::Number::Int, + Number::Int8 => wit::Number::Int8, + Number::Int16 => wit::Number::Int16, + Number::Int32 => wit::Number::Int32, + Number::Int64 => wit::Number::Int64, + Number::Uint8 => wit::Number::Uint8, + Number::Uint16 => wit::Number::Uint16, + Number::Uint32 => wit::Number::Uint32, + Number::Uint64 => wit::Number::Uint64, + Number::Float => wit::Number::Float, + Number::Double => wit::Number::Double, + }), + Scalar::DateTime => wit::Scalar::DateTime, + Scalar::Date => wit::Scalar::Date, + Scalar::Bytes => wit::Scalar::Bytes, + Scalar::Binary => wit::Scalar::Binary, + }), + TypeData::Enum(values) => wit::TypeInfo::Enum( + forge + .values + .get(*values) + .iter() + .map(|value| match *value { + EnumData::String(value) => wit::EnumValue::String(s(value)), + EnumData::Number(value) => wit::EnumValue::Number(value), + EnumData::Boolean(value) => wit::EnumValue::Boolean(value), + }) + .collect(), + ), + TypeData::Array(array) => wit::TypeInfo::Array(wit::ArrayInfo { + elem: array.elem.0, + max_items: array.max_items, + }), + TypeData::Object(object) => wit::TypeInfo::Object(wit::ObjectInfo { + fields: forge + .fields + .get(object.fields) + .iter() + .map(|field| wit::FieldInfo { + wire_name: s(field.wire_name), + name: words(&forge.names[field.name.index()]), + required: field.required, + ty: field.ty.0, + description: s(field.description), + deprecated: field.deprecated, + read_only: field.read_only, + sensitive: field.sensitive, + default: field.default.map(|id| json(&forge.defaults[id.index()])), + }) + .collect(), + extra: object.extra.map(|id| id.0), + }), + TypeData::Union(union) => wit::TypeInfo::Union(wit::UnionInfo { + members: forge.members.get(union.members).iter().map(|id| id.0).collect(), + exclusive: union.exclusive, + discriminator: union.discriminator.as_deref().map(|data| wit::DiscriminatorInfo { + wire_name: s(data.wire_name), + name: words(&data.name), + cases: data.cases.iter().map(|&(value, id)| (s(value), id.0)).collect(), + default: data.default.map(|id| id.0), + }), + }), + TypeData::Intersection(members) => { + wit::TypeInfo::Intersection(forge.members.get(*members).iter().map(|id| id.0).collect()) + } + TypeData::Model(id) => wit::TypeInfo::Model(id.0), + }) + } + + fn get_location(&mut self, id: u32, object: u32, index: u32) -> wasmtime::Result> { + let forge = self.forge()?; + let data = get(&forge.methods, id, "method")?; + let TypeData::Object(object) = get(&forge.types, object, "type")? else { + wasmtime::bail!("type {object} is not an object"); + }; + let field = get(forge.fields.get(object.fields), index, "field")?; + + // As in `Method::location`: the field must be one of the parameters + // themselves, not a field like them. + let Some(TypeData::Object(params)) = data.params.map(|id| &forge.types[id.index()]) else { + return Ok(None); + }; + let params: &[FieldData] = forge.fields.get(params.fields); + let Some(i) = params.iter().position(|param| ptr::eq(param, field)) else { + return Ok(None); + }; + Ok(data.locations.get(i).map(|location| match *location { + Location::Path => wit::Location::Path, + Location::Query { exploded } => wit::Location::Query(exploded), + })) + } +} + +/// Returns item `id` of `items`, or an error naming it as a `what`. +fn get<'a, T>(items: &'a [T], id: u32, what: &str) -> wasmtime::Result<&'a T> { + items + .get(id as usize) + .ok_or_else(|| wasmtime::format_err!("no {what} with id {id}")) +} + +fn words(name: &Name) -> Vec { + name.words().to_vec() +} + +fn response(forge: &Forge, data: &ResponseData) -> wit::ResponseInfo { + wit::ResponseInfo { + status: forge.strings.get(data.status).to_owned(), + description: forge.strings.get(data.description).to_owned(), + contents: contents(forge, &data.contents), + json: data.json, + } +} + +fn contents(forge: &Forge, contents: &[ContentData]) -> Vec { + contents + .iter() + .map(|data| wit::ContentInfo { + media_type: forge.strings.get(data.media_type).to_owned(), + ty: data.ty.0, + description: forge.strings.get(data.description).to_owned(), + }) + .collect() +} + +/// Returns `value` as JSON text, which `JSON.parse` reads back as the same +/// value: an object's members in order, and a number as JavaScript writes +/// it when it is an integer it can hold exactly. +fn json(value: &Json) -> String { + fn write(out: &mut String, value: &Json) { + match value { + Json::Null => out.push_str("null"), + Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + // JSON can't write these, and reading JSON never makes them. + Json::Number(n) if !n.is_finite() => out.push_str("null"), + Json::Number(n) => { + use std::fmt::Write; + let _ = write!(out, "{n}"); + } + Json::String(s) => string(out, s), + Json::Array(elements) => { + out.push('['); + for (i, element) in elements.iter().enumerate() { + if i > 0 { + out.push(','); + } + write(out, element); + } + out.push(']'); + } + Json::Object(members) => { + out.push('{'); + for (i, (key, value)) in members.iter().enumerate() { + if i > 0 { + out.push(','); + } + string(out, key); + out.push(':'); + write(out, value); + } + out.push('}'); + } + } + } + fn string(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + use std::fmt::Write; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + } + let mut out = String::new(); + write(&mut out, value); + out +} + +/// Turns a wasmtime error into an [`Err`], keeping its causes and any +/// backtrace of the plugin. +fn error(err: wasmtime::Error) -> Err { + format!("{err:?}").into() +} + +#[cfg(test)] +mod tests { + use super::json; + use crate::Json; + + /// Reads `text` as forge reads JSON. + fn read(text: &str) -> Json { + serde_json::from_str(text).unwrap() + } + + #[test] + fn json_is_text_that_reads_back_as_the_value() { + for text in [ + "null", + "true", + "false", + "0", + "-0", + "1", + "-12.5", + "1e21", + "0.0000001", + "9007199254740993", + r#""""#, + r#""a \"quoted\" \\ line\nand\ttab\u0001 é 😀""#, + "[]", + "{}", + r#"[1, [2, {"a": null}], "x"]"#, + // Members keep their order, and a repeated key its last value + // in the place of the first, as forge reads them. + r#"{"b": 1, "a": {"z": [], "y": false}, "b": 2}"#, + ] { + let value = read(text); + assert_eq!(read(&json(&value)), value, "{text} wrote {}", json(&value)); + } + } + + #[test] + fn json_is_compact_and_ordered() { + assert_eq!( + json(&read(r#"{ "b" : [1, 2.5], "a": "x" }"#)), + r#"{"b":[1,2.5],"a":"x"}"# + ); + assert_eq!(json(&read("1.0")), "1"); + assert_eq!(json(&read(r#""\u0001""#)), r#""\u0001""#); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 000000000..5255d898a --- /dev/null +++ b/src/types.rs @@ -0,0 +1,676 @@ +//! The types of values, built from schemas. + +use std::fmt; + +use crate::arena::{Id, Span, Str, Strings}; +use crate::{Forge, Json, Model, ModelId, Name}; + +/// The type of a value, built from a schema. +/// +/// A `Type` and the parts it carries are cheap references into the +/// [`Forge`] it came from. +/// +/// A type that allows null, other than [`Scalar::Unknown`], is a +/// [`Union`] with a [`Scalar::Null`] member, however the schema says so: +/// with `nullable: true`, in any version of OpenAPI, a `"null"` type, +/// `const: null`, or `null` among the values of an `enum`. +#[derive(Clone, Copy, Debug)] +pub enum Type<'f> { + /// A type with no parts. + Scalar(Scalar), + /// One of the values of an `enum`, which are all different, in the + /// order it lists them. There is at least one, and none is null: an + /// `enum` that lists `null` is a union of the other values and + /// [`Scalar::Null`], and one that lists only `null` is `Scalar::Null`. + /// + /// A `const` is an enum of one value. An `enum` that lists an object or + /// an array is not covered yet, and is [`Scalar::Unknown`]. + Enum(Enum<'f>), + /// A list of values of one type: the type of `items`, or any value + /// without it. A schema with `items` is an array even without `type`. + /// + /// A tuple, written with `prefixItems`, is not covered yet, and is + /// [`Scalar::Unknown`]. + Array(Array<'f>), + /// An object, with fields, other keys, both or neither. A map is an + /// object with no fields and other keys. A schema with `properties` or + /// `additionalProperties` is an object even without `type`. + Object(Object<'f>), + /// Any of the members: from `anyOf`, `oneOf`, or a `type` that lists + /// more than one type. + /// + /// A member that only constrains the value, such as one with only + /// `required` or `not`, is left out, and so is the whole `anyOf` or + /// `oneOf` when every member is one. A `type` next to `anyOf` or + /// `oneOf` is left out, and `properties` next to them make an + /// [`Intersection`] of their object and the union. + Union(Union<'f>), + /// All of the members: from `allOf`. + /// + /// A member that gives no shape, such as `{}`, `true`, `{type: object}` + /// or one with only a `description`, is left out, and a single member + /// left is the type itself. A member with only `required` makes those + /// fields of the other members required. A model among them becomes an + /// object with a copy of its fields, so the model itself is unchanged. + /// An `allOf` member adds its own members. A `type` or `items` next to + /// `allOf` is left out, and `properties` next to it are one more + /// member: their object. + Intersection(Intersection<'f>), + /// The type of the model, from a `$ref` to its schema. + /// + /// Every `$ref` to a schema in `components.schemas`, or in the `$defs` + /// of one, is a model, whatever the schema is, even a primitive. A + /// generator that wants to write the type in place can use + /// [`Model::ty`]. A `$ref` to anywhere else in the document is written + /// in place, and is an error if it leads back to itself. A `$ref` to + /// another document is an error. + /// + /// In OpenAPI 3.0, the keywords next to a `$ref` are ignored. From 3.1, + /// keywords that only describe, such as `description`, keep the model, + /// `nullable: true` makes a union of the model and [`Scalar::Null`], + /// and any other keyword makes an [`Intersection`] of the model and the + /// rest of the schema. + Model(Model<'f>), +} + +/// A type with no parts: a primitive, or any value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Scalar { + /// Any value: from a schema that does not constrain its type, such as + /// `{}`, `true` or one with only a `description`, from content with no + /// schema, and from the schemas that are not covered yet. + Unknown, + Null, + Boolean, + /// A string, with no format or one not listed here, such as `uuid`. + String, + /// An integer or number, of the size its format gives. + Number(Number), + /// A string with format `date-time`. + DateTime, + /// A string with format `date`. + Date, + /// A string with format `byte`, or with `contentEncoding: base64`: + /// base64-encoded bytes. The value is text, the base64 of the bytes, + /// unlike [`Binary`](Scalar::Binary). + Bytes, + /// A string with format `binary`: raw bytes, such as a file uploaded + /// as a part of `multipart/form-data` or a whole + /// `application/octet-stream` body. The value is the bytes themselves, + /// not base64 text as for [`Bytes`](Scalar::Bytes). + /// + /// Format `binary` decides the type even with `contentEncoding: + /// base64`. + Binary, +} + +/// The size of a [`Scalar::Number`], from the `format` of an `integer` or +/// `number` schema, as defined by the +/// [Format Registry](https://spec.openapis.org/registry/format/). +/// +/// A schema with no format, or one that is not a size, has no size: an +/// `integer` is [`Int`](Number::Int) and a `number` is +/// [`Double`](Number::Double). The format decides the size whatever the +/// type, so a `number` with format `int32` is [`Int32`](Number::Int32). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Number { + /// An `integer` with no format: an integer of no particular size. + Int, + /// Format `int8`: a signed 8-bit integer. + Int8, + /// Format `int16`: a signed 16-bit integer. + Int16, + /// Format `int32`: a signed 32-bit integer. + Int32, + /// Format `int64`: a signed 64-bit integer. + Int64, + /// Format `uint8`: an unsigned 8-bit integer. + Uint8, + /// Format `uint16`: an unsigned 16-bit integer. + Uint16, + /// Format `uint32`: an unsigned 32-bit integer. + Uint32, + /// Format `uint64`: an unsigned 64-bit integer. + Uint64, + /// Format `float`: a single-precision floating-point number. + Float, + /// Format `double`, or a `number` with no format: a double-precision + /// floating-point number. + Double, +} + +impl Number { + /// Reports whether the number is an integer, rather than + /// [`Float`](Number::Float) or [`Double`](Number::Double). + pub fn is_integer(self) -> bool { + !matches!(self, Number::Float | Number::Double) + } +} + +/// The values of a [`Type::Enum`]. +#[derive(Clone, Copy)] +pub struct Enum<'f> { + forge: &'f Forge, + values: &'f [EnumData], +} + +/// A value of a [`Type::Enum`]. Null is never a value; see +/// [`Type::Enum`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EnumValue<'f> { + String(&'f str), + Number(f64), + Boolean(bool), +} + +/// The element type and limits of a [`Type::Array`]. +#[derive(Clone, Copy)] +pub struct Array<'f> { + forge: &'f Forge, + data: &'f ArrayData, +} + +/// The fields and other keys of a [`Type::Object`]. +#[derive(Clone, Copy)] +pub struct Object<'f> { + forge: &'f Forge, + data: &'f ObjectData, +} + +/// A property of an [`Object`]. +#[derive(Clone, Copy)] +pub struct Field<'f> { + forge: &'f Forge, + pub(crate) data: &'f FieldData, +} + +/// The members of a [`Type::Union`]. +#[derive(Clone, Copy)] +pub struct Union<'f> { + forge: &'f Forge, + data: &'f UnionData, +} + +/// The members of a [`Type::Intersection`]. +#[derive(Clone, Copy)] +pub struct Intersection<'f> { + forge: &'f Forge, + members: &'f [TypeId], +} + +/// The property whose value says which member of a [`Union`] a value is. +#[derive(Clone, Copy)] +pub struct Discriminator<'f> { + forge: &'f Forge, + data: &'f DiscriminatorData, +} + +impl<'f> Type<'f> { + /// Returns the type with ID `id` in `forge`. + pub(crate) fn new(forge: &'f Forge, id: TypeId) -> Type<'f> { + match &forge.types[id.index()] { + TypeData::Scalar(scalar) => Type::Scalar(*scalar), + TypeData::Enum(values) => Type::Enum(Enum { + forge, + values: forge.values.get(*values), + }), + TypeData::Array(data) => Type::Array(Array { forge, data }), + TypeData::Object(data) => Type::Object(Object { forge, data }), + TypeData::Union(data) => Type::Union(Union { forge, data }), + TypeData::Intersection(members) => Type::Intersection(Intersection { + forge, + members: forge.members.get(*members), + }), + TypeData::Model(id) => Type::Model(forge.model(*id)), + } + } +} + +impl<'f> Enum<'f> { + /// Returns the values, which are all different, in the order the + /// `enum` lists them. There is at least one. + pub fn values(self) -> impl ExactSizeIterator> { + self.values.iter().map(move |value| value.get(&self.forge.strings)) + } +} + +impl EnumData { + /// Returns the value, whose string is in `strings`. + pub(crate) fn get(self, strings: &Strings) -> EnumValue<'_> { + match self { + EnumData::String(s) => EnumValue::String(strings.get(s)), + EnumData::Number(n) => EnumValue::Number(n), + EnumData::Boolean(b) => EnumValue::Boolean(b), + } + } +} + +impl<'f> Array<'f> { + /// Returns the type of the elements. + pub fn elem(self) -> Type<'f> { + Type::new(self.forge, self.data.elem) + } + + /// Returns the most elements the array can have, or `None` if it has + /// no limit. + /// + /// Corresponds to `maxItems` on the array's schema, which must be a + /// non-negative integer: a number with no fractional part, such as + /// `2.0`, is one, as JSON Schema says, and any other value, such as + /// `-1`, `2.5` or `"2"`, is an error, on any schema. A `maxItems` on a + /// schema that is not an array is read, and ignored. + /// + /// Each array schema is its own array, so two arrays of the same + /// elements with different limits are never the same type. An array + /// that is a model, reached by a `$ref`, has the limit of the model's + /// schema, in [`Model::ty`]. A `maxItems` next to a `$ref` is not the + /// array's: in OpenAPI 3.0 it is ignored, as every keyword next to a + /// `$ref` is, and from 3.1, on its own, it keeps the model, as other + /// keywords that only constrain a value do. + pub fn max_items(self) -> Option { + self.data.max_items + } +} + +impl<'f> Object<'f> { + /// Returns the object `data` in `forge`. + pub(crate) fn new(forge: &'f Forge, data: &'f ObjectData) -> Object<'f> { + Object { forge, data } + } + + /// Returns the fields, one for each property, in an unspecified order, + /// except in a [`Method::request`](crate::Method::request) and in + /// [`Method::headers`](crate::Method::headers). + /// + /// A property marked `x-stainless-skip: true`, or whose schema is + /// `false`, has no field. A name in `required` that is not a property is + /// ignored. Two fields with equal names are an error. + pub fn fields(self) -> impl ExactSizeIterator> { + let forge = self.forge; + forge + .fields + .get(self.data.fields) + .iter() + .map(move |data| Field { forge, data }) + } + + /// Returns the type of the values under keys other than the fields, or + /// `None` if the object allows no other keys. + /// + /// Corresponds to `additionalProperties`. Without it, an object with + /// properties allows no other keys, and one without allows any, so + /// `{type: object}` has [`Scalar::Unknown`] values. An object with no + /// properties and `additionalProperties: false` is the empty object. + /// `patternProperties` is ignored, since no type can say which keys + /// match a pattern. + /// + /// The `unevaluatedProperties` of an `allOf` becomes one more member of + /// the [`Intersection`]: an object with no fields and those values. + pub fn extra(self) -> Option> { + Some(Type::new(self.forge, self.data.extra?)) + } +} + +impl<'f> Field<'f> { + /// Returns the property's name in the object, as it is sent. + pub fn wire_name(self) -> &'f str { + self.forge.strings.get(self.data.wire_name) + } + + /// Returns the name of the field. + /// + /// Corresponds to the `x-fern-property-name` extension of the + /// property, or its wire name if it has none. + pub fn name(self) -> &'f Name { + &self.forge.names[self.data.name.index()] + } + + /// Reports whether the object must have the property. + /// + /// A field for a parameter is required when the parameter is, and a + /// path parameter always is. + pub fn is_required(self) -> bool { + self.data.required + } + + /// Returns the type of the property's value. + pub fn ty(self) -> Type<'f> { + Type::new(self.forge, self.data.ty) + } + + /// Returns a description of the property, or an empty string if it has + /// none. + /// + /// The description is the property's own `description`, or else that + /// of the schema its `$ref` points to, following `$ref`s through any + /// chain of them. From OpenAPI 3.1, a `description` next to a `$ref` is + /// the property's own; in 3.0 it is ignored, as every keyword next to a + /// `$ref` is. From 3.1 too, a description is an annotation that every + /// schema applied in place gives, so without the others it is the first + /// one of an `allOf` member, in order, each read the same way; a member + /// of `anyOf` or `oneOf` applies only to some values, and gives none. + /// It is never the `title`, unlike a + /// [`Model::description`](crate::Model::description). + /// + /// A field for a parameter has the parameter's `description`, or else + /// its schema's, as for a property. From OpenAPI 3.1, the `description` + /// of a Reference Object to the parameter overrides the parameter's. + /// The `body` field of a [`Method::request`](crate::Method::request) + /// has the body's description, and a path template placeholder that no + /// parameter declares has none. + pub fn description(self) -> &'f str { + self.forge.strings.get(self.data.description) + } + + /// Reports whether the property is deprecated. + /// + /// A field for a parameter is deprecated when the parameter is, unless + /// it is a path parameter, which a caller must always send. + pub fn is_deprecated(self) -> bool { + self.data.deprecated + } + + /// Reports whether the field is read-only: the server sets the + /// property, so a client should not send it, though the same model's + /// field appears in responses. + /// + /// Corresponds to `readOnly: true` on the property's schema, or on the + /// schema its `$ref` points to, following `$ref`s through any chain of + /// them: the field is read-only if any of them says so, and a + /// `readOnly: false` does not undo another's `true`. From OpenAPI 3.1, + /// a `readOnly` next to a `$ref` is the property's own; in 3.0 it is + /// ignored, as every keyword next to a `$ref` is. A value that is not a + /// boolean is an error. + /// + /// Forge keeps a read-only field in its object: a model is the same + /// object in requests and responses, so the field cannot be dropped + /// from one without the other. A generator decides to leave read-only + /// fields out of what a client sends, such as a request body. + /// + /// A field for a parameter is never read-only, even when its schema is + /// marked `readOnly`, since a client sends every parameter. That + /// includes a field in [`Method::headers`](crate::Method::headers) and + /// a property of the object of an `in: querystring` parameter. Nor is + /// the `body` field of a [`Method::request`](crate::Method::request), + /// or a path template placeholder that no parameter declares. + pub fn is_read_only(self) -> bool { + self.data.read_only + } + + /// Reports whether the field holds a secret, such as a password or a + /// token, which a client should mask when it shows it. + /// + /// Corresponds to the `x-sensitive: true` extension on the property's + /// schema, or on any schema along its chain of `$ref`s. Unlike + /// `readOnly`, an `x-sensitive` next to a `$ref` counts in every + /// version of OpenAPI, since tools commonly honour extensions there. A + /// value that is not a boolean is an error, as it is for a schema's + /// `x-stainless-skip` and an operation's `x-forge-hidden`. + /// + /// A field for a parameter, whether in the request or in + /// [`Method::headers`](crate::Method::headers), is sensitive when the + /// parameter's schema is, in the same way: `x-sensitive` next to its + /// `$ref` or along its chain. An `x-sensitive` on the Parameter Object + /// itself, rather than its schema, is ignored. The `body` field of a + /// request, and a path template placeholder that no parameter + /// declares, are never sensitive. + pub fn is_sensitive(self) -> bool { + self.data.sensitive + } + + /// Returns the default of the property: the value to take for it when + /// it is left out, or `None` if there is none. `default: null` is + /// `Some(&Json::Null)`, apart from no default. + /// + /// Corresponds to `default` on the property's schema, or else on the + /// schema its `$ref` points to, following `$ref`s through any chain of + /// them: the first one along the way. From OpenAPI 3.1, a `default` + /// next to a `$ref` is the property's own; in 3.0 it is ignored, as + /// every keyword next to a `$ref` is. A `default` inside `allOf`, + /// `anyOf` or `oneOf` members is not read. + /// + /// Forge keeps the default as written, and does not check it against + /// the schema: a default that the field's type does not allow, such as + /// a string for an integer, is returned all the same. + /// + /// A field for a parameter, whether in the request or in + /// [`Method::headers`](crate::Method::headers), has the default of the + /// parameter's schema, in the same way, since the Parameter Object has + /// no `default` of its own: one written on it is ignored. The `body` + /// field of a [`Method::request`](crate::Method::request), and a path + /// template placeholder that no parameter declares, have none. + pub fn default(self) -> Option<&'f Json> { + Some(&self.forge.defaults[self.data.default?.index()]) + } +} + +impl<'f> Union<'f> { + /// Returns the members, in an unspecified order. + /// + /// A union has at least two members, which are all different, and at + /// most one of them is an enum. Only a union with a discriminator is a + /// member of another union, which then has no discriminator. + pub fn members(self) -> impl ExactSizeIterator> { + let forge = self.forge; + forge + .members + .get(self.data.members) + .iter() + .map(move |&id| Type::new(forge, id)) + } + + /// Reports whether a value is at most one of the members, so that the + /// members exclude each other. + /// + /// A union from `oneOf` is exclusive, and so is one from a `type` that + /// lists more than one type, or from null and another type, since a + /// value has one JSON type. A union from `anyOf` is not, since a value + /// can be more than one of its members. A union with a member that + /// is a union without a discriminator has that union's members, and is + /// exclusive only if both are. + pub fn is_exclusive(self) -> bool { + self.data.exclusive + } + + /// Returns the property that says which member a value is, or `None` + /// if there is none. + pub fn discriminator(self) -> Option> { + let data = self.data.discriminator.as_ref()?; + Some(Discriminator { + forge: self.forge, + data, + }) + } +} + +impl<'f> Intersection<'f> { + /// Returns the members, in an unspecified order, except in a + /// [`Method::request`](crate::Method::request). + pub fn members(self) -> impl ExactSizeIterator> { + self.members.iter().map(move |&id| Type::new(self.forge, id)) + } +} + +impl<'f> Discriminator<'f> { + /// Returns the property's name in the object, as it is sent. + pub fn wire_name(self) -> &'f str { + self.forge.strings.get(self.data.wire_name) + } + + /// Returns the name of the property's field: the + /// `x-fern-property-name` of the property in the members, or else its + /// wire name. + pub fn name(self) -> &'f Name { + &self.data.name + } + + /// Returns each value of the property and the member of the union it + /// says a value is, in an unspecified order. A member is a + /// [`Type::Model`], or an object written in place. + /// + /// The cases come from `mapping`, whose values are a `$ref` or a schema + /// name. A member the mapping leaves out has a case for the one value + /// of an `enum` on its property, or else, for a model, for its schema + /// name. + pub fn cases(self) -> impl ExactSizeIterator)> { + let forge = self.forge; + self.data + .cases + .iter() + .map(move |&(value, id)| (forge.strings.get(value), Type::new(forge, id))) + } + + /// Returns the model of a value whose property is missing or matches + /// no case, or `None` if there is none. + /// + /// Corresponds to `defaultMapping` in the + /// [Discriminator Object](https://spec.openapis.org/oas/latest.html#discriminator-object), + /// added in OpenAPI 3.2. + pub fn default(self) -> Option> { + Some(self.forge.model(self.data.default?)) + } +} + +/// The index of a type's data in its `Forge`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct TypeId(pub(crate) u32); + +impl TypeId { + pub(crate) fn index(self) -> usize { + self.0 as usize + } +} + +/// The index of a name in its `Forge`. Names are kept once each, so two +/// names are equal when their IDs are. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct NameId(pub(crate) u32); + +impl NameId { + pub(crate) fn index(self) -> usize { + self.0 as usize + } +} + +/// What a [`Forge`] stores for a type. The types inside it are stored +/// separately, by ID, and its lists in the `Forge`'s arenas. +pub(crate) enum TypeData { + Scalar(Scalar), + Enum(Span), + Array(ArrayData), + Object(ObjectData), + Union(UnionData), + Intersection(Span), + Model(ModelId), +} + +/// What a [`Forge`] stores for an [`EnumValue`]. +#[derive(Clone, Copy)] +pub(crate) enum EnumData { + String(Str), + Number(f64), + Boolean(bool), +} + +#[derive(Clone, Copy)] +pub(crate) struct ArrayData { + pub(crate) elem: TypeId, + /// The `maxItems`, if the array has a limit. + pub(crate) max_items: Option, +} + +#[derive(Clone, Copy)] +pub(crate) struct ObjectData { + pub(crate) fields: Span, + /// The type of the values under other keys, if it allows them. + pub(crate) extra: Option, +} + +pub(crate) struct FieldData { + pub(crate) wire_name: Str, + pub(crate) name: NameId, + pub(crate) required: bool, + pub(crate) ty: TypeId, + pub(crate) description: Str, + pub(crate) deprecated: bool, + pub(crate) read_only: bool, + pub(crate) sensitive: bool, + /// The default, in `Forge::defaults`. + pub(crate) default: Option>, +} + +pub(crate) struct UnionData { + pub(crate) members: Span, + /// Whether a value is at most one of the members. + pub(crate) exclusive: bool, + /// Boxed, since most unions have none, to keep `TypeData` small. + pub(crate) discriminator: Option>, +} + +pub(crate) struct DiscriminatorData { + pub(crate) wire_name: Str, + pub(crate) name: Name, + /// Each value of the property and the member it names. + pub(crate) cases: Vec<(Str, TypeId)>, + /// The model of a value that matches no case. + pub(crate) default: Option, +} + +impl fmt::Debug for Enum<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.values()).finish() + } +} + +impl fmt::Debug for Array<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.elem().fmt(f) + } +} + +impl fmt::Debug for Object<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Object") + .field("fields", &self.fields().collect::>()) + .field("extra", &self.extra()) + .finish() + } +} + +impl fmt::Debug for Field<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Field") + .field("wire_name", &self.wire_name()) + .field("required", &self.is_required()) + .field("ty", &self.ty()) + .finish() + } +} + +impl fmt::Debug for Union<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Union") + .field("members", &self.members().collect::>()) + .field("exclusive", &self.is_exclusive()) + .field("discriminator", &self.discriminator()) + .finish() + } +} + +impl fmt::Debug for Intersection<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Intersection") + .field("members", &self.members().collect::>()) + .finish() + } +} + +impl fmt::Debug for Discriminator<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Discriminator") + .field("wire_name", &self.wire_name()) + .field("cases", &self.cases().collect::>()) + .field("default", &self.default()) + .finish() + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 000000000..6b85c5fc4 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,81 @@ +//! Runs the `forge` command. + +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +/// A document with one method, `a.get`. +const SPEC: &str = r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok"}}}}} +}"#; + +/// Runs `forge` with `args`, writing `stdin` to it. +fn forge(args: &[&str], stdin: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap(); + child.wait_with_output().unwrap() +} + +/// Returns the standard error of `out`, and panics if `forge` succeeded. +#[track_caller] +fn failure(out: Output) -> String { + assert!(!out.status.success(), "forge succeeded"); + String::from_utf8(out.stderr).unwrap() +} + +#[test] +fn flags_are_required() { + let err = failure(forge(&["-"], SPEC)); + assert!(err.contains("--target "), "{err}"); + assert!(err.contains("--out "), "{err}"); +} + +#[test] +fn unknown_target() { + let err = failure(forge(&["-", "--target", "go", "--out", "sdk"], SPEC)); + assert!(err.contains("invalid value 'go'"), "{err}"); + assert!(err.contains("possible values: ts"), "{err}"); +} + +#[test] +fn missing_spec() { + let err = failure(forge(&["missing.json", "--target", "ts", "--out", "sdk"], "")); + assert!(err.starts_with("forge: missing.json: "), "{err}"); +} + +#[test] +fn invalid_spec() { + let err = failure(forge(&["-", "--target", "ts", "--out", "sdk"], "{")); + assert!(err.starts_with("forge: -: "), "{err}"); +} + +#[test] +fn ts() { + let out = std::env::temp_dir().join(format!("forge-cli-ts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let result = forge(&["-", "--target", "ts", "--out", out.to_str().unwrap()], SPEC); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + for name in [ + "index.ts", + "client.ts", + "a/index.ts", + "a/client.ts", + "_/index.ts", + "_/hooks/envelope.ts", + ] { + assert!(out.join(name).is_file(), "{name} is missing"); + } + let client = std::fs::read_to_string(out.join("a/client.ts")).unwrap(); + assert!( + client.contains("get(request?: GetRequest, options?: RequestOptions): Promise;"), + "{client}" + ); + std::fs::remove_dir_all(&out).unwrap(); +} diff --git a/tests/parse/aliases.rs b/tests/parse/aliases.rs new file mode 100644 index 000000000..eb299ef5f --- /dev/null +++ b/tests/parse/aliases.rs @@ -0,0 +1,342 @@ +use forge::{Availability, Forge, HttpMethod, Json, Scalar}; + +use crate::common::{field_names, group, intersection, method, model, object, parse, parse_err, scalar}; + +/// Returns a document with one operation, `PATCH /a/{id}/status`, whose own +/// method would be `a.status.edit`, with `x-forge-aliases` written as +/// `aliases` and `fields`, which end with a comma. +fn aliased(aliases: &str, fields: &str) -> String { + format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a/{{id}}/status": {{"patch": {{{fields} + "x-fern-sdk-group-name": ["a", "status"], "x-fern-sdk-method-name": "edit", + "x-forge-aliases": {aliases}, + "summary": "Edit status", "description": "Changes the status.", + "parameters": [{{"name": "id", "in": "path", "required": true, "schema": {{"type": "string"}}}}], + "requestBody": {{"content": {{"application/json": {{"schema": {{"type": "object", + "properties": {{"status": {{"type": "string"}}}}}}}}}}}}, + "responses": {{ + "200": {{"description": "ok", "content": {{"application/json": {{"schema": {{"type": "boolean"}}}}}}}}, + "404": {{"description": "missing"}} + }}}}}}}} + }}"# + ) +} + +/// Returns every method, as its group path and name joined by dots in +/// snake case, such as `a.pause`, in the order the groups are walked. +fn methods(forge: &Forge) -> Vec { + let mut paths = Vec::new(); + for group in forge.groups() { + for method in group.methods() { + let mut path: Vec = group.path().map(|name| name.snake()).collect(); + path.push(method.name().snake()); + paths.push(path.join(".")); + } + } + paths +} + +const PAUSE_AND_TERMINATE: &str = r#"[ + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, + {"x-fern-sdk-group-name": ["a"], "x-fern-sdk-method-name": "terminate"} +]"#; + +#[test] +fn each_alias_is_a_method_and_the_operation_is_not() { + let forge = parse(&aliased(PAUSE_AND_TERMINATE, "")); + + assert_eq!(methods(&forge), ["a.pause", "a.terminate"]); +} + +#[test] +fn aliases_share_the_rest_of_the_operation() { + let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""deprecated": true,"#)); + + for path in ["a.pause", "a.terminate"] { + let m = method(&forge, path); + assert_eq!(*m.http_method(), HttpMethod::Patch, "{path}"); + assert_eq!(m.path(), "/a/{id}/status", "{path}"); + assert_eq!(m.summary(), "Edit status", "{path}"); + assert_eq!(m.description(), "Changes the status.", "{path}"); + assert!(m.is_deprecated(), "{path}"); + let request = intersection(m.request().unwrap()); + let members: Vec> = request.members().map(|ty| field_names(object(ty))).collect(); + assert_eq!(members, [vec!["id"], vec!["status"]], "{path}"); + let statuses: Vec<&str> = m.responses().map(|r| r.status()).collect(); + assert_eq!(statuses, ["200"], "{path}"); + let json = m.responses().next().and_then(|r| r.json()).unwrap(); + assert_eq!(scalar(json.ty()), Scalar::Boolean, "{path}"); + let errors: Vec<&str> = m.errors().map(|r| r.status()).collect(); + assert_eq!(errors, ["404"], "{path}"); + } +} + +#[test] +fn each_alias_has_its_own_availability_hidden_confirmation_and_extensions() { + let forge = parse(&aliased( + r#"[ + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", + "x-fern-availability": "generally-available", "x-forge-hidden": true, + "x-forge-params": {"status": {"default": "pause", "hidden": true}}}, + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "terminate", + "x-fern-availability": "beta", "x-forge-require-confirmation": "Sure?", "x-a": 1, "x-b": 2} + ]"#, + "", + )); + + let pause = method(&forge, "a.pause"); + assert_eq!(pause.availability(), Some(Availability::GenerallyAvailable)); + assert!(pause.is_hidden()); + assert_eq!(pause.confirmation(), None); + let names: Vec<&str> = pause.extensions().map(|(name, _)| name).collect(); + assert_eq!(names, ["x-forge-params"]); + let status = pause + .extension("x-forge-params") + .and_then(|params| params.get("status")); + assert_eq!( + status.and_then(|s| s.get("default")).and_then(Json::as_str), + Some("pause") + ); + + let terminate = method(&forge, "a.terminate"); + assert_eq!(terminate.availability(), Some(Availability::Beta)); + assert!(!terminate.is_hidden()); + assert_eq!(terminate.confirmation(), Some("Sure?")); + let extensions: Vec<(&str, &Json)> = terminate.extensions().collect(); + assert_eq!(extensions, [("x-a", &Json::Number(1.0)), ("x-b", &Json::Number(2.0))]); +} + +#[test] +fn alias_does_not_fall_back_to_the_operation() { + let forge = parse(&aliased( + r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}]"#, + r#""x-fern-availability": "beta", "x-forge-hidden": true, "x-forge-require-confirmation": "Sure?", + "x-forge-params": {"status": {}},"#, + )); + + let pause = method(&forge, "a.pause"); + assert_eq!(pause.availability(), None); + assert!(!pause.is_hidden()); + assert_eq!(pause.confirmation(), None); + assert_eq!(pause.extension("x-forge-params"), None); + assert_eq!(pause.extensions().len(), 0); +} + +#[test] +fn alias_without_a_group_or_name_is_not_a_method() { + let forge = parse(&aliased( + r#"[ + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, + {"x-fern-sdk-group-name": "a"}, + {"x-fern-sdk-method-name": "resume"}, + {"x-fern-sdk-group-name": "", "x-fern-sdk-method-name": "restart"} + ]"#, + "", + )); + + assert_eq!(methods(&forge), ["a.pause"]); +} + +#[test] +fn element_that_is_not_an_object_is_ignored() { + let forge = parse(&aliased( + r#"["pause", null, 1, true, ["a"], {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}]"#, + "", + )); + + assert_eq!(methods(&forge), ["a.pause"]); +} + +#[test] +fn empty_aliases_make_no_method() { + let forge = parse(&aliased("[]", "")); + + assert_eq!(methods(&forge), Vec::::new()); +} + +#[test] +fn aliases_that_are_not_an_array_are_an_error() { + let err = parse_err(&aliased( + r#"{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}"#, + "", + )); + + assert!(err.contains("an array of aliases"), "{err}"); +} + +#[test] +fn alias_and_another_method_with_equal_names_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"post": {"x-forge-aliases": [ + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "resume"}, + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "Pause"} + ], "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + assert!( + err.contains(r#"POST /b: x-forge-aliases[1]: method "pause" is also GET /a"#), + "{err}" + ); +} + +#[test] +fn two_aliases_with_equal_names_are_an_error() { + let err = parse_err(&aliased( + r#"[ + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"} + ]"#, + "", + )); + + assert!( + err.contains(r#"x-forge-aliases[1]: method "pause" is also PATCH"#), + "{err}" + ); +} + +#[test] +fn alias_group_name_without_letters_is_an_error_naming_the_alias() { + let err = parse_err(&aliased( + r#"[{"x-fern-sdk-group-name": "a..b", "x-fern-sdk-method-name": "pause"}]"#, + "", + )); + + assert!( + err.contains("PATCH /a/{id}/status: x-forge-aliases[0]: x-fern-sdk-group-name"), + "{err}" + ); +} + +#[test] +fn alias_availability_is_checked() { + let err = parse_err(&aliased( + r#"[1, {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "x-fern-availability": "ga"}]"#, + "", + )); + + assert!( + err.contains(r#"x-forge-aliases[1]: x-fern-availability "ga" is not"#), + "{err}" + ); +} + +#[test] +fn repeated_key_forge_reads_in_an_alias_is_an_error() { + let err = parse_err(&aliased( + r#"[{"x-fern-sdk-method-name": "pause", "x-fern-sdk-method-name": "resume"}]"#, + "", + )); + + assert!(err.contains("duplicate field `x-fern-sdk-method-name`"), "{err}"); +} + +#[test] +fn ignored_operation_still_has_its_aliases() { + let forge = parse(&aliased(PAUSE_AND_TERMINATE, r#""x-fern-ignore": true,"#)); + + assert_eq!(methods(&forge), ["a.pause", "a.terminate"]); +} + +#[test] +fn ignored_alias_is_not_a_method() { + let forge = parse(&aliased( + r#"[ + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "x-fern-ignore": true}, + {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "terminate", "x-fern-ignore": false} + ]"#, + "", + )); + + assert_eq!(methods(&forge), ["a.terminate"]); +} + +#[test] +fn aliases_are_not_extensions() { + let forge = parse(&aliased( + r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "x-a": 1, + "x-forge-aliases": [{"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "nested"}]}]"#, + "", + )); + + let pause = method(&forge, "a.pause"); + let names: Vec<&str> = pause.extensions().map(|(name, _)| name).collect(); + assert_eq!(names, ["x-a"]); + assert_eq!(methods(&forge), ["a.pause"]); +} + +#[test] +fn keys_of_an_alias_that_are_not_extensions_are_ignored() { + let forge = parse(&aliased( + r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", + "summary": "Pause", "deprecated": false, "path": "/b"}]"#, + "", + )); + + let pause = method(&forge, "a.pause"); + assert_eq!(pause.summary(), "Edit status"); + assert_eq!(pause.path(), "/a/{id}/status"); + assert_eq!(pause.extensions().len(), 0); +} + +#[test] +fn operation_without_aliases_keeps_its_own_method() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(methods(&forge), ["a.get"]); +} + +#[test] +fn model_used_by_aliases_in_two_top_level_groups_is_at_the_top_level() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/m": {"get": { + "x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "x-forge-aliases": [ + {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get"}, + {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get"} + ], + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, + "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(model(&forge, "M").group(), None); + assert_eq!(forge.models().count(), 1); + assert_eq!(group(&forge, "a.b").models().count(), 0); +} + +#[test] +fn model_used_by_aliases_in_one_group_is_in_that_group() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/m": {"get": { + "x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "x-forge-aliases": [ + {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get"}, + {"x-fern-sdk-group-name": "a.c", "x-fern-sdk-method-name": "get"} + ], + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}}, + "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(model(&forge, "M").group(), Some(group(&forge, "a"))); +} diff --git a/tests/parse/bodies.rs b/tests/parse/bodies.rs new file mode 100644 index 000000000..4b09e2967 --- /dev/null +++ b/tests/parse/bodies.rs @@ -0,0 +1,323 @@ +use forge::{Body, Content, Forge, Scalar}; + +use crate::common::{field, kind, method, object, parse, scalar}; + +/// Returns the body of the method `a.create`. +#[track_caller] +fn body(forge: &Forge) -> Body<'_> { + let create = method(forge, "a.create"); + let Some(body) = create.body() else { + panic!("{create:?} has no body"); + }; + body +} + +fn media_types<'f>(contents: impl Iterator>) -> Vec<&'f str> { + contents.map(|content| content.media_type()).collect() +} + +#[test] +fn body_has_its_required_and_description() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"required": true, "description": "The pet.", + "content": {"application/json": {"schema": {"type": "string"}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let body = body(&forge); + assert!(body.is_required()); + assert_eq!(body.description(), "The pet."); +} + +#[test] +fn body_is_optional_by_default() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/json": {"schema": {"type": "string"}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(!body(&forge).is_required()); +} + +#[test] +fn contents_are_sorted_by_media_type() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": { + "text/plain": {"schema": {"type": "string"}}, + "multipart/form-data": {"schema": {"type": "object"}}, + "application/json": {"schema": {"type": "object"}} + }}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!( + media_types(body(&forge).contents()), + ["application/json", "multipart/form-data", "text/plain"] + ); +} + +#[test] +fn json_is_application_json_before_other_json_types() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": { + "application/geo+json": {"schema": {"type": "object"}}, + "application/json": {"schema": {"type": "string"}} + }}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(body(&forge).json().unwrap().media_type(), "application/json"); +} + +#[test] +fn json_is_otherwise_the_first_json_type() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"patch": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": { + "multipart/form-data": {"schema": {"type": "object"}}, + "application/scim+json": {"schema": {"type": "object"}}, + "application/merge-patch+json": {"schema": {"type": "object"}} + }}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!( + body(&forge).json().unwrap().media_type(), + "application/merge-patch+json" + ); +} + +#[test] +fn json_ignores_case_and_parameters() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"Application/JSON; charset=utf-8": {"schema": {"type": "string"}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let json = body(&forge).json().unwrap(); + assert_eq!(json.media_type(), "Application/JSON; charset=utf-8"); + assert!(json.is_json()); +} + +#[test] +fn multipart_content_is_typed() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"multipart/form-data": {"schema": { + "type": "object", "required": ["file"], "properties": { + "file": {"type": "string", "format": "binary"}, + "name": {"type": "string"} + } + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let body = body(&forge); + assert_eq!(media_types(body.contents()), ["multipart/form-data"]); + let form = object(body.contents().next().unwrap().ty()); + assert!(field(form, "file").is_required()); + assert_eq!(scalar(field(form, "file").ty()), Scalar::Binary); + assert_eq!(scalar(field(form, "name").ty()), Scalar::String); +} + +#[test] +fn octet_stream_content_of_a_binary_string_is_binary() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/octet-stream": {"schema": { + "type": "string", "format": "binary" + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let content = body(&forge).contents().next().unwrap(); + assert!(!content.is_json()); + assert_eq!(scalar(content.ty()), Scalar::Binary); +} + +#[test] +fn json_ignores_content_that_is_not_json() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": { + "multipart/form-data": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, + "text/plain": {"schema": {"type": "string"}} + }}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let body = body(&forge); + assert!(body.json().is_none()); + let kinds: Vec = body.contents().map(|content| kind(content.ty())).collect(); + assert_eq!(kinds, ["Object", "String"]); +} + +#[test] +fn content_that_is_not_json_without_a_schema_is_unknown() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/octet-stream": {}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let content = body(&forge).contents().next().unwrap(); + assert_eq!(scalar(content.ty()), Scalar::Unknown); +} + +#[test] +fn content_without_a_schema_is_unknown() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/json": {}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let content = body(&forge).contents().next().unwrap(); + assert_eq!(scalar(content.ty()), Scalar::Unknown); +} + +#[test] +fn body_ref_is_followed() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"$ref": "#/components/requestBodies/Pet"}, + "responses": {"204": {"description": "ok"}}}}}, + "components": {"requestBodies": { + "Pet": {"required": true, "content": {"application/json": {"schema": {"type": "string"}}}} + }} + }"##, + ); + + let body = body(&forge); + assert!(body.is_required()); + assert_eq!(scalar(body.json().unwrap().ty()), Scalar::String); +} + +/// Returns a document whose method `a.create` has a JSON body of +/// `schema`, in OpenAPI `version`, with `schemas` as its schemas. +fn with_body_schema(version: &str, schema: &str, schemas: &str) -> String { + format!( + r#"{{ + "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"post": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {{"content": {{"application/json": {{"schema": {schema}}}}}}}, + "responses": {{"204": {{"description": "ok"}}}}}}}}}}, + "components": {{"schemas": {schemas}}} + }}"# + ) +} + +#[test] +fn content_has_its_schema_description() { + let schema = r#"{"type": "string", "description": "A name."}"#; + let forge = parse(&with_body_schema("3.0.3", schema, "{}")); + + assert_eq!(body(&forge).json().unwrap().description(), "A name."); +} + +#[test] +fn content_description_follows_refs() { + let forge = parse(&with_body_schema( + "3.0.3", + r##"{"$ref": "#/components/schemas/Name"}"##, + r##"{"Name": {"$ref": "#/components/schemas/Text"}, "Text": {"type": "string", "description": "Some text."}}"##, + )); + + assert_eq!(body(&forge).json().unwrap().description(), "Some text."); +} + +#[test] +fn content_description_is_never_the_title() { + let forge = parse(&with_body_schema( + "3.0.3", + r##"{"$ref": "#/components/schemas/Pet"}"##, + r#"{"Pet": {"type": "object", "title": "A pet", "properties": {"name": {"type": "string"}}}}"#, + )); + + let content = body(&forge).json().unwrap(); + assert_eq!(content.description(), ""); +} + +#[test] +fn content_description_next_to_a_ref_counts_from_3_1() { + let schema = r##"{"$ref": "#/components/schemas/Pet", "description": "The new pet."}"##; + let schemas = r#"{"Pet": {"type": "object", "description": "A pet."}}"#; + + let forge = parse(&with_body_schema("3.0.3", schema, schemas)); + assert_eq!(body(&forge).json().unwrap().description(), "A pet."); + let forge = parse(&with_body_schema("3.1.0", schema, schemas)); + assert_eq!(body(&forge).json().unwrap().description(), "The new pet."); +} + +#[test] +fn content_without_a_schema_has_no_description() { + let forge = parse(&with_body_schema("3.0.3", "{}", "{}")); + + assert_eq!(body(&forge).json().unwrap().description(), ""); +} + +#[test] +fn response_content_has_its_schema_description() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": { + "schema": {"type": "string", "description": "The name."}}}}}}}} + }"#, + ); + + let response = method(&forge, "a.get").responses().next().unwrap(); + assert_eq!(response.json().unwrap().description(), "The name."); +} + +#[test] +fn content_description_comes_from_an_all_of_member_in_3_1() { + let schema = r##"{"allOf": [{"$ref": "#/components/schemas/Pet"}, {"required": ["name"]}]}"##; + let schemas = r#"{"Pet": {"type": "object", "description": "A pet."}}"#; + + let forge = parse(&with_body_schema("3.0.3", schema, schemas)); + assert_eq!(body(&forge).json().unwrap().description(), ""); + let forge = parse(&with_body_schema("3.1.0", schema, schemas)); + assert_eq!(body(&forge).json().unwrap().description(), "A pet."); +} diff --git a/tests/parse/common.rs b/tests/parse/common.rs new file mode 100644 index 000000000..a4645a106 --- /dev/null +++ b/tests/parse/common.rs @@ -0,0 +1,232 @@ +//! Helpers to find things in a `Forge` and look inside types, so each test +//! asserts only what it is about. Each helper panics with what there is +//! when it finds nothing. + +use forge::{EnumValue, Field, Forge, Group, Intersection, Method, Model, Name, Object, Scalar, Type, Union}; + +/// Parses `json` as a whole document. +#[track_caller] +pub fn parse(json: &str) -> Forge { + match serde_json::from_str(json) { + Ok(forge) => forge, + Err(err) => panic!("parse: {err}"), + } +} + +/// Returns an OpenAPI 3.0.3 document with one method, `a.get`, whose JSON +/// response has the schema `schema`. +pub fn responding_with(schema: &str) -> String { + format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}} + }}"# + ) +} + +/// Returns the document of [`responding_with`] with `schemas` as its +/// `components.schemas`. +pub fn responding_with_schemas(schema: &str, schemas: &str) -> String { + format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {{"200": {{"description": "ok", "content": {{"application/json": {{"schema": {schema}}}}}}}}}}}}}}}, + "components": {{"schemas": {schemas}}} + }}"# + ) +} + +/// Parses `json`, which must fail, and returns the error message. +#[track_caller] +pub fn parse_err(json: &str) -> String { + match serde_json::from_str::(json) { + Ok(_) => panic!("parse: want an error, got a Forge"), + Err(err) => err.to_string(), + } +} + +/// Returns the group at `path`: the names from the top level down, +/// separated by dots, such as `zones` or `zones.settings`. +#[track_caller] +pub fn group<'f>(forge: &'f Forge, path: &str) -> Group<'f> { + let names: Vec = path.split('.').map(Name::new).collect(); + let Some(group) = forge.groups().find(|group| group.path().eq(names.iter())) else { + panic!("no group {path:?} in {:?}", forge.groups().collect::>()); + }; + group +} + +/// Returns the method at `path`: its group's path, a dot and its name, +/// such as `zones.get`. +#[track_caller] +pub fn method<'f>(forge: &'f Forge, path: &str) -> Method<'f> { + let Some((group_path, name)) = path.rsplit_once('.') else { + panic!("method path {path:?} is not group.method"); + }; + let group = group(forge, group_path); + let name = Name::new(name); + let Some(method) = group.methods().find(|method| *method.name() == name) else { + panic!("no method {path:?} in {:?}", group.methods().collect::>()); + }; + method +} + +/// Returns the schema names of every model, wherever it is placed, sorted. +pub fn models(forge: &Forge) -> Vec<&str> { + let mut names: Vec<&str> = forge + .models() + .chain(forge.groups().flat_map(|group| group.models())) + .map(|model| model.schema_name()) + .collect(); + names.sort(); + names +} + +/// Returns the model with schema name `schema_name`, wherever it is +/// placed. +#[track_caller] +pub fn model<'f>(forge: &'f Forge, schema_name: &str) -> Model<'f> { + let models = || forge.models().chain(forge.groups().flat_map(|group| group.models())); + let Some(model) = models().find(|model| model.schema_name() == schema_name) else { + panic!("no model {schema_name:?} in {:?}", models().collect::>()); + }; + model +} + +/// Returns the type of the JSON content of the first success response of +/// the method at `path`. +#[track_caller] +pub fn response_type<'f>(forge: &'f Forge, path: &str) -> Type<'f> { + let method = method(forge, path); + let Some(response) = method.responses().next() else { + panic!("{method:?} has no success response"); + }; + let Some(json) = response.json() else { + panic!("{response:?} has no JSON content"); + }; + json.ty() +} + +/// Returns the field of `object` with wire name `wire_name`. +#[track_caller] +pub fn field<'f>(object: Object<'f>, wire_name: &str) -> Field<'f> { + let Some(field) = object.fields().find(|field| field.wire_name() == wire_name) else { + panic!("no field {wire_name:?} in {object:?}"); + }; + field +} + +/// Returns the wire names of the fields of `object`, in order. +pub fn field_names(object: Object<'_>) -> Vec<&str> { + object.fields().map(|field| field.wire_name()).collect() +} + +/// Returns the scalar `ty` is. +#[track_caller] +pub fn scalar(ty: Type<'_>) -> Scalar { + let Type::Scalar(scalar) = ty else { + panic!("want a scalar, got {ty:?}"); + }; + scalar +} + +/// Returns the values of the enum `ty` is. +#[track_caller] +pub fn values(ty: Type<'_>) -> Vec> { + let Type::Enum(values) = ty else { + panic!("want an enum, got {ty:?}"); + }; + values.values().collect() +} + +/// Returns the element type of the array `ty` is. +#[track_caller] +pub fn elem(ty: Type<'_>) -> Type<'_> { + let Type::Array(array) = ty else { + panic!("want an array, got {ty:?}"); + }; + array.elem() +} + +/// Returns the object `ty` is. +#[track_caller] +pub fn object(ty: Type<'_>) -> Object<'_> { + let Type::Object(object) = ty else { + panic!("want an object, got {ty:?}"); + }; + object +} + +/// Returns the union `ty` is. +#[track_caller] +pub fn union(ty: Type<'_>) -> Union<'_> { + let Type::Union(union) = ty else { + panic!("want a union, got {ty:?}"); + }; + union +} + +/// Returns the intersection `ty` is. +#[track_caller] +pub fn intersection(ty: Type<'_>) -> Intersection<'_> { + let Type::Intersection(intersection) = ty else { + panic!("want an intersection, got {ty:?}"); + }; + intersection +} + +/// Returns the model `ty` is. +#[track_caller] +pub fn model_of(ty: Type<'_>) -> Model<'_> { + let Type::Model(model) = ty else { + panic!("want a model, got {ty:?}"); + }; + model +} + +/// Returns the type other than null in `ty`, which must be a union of it +/// and [`Scalar::Null`]. +#[track_caller] +pub fn non_null(ty: Type<'_>) -> Type<'_> { + let members: Vec = union(ty).members().collect(); + let others: Vec = members + .iter() + .copied() + .filter(|m| !matches!(m, Type::Scalar(Scalar::Null))) + .collect(); + let [other] = others[..] else { + panic!("want a union of one type and null, got {ty:?}"); + }; + assert_eq!(members.len(), 2, "want a union of one type and null, got {ty:?}"); + other +} + +/// Returns what kind of type `ty` is, without its parts: the scalar, such +/// as `String` or `Number(Int)`, `Model(` and its schema name `)`, or the +/// name of the variant. +pub fn kind(ty: Type<'_>) -> String { + match ty { + Type::Scalar(scalar) => format!("{scalar:?}"), + Type::Enum(_) => "Enum".to_string(), + Type::Array(_) => "Array".to_string(), + Type::Object(_) => "Object".to_string(), + Type::Union(_) => "Union".to_string(), + Type::Intersection(_) => "Intersection".to_string(), + Type::Model(model) => format!("Model({})", model.schema_name()), + } +} + +/// Returns the [`kind`] of each member of the union or intersection `ty` +/// is, sorted, since members have no order. +#[track_caller] +pub fn kinds(ty: Type<'_>) -> Vec { + let mut kinds: Vec = match ty { + Type::Union(union) => union.members().map(kind).collect(), + Type::Intersection(intersection) => intersection.members().map(kind).collect(), + _ => panic!("want a union or intersection, got {ty:?}"), + }; + kinds.sort(); + kinds +} diff --git a/tests/parse/document.rs b/tests/parse/document.rs new file mode 100644 index 000000000..282c6744b --- /dev/null +++ b/tests/parse/document.rs @@ -0,0 +1,146 @@ +use crate::common::{method, parse, parse_err}; + +#[test] +fn version_is_the_info_version() { + let forge = parse(r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "2.1.0"}, "paths": {}}"#); + + assert_eq!(forge.version, "2.1.0"); +} + +#[test] +fn description_joins_title_summary_and_description() { + let forge = parse( + r#"{ + "openapi": "3.1.0", + "info": {"title": "Example", "summary": "Short.", "description": "Long.", "version": "1"}, + "paths": {} + }"#, + ); + + assert_eq!(forge.description, "Example\n\nShort.\n\nLong."); +} + +#[test] +fn description_leaves_out_missing_parts() { + let forge = parse( + r#"{ + "openapi": "3.0.3", + "info": {"title": "Example", "description": "Long.", "version": "1"}, + "paths": {} + }"#, + ); + + assert_eq!(forge.description, "Example\n\nLong."); +} + +#[test] +fn base_url_is_the_first_server() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "servers": [{"url": "https://a.example.com/v1"}, {"url": "https://b.example.com"}], + "paths": {} + }"#, + ); + + assert_eq!(forge.base_url, "https://a.example.com/v1"); +} + +#[test] +fn base_url_substitutes_server_variables() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "servers": [{ + "url": "https://{region}.example.com/{version}", + "variables": {"region": {"default": "eu", "enum": ["eu", "us"]}, "version": {"default": "v4"}} + }], + "paths": {} + }"#, + ); + + assert_eq!(forge.base_url, "https://eu.example.com/v4"); +} + +#[test] +fn base_url_is_empty_without_servers() { + let forge = parse(r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {}}"#); + + assert_eq!(forge.base_url, ""); +} + +#[test] +fn undefined_server_variable_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "servers": [{"url": "https://{region}.example.com"}], + "paths": {} + }"#, + ); + + assert!(err.contains("region"), "{err}"); +} + +#[test] +fn unclosed_server_variable_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "servers": [{"url": "https://{region.example.com"}], + "paths": {} + }"#, + ); + + assert!(err.contains("https://{region.example.com"), "{err}"); +} + +#[test] +fn every_version_of_3_0_3_1_and_3_2_is_read() { + for version in [ + "3.0.0", "3.0.1", "3.0.2", "3.0.3", "3.0.4", "3.1.0", "3.1.1", "3.1.2", "3.2.0", + ] { + let forge = parse(&format!( + r#"{{ + "openapi": "{version}", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + )); + + method(&forge, "a.get"); + } +} + +#[test] +fn errors_are_reported_together() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "servers": [{"url": "https://{region}.example.com"}], + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a..b", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("region"), "{err}"); + assert!(err.contains("GET /a"), "{err}"); +} + +#[test] +fn other_versions_are_an_error() { + for version in ["2.0", "3.3.0", "4.0.0"] { + let err = parse_err(&format!( + r#"{{"openapi": "{version}", "info": {{"title": "t", "version": "1"}}, "paths": {{}}}}"# + )); + + assert!(err.contains(version), "{version}: {err}"); + } +} + +#[test] +fn swagger_document_is_an_error() { + let err = parse_err(r#"{"swagger": "2.0", "info": {"title": "t", "version": "1"}, "paths": {}}"#); + + assert!(err.contains("openapi"), "{err}"); +} diff --git a/tests/parse/extensions.rs b/tests/parse/extensions.rs new file mode 100644 index 000000000..f626036f4 --- /dev/null +++ b/tests/parse/extensions.rs @@ -0,0 +1,201 @@ +use forge::{Forge, Json}; + +use crate::common::{method, parse, parse_err}; + +/// Returns a document with one method, `a.get`, whose operation also has +/// `fields`, which end with a comma. +fn operation_with(fields: &str) -> String { + format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{{fields} + "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + ) +} + +/// Returns the names of the extensions of the method at `path`, in order. +fn names<'f>(forge: &'f Forge, path: &str) -> Vec<&'f str> { + method(forge, path).extensions().map(|(name, _)| name).collect() +} + +#[test] +fn extensions_are_kept_in_the_order_they_are_written() { + let forge = parse(&operation_with( + r#""x-b": {"k": [1, 2.5, null]}, "summary": "s", "x-a": true, "x-c": null, "x-d": -3,"#, + )); + + let get = method(&forge, "a.get"); + let extensions: Vec<(&str, &Json)> = get.extensions().collect(); + let object = Json::Object(vec![( + "k".into(), + Json::Array(vec![Json::Number(1.0), Json::Number(2.5), Json::Null]), + )]); + assert_eq!( + extensions, + [ + ("x-b", &object), + ("x-a", &Json::Bool(true)), + ("x-c", &Json::Null), + ("x-d", &Json::Number(-3.0)), + ] + ); + assert_eq!(get.extensions().len(), 4); +} + +#[test] +fn extension_is_found_by_name() { + let forge = parse(&operation_with( + r#""x-forge-params": {"zone": {"flag": "z", "positional": false}, "page": {}},"#, + )); + + let params = method(&forge, "a.get").extension("x-forge-params").unwrap(); + let names: Vec<&str> = params.as_object().unwrap().iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(names, ["zone", "page"]); + let zone = params.get("zone").unwrap(); + assert_eq!(zone.get("flag").and_then(Json::as_str), Some("z")); + assert_eq!(zone.get("positional").and_then(Json::as_bool), Some(false)); +} + +#[test] +fn missing_extension_is_none() { + let forge = parse(&operation_with(r#""x-a": 1,"#)); + + assert_eq!(method(&forge, "a.get").extension("x-b"), None); +} + +#[test] +fn method_without_extensions_has_none() { + let forge = parse(&operation_with("")); + + let get = method(&forge, "a.get"); + assert_eq!(get.extensions().len(), 0); + assert_eq!(get.extension("x-fern-sdk-method-name"), None); +} + +#[test] +fn keys_that_are_not_extensions_are_not_kept() { + let forge = parse(&operation_with( + r#""operationId": "getA", "tags": ["a"], "X-a": 1, "x-b": 2,"#, + )); + + assert_eq!(names(&forge, "a.get"), ["x-b"]); +} + +#[test] +fn extensions_forge_reads_are_not_kept() { + let forge = parse(&operation_with(r#""x-fern-ignore": false, "x-a": 1,"#)); + + let get = method(&forge, "a.get"); + assert_eq!(names(&forge, "a.get"), ["x-a"]); + assert_eq!(get.extension("x-fern-sdk-group-name"), None); + assert_eq!(get.extension("x-fern-ignore"), None); +} + +#[test] +fn availability_hidden_and_confirmation_are_not_kept() { + let forge = parse(&operation_with( + r#""x-a": 1, "x-fern-availability": "beta", "x-forge-hidden": true, + "x-forge-require-confirmation": "Sure?", "x-b": 2,"#, + )); + + let get = method(&forge, "a.get"); + assert_eq!(names(&forge, "a.get"), ["x-a", "x-b"]); + assert_eq!(get.extension("x-fern-availability"), None); + assert_eq!(get.extension("x-forge-hidden"), None); + assert_eq!(get.extension("x-forge-require-confirmation"), None); +} + +#[test] +fn extensions_forge_reads_are_still_read() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "get": {"x-fern-sdk-group-name": ["a", "b"], "x-fern-sdk-method-name": "list", "x-a": 1, + "responses": {"204": {"description": "ok"}}}, + "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", "x-fern-ignore": true, + "x-a": 1, "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + assert_eq!(names(&forge, "a.b.list"), ["x-a"]); + assert_eq!(forge.groups().flat_map(|group| group.methods()).count(), 1); +} + +#[test] +fn repeated_extension_has_the_last_value_in_the_place_of_the_first() { + let forge = parse(&operation_with(r#""x-a": 1, "x-b": 2, "x-a": {"c": 3},"#)); + + let get = method(&forge, "a.get"); + assert_eq!(names(&forge, "a.get"), ["x-a", "x-b"]); + assert_eq!(get.extension("x-a").and_then(|a| a.get("c")), Some(&Json::Number(3.0))); +} + +#[test] +fn repeated_extension_forge_reads_is_still_an_error() { + let err = parse_err(&operation_with(r#""x-fern-ignore": false, "x-fern-ignore": false,"#)); + + assert!(err.contains("duplicate field `x-fern-ignore`"), "{err}"); +} + +#[test] +fn each_method_keeps_its_own_extensions() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "x-a": "get", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}, + "/c": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", "x-a": "create", + "x-b": [], "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + let a = |path| method(&forge, path).extension("x-a").and_then(Json::as_str); + assert_eq!(a("a.get"), Some("get")); + assert_eq!(a("a.list"), None); + assert_eq!(a("a.create"), Some("create")); + assert_eq!(names(&forge, "a.get"), ["x-a"]); + assert_eq!(names(&forge, "a.create"), ["x-a", "x-b"]); +} + +#[test] +fn extensions_outside_the_operation_are_not_kept() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "x-root": 1, + "paths": {"/a": {"x-path-item": 1, "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "q", "in": "query", "x-parameter": 1, "schema": {"type": "string", "x-schema": 1}}], + "responses": {"204": {"description": "ok", "x-response": 1}}}}} + }"#, + ); + + assert_eq!(method(&forge, "a.get").extensions().len(), 0); +} + +#[test] +fn operation_of_a_referenced_path_item_keeps_its_extensions() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"$ref": "#/components/pathItems/A"}, + "/b": {"get": {"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "get", "x-b": 2, + "responses": {"204": {"description": "ok"}}}} + }, + "components": {"pathItems": { + "A": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "x-a": 1, + "responses": {"204": {"description": "ok"}}}} + }} + }"##, + ); + + assert_eq!(names(&forge, "a.get"), ["x-a"]); + assert_eq!(names(&forge, "b.get"), ["x-b"]); +} diff --git a/tests/parse/groups.rs b/tests/parse/groups.rs new file mode 100644 index 000000000..18618c70e --- /dev/null +++ b/tests/parse/groups.rs @@ -0,0 +1,430 @@ +use forge::{Group, Name}; + +use crate::common::{group, method, parse, parse_err}; + +#[test] +fn string_group_name_splits_on_dots() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let members = group(&forge, "accounts.members"); + assert_eq!(members.parent(), Some(group(&forge, "accounts"))); +} + +#[test] +fn array_group_name_has_a_group_per_element() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": ["accounts", "members"], "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let members = group(&forge, "accounts.members"); + assert_eq!(members.parent(), Some(group(&forge, "accounts"))); +} + +#[test] +fn array_element_with_a_dot_is_one_group() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": ["zones.settings"], "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let groups: Vec = forge.groups().collect(); + let [settings] = groups[..] else { + panic!("want one group, got {groups:?}"); + }; + assert_eq!(settings.name(), &Name::new("zones.settings")); + assert_eq!(settings.parent(), None); +} + +#[test] +fn groups_with_equal_names_are_one_group() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "abuse-reports", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": ["abuse_reports"], "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + assert_eq!(forge.groups().count(), 1); + assert_eq!(group(&forge, "abuse_reports").methods().len(), 2); +} + +#[test] +fn subgroup_and_method_can_share_a_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"delete": {"x-fern-sdk-group-name": "members", "x-fern-sdk-method-name": "delete", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"post": {"x-fern-sdk-group-name": "members.delete", "x-fern-sdk-method-name": "bulk", + "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + method(&forge, "members.delete"); + method(&forge, "members.delete.bulk"); +} + +#[test] +fn operation_without_a_group_is_not_a_method() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(forge.groups().count(), 0); +} + +#[test] +fn empty_group_name_is_no_group() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(forge.groups().count(), 0); +} + +#[test] +fn operation_without_a_method_name_is_not_a_method() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(forge.groups().count(), 0); +} + +#[test] +fn empty_method_name_is_not_a_method() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(forge.groups().count(), 0); +} + +#[test] +fn ignored_operation_is_not_a_method() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}, + "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", "x-fern-ignore": true, + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let names: Vec<&Name> = group(&forge, "a").methods().map(|method| method.name()).collect(); + assert_eq!(names, [&Name::new("list")]); +} + +#[test] +fn group_name_without_letters_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts..members", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("GET /a"), "{err}"); +} + +#[test] +fn duplicate_method_in_a_group_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + assert!(err.contains("GET /a") && err.contains("GET /b"), "{err}"); +} + +#[test] +fn method_names_are_equal_as_names() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list_all", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "listAll", + "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + assert!(err.contains("GET /a") && err.contains("GET /b"), "{err}"); +} + +#[test] +fn groups_are_depth_first() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "a.d", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + // Each group is followed by everything below it, whatever the order of + // siblings. + let groups: Vec = forge.groups().collect(); + assert_eq!(groups.len(), 5); + for (i, group) in groups.iter().enumerate() { + let below = group.descendants().count(); + for after in &groups[i + 1..i + 1 + below] { + assert!( + iter_parents(*after).any(|parent| parent == *group), + "{after:?} follows {group:?} but is not below it, in {groups:?}" + ); + } + } +} + +fn iter_parents(group: Group<'_>) -> impl Iterator> { + std::iter::successors(group.parent(), |group| group.parent()) +} + +#[test] +fn group_description_comes_from_x_forge_commands() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}}, + "x-forge-commands": {"accounts": {"description": "Accounts."}} + }"#, + ); + + assert_eq!(group(&forge, "accounts").description(), "Accounts."); +} + +#[test] +fn subgroup_description_is_in_its_parents_command() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}}, + "x-forge-commands": {"accounts": {"groups": {"members": {"description": "Members."}}}} + }"#, + ); + + assert_eq!(group(&forge, "accounts.members").description(), "Members."); +} + +/// A document with a method in each of `groups`, and `commands` as its +/// `x-forge-commands`. +fn with_commands(groups: &[&str], commands: &str) -> String { + let paths: Vec = groups + .iter() + .enumerate() + .map(|(i, group)| { + format!( + r#""/{i}": {{"get": {{"x-fern-sdk-group-name": "{group}", "x-fern-sdk-method-name": "list", + "responses": {{"204": {{"description": "ok"}}}}}}}}"# + ) + }) + .collect(); + format!( + r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{{}}}, "x-forge-commands": {commands}}}"#, + paths.join(", ") + ) +} + +#[test] +fn dotted_group_key_describes_a_subgroup() { + let forge = parse(&with_commands( + &["ai.dynamic-routing.deployments", "ai.dynamic-routing"], + r#"{"ai": {"groups": { + "dynamic-routing": {"description": "Routes."}, + "dynamic-routing.deployments": {"description": "Deployments."}}}}"#, + )); + + assert_eq!(group(&forge, "ai.dynamic_routing").description(), "Routes."); + assert_eq!( + group(&forge, "ai.dynamic_routing.deployments").description(), + "Deployments." + ); +} + +#[test] +fn dotted_root_key_describes_a_subgroup() { + let forge = parse(&with_commands( + &["user.analytics"], + r#"{"user.analytics": {"description": "Analytics."}}"#, + )); + + assert_eq!(group(&forge, "user").description(), ""); + assert_eq!(group(&forge, "user.analytics").description(), "Analytics."); +} + +#[test] +fn dotted_key_can_come_before_its_parents_entry() { + let forge = parse(&with_commands( + &["a.b.c", "a.b", "a"], + r#"{"a.b.c": {"description": "C."}, "a": {"description": "A.", "groups": {"b": {"description": "B."}}}}"#, + )); + + assert_eq!(group(&forge, "a").description(), "A."); + assert_eq!(group(&forge, "a.b").description(), "B."); + assert_eq!(group(&forge, "a.b.c").description(), "C."); +} + +#[test] +fn dotted_and_nested_entries_for_a_group_are_an_error() { + let err = parse_err(&with_commands( + &["a.b"], + r#"{"a": {"groups": {"b": {"description": "One."}}}, "a.b": {"description": "Two."}}"#, + )); + + assert!( + err.contains(r#"two entries in x-forge-commands have the name "b""#), + "{err}" + ); +} + +#[test] +fn command_matches_a_group_with_an_equal_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "abuse_reports", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}}, + "x-forge-commands": {"abuse-reports": {"description": "Abuse reports."}} + }"#, + ); + + assert_eq!(group(&forge, "abuse_reports").description(), "Abuse reports."); +} + +#[test] +fn group_without_a_command_has_no_description() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "accounts", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}}}, + "x-forge-commands": {"zones": {"description": "Zones."}} + }"#, + ); + + assert_eq!(group(&forge, "accounts").description(), ""); + assert_eq!(forge.groups().count(), 1); +} + +#[test] +fn commands_with_equal_names_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {}, + "x-forge-commands": {"abuse-reports": {"description": "One."}, "abuse_reports": {"description": "Two."}} + }"#, + ); + + assert!(err.contains("x-forge-commands"), "{err}"); +} + +#[test] +fn groups_are_sorted_by_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "zones", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}, + "/b": {"get": {"x-fern-sdk-group-name": "accounts.members", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "dns", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}, + "/d": {"get": {"x-fern-sdk-group-name": "accounts.billing", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}} + } + }"#, + ); + + let paths: Vec = forge + .groups() + .map(|group| group.path().map(Name::snake).collect::>().join(".")) + .collect(); + assert_eq!( + paths, + ["accounts", "accounts.billing", "accounts.members", "dns", "zones"] + ); +} + +#[test] +fn methods_are_sorted_by_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "responses": {"204": {"description": "ok"}}}, + "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "responses": {"204": {"description": "ok"}}}, + "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let names: Vec = group(&forge, "a") + .methods() + .map(|method| method.name().snake()) + .collect(); + assert_eq!(names, ["create", "delete", "list"]); +} diff --git a/tests/parse/headers.rs b/tests/parse/headers.rs new file mode 100644 index 000000000..49f34a22a --- /dev/null +++ b/tests/parse/headers.rs @@ -0,0 +1,476 @@ +use forge::{Forge, Json, Name, Number, Object, Scalar}; + +use crate::common::{field, field_names, kind, method, model, models, object, parse, parse_err, scalar}; + +/// Returns the headers of the method at `path`, which must have some. +#[track_caller] +fn headers<'f>(forge: &'f Forge, path: &str) -> Object<'f> { + let method = method(forge, path); + let Some(headers) = method.headers() else { + panic!("{method:?} has no headers"); + }; + headers +} + +#[test] +fn header_param_is_a_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Request-ID", "in": "header", "required": true, "description": "Traces the request.", + "schema": {"type": "integer", "format": "int64"}}, + {"name": "If-None-Match", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let headers = headers(&forge, "a.get"); + let request_id = field(headers, "X-Request-ID"); + assert_eq!(request_id.name(), &Name::new("x request id")); + assert!(request_id.is_required()); + assert_eq!(request_id.description(), "Traces the request."); + assert_eq!(scalar(request_id.ty()), Scalar::Number(Number::Int64)); + let if_none_match = field(headers, "If-None-Match"); + assert!(!if_none_match.is_required()); + assert_eq!(scalar(if_none_match.ty()), Scalar::String); +} + +#[test] +fn header_field_is_named_by_x_fern_parameter_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "cf-r2-jurisdiction", "in": "header", "x-fern-parameter-name": "jurisdiction", + "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let jurisdiction = field(headers(&forge, "a.get"), "cf-r2-jurisdiction"); + assert_eq!(jurisdiction.name(), &Name::new("jurisdiction")); +} + +#[test] +fn header_description_is_the_params_or_else_its_schemas() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Own", "in": "header", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, + {"name": "X-Inherited", "in": "header", "schema": {"$ref": "#/components/schemas/Id"}}, + {"name": "X-Titled", "in": "header", "schema": {"type": "string", "title": "Titled"}} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Id": {"type": "string", "description": "An ID."}}} + }"##, + ); + + let headers = headers(&forge, "a.get"); + assert_eq!(field(headers, "X-Own").description(), "Param."); + assert_eq!(field(headers, "X-Inherited").description(), "An ID."); + assert_eq!(field(headers, "X-Titled").description(), ""); +} + +#[test] +fn deprecated_header_is_a_deprecated_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "X-Old", "in": "header", "deprecated": true, "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(field(headers(&forge, "a.get"), "X-Old").is_deprecated()); +} + +#[test] +fn header_is_sensitive_when_its_schema_is_and_never_read_only() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Api-Key", "in": "header", "schema": {"type": "string", "x-sensitive": true, "readOnly": true}}, + {"name": "X-Auth-Token", "in": "header", "schema": {"$ref": "#/components/schemas/Token"}}, + {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Token": {"type": "string", "x-sensitive": true, "readOnly": true}}} + }"##, + ); + + let headers = headers(&forge, "a.get"); + for name in ["X-Api-Key", "X-Auth-Token"] { + assert!(field(headers, name).is_sensitive(), "{name}"); + assert!(!field(headers, name).is_read_only(), "{name}"); + } + assert!(!field(headers, "X-Trace").is_sensitive()); +} + +#[test] +fn header_has_the_default_of_its_schema() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Version", "in": "header", "schema": {"type": "string", "default": "2024-01-01"}}, + {"name": "X-Retries", "in": "header", "schema": {"$ref": "#/components/schemas/Retries"}}, + {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Retries": {"type": "integer", "default": 3}}} + }"##, + ); + + let headers = headers(&forge, "a.get"); + assert_eq!( + field(headers, "X-Version").default(), + Some(&Json::String("2024-01-01".into())) + ); + assert_eq!(field(headers, "X-Retries").default(), Some(&Json::Number(3.0))); + assert_eq!(field(headers, "X-Trace").default(), None); +} + +#[test] +fn header_param_ref_is_followed() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"parameters": {"IdempotencyKey": {"name": "Idempotency-Key", "in": "header", + "description": "Makes retries safe.", "schema": {"type": "string"}}}} + }"##, + ); + + let key = field(headers(&forge, "a.create"), "Idempotency-Key"); + assert_eq!(key.description(), "Makes retries safe."); +} + +#[test] +fn path_item_headers_belong_to_each_operation() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "X-Account", "in": "header", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + for name in ["a.get", "a.delete"] { + assert_eq!(field_names(headers(&forge, name)), ["X-Account"], "{name}"); + } +} + +#[test] +fn operation_header_replaces_the_path_item_header() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "X-Limit", "in": "header", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "X-Limit", "in": "header", "required": true, "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let headers = headers(&forge, "a.get"); + assert_eq!(field_names(headers), ["X-Limit"]); + let limit = field(headers, "X-Limit"); + assert!(limit.is_required()); + assert_eq!(scalar(limit.ty()), Scalar::Number(Number::Int)); +} + +#[test] +fn operation_header_replaces_the_path_item_header_ignoring_case() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "X-Request-ID", "in": "header", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "x-request-id", "in": "header", "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let headers = headers(&forge, "a.get"); + assert_eq!(field_names(headers), ["x-request-id"]); + assert_eq!(scalar(field(headers, "x-request-id").ty()), Scalar::Number(Number::Int)); +} + +#[test] +fn query_param_does_not_replace_a_header_with_its_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "version", "in": "header", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "version", "in": "query", "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let get = method(&forge, "a.get"); + assert_eq!(field_names(get.headers().unwrap()), ["version"]); + assert_eq!(field_names(object(get.request().unwrap())), ["version"]); +} + +#[test] +fn headers_are_in_declared_order_the_path_items_first() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [ + {"name": "X-First", "in": "header", "schema": {"type": "string"}}, + {"name": "X-Second", "in": "header", "schema": {"type": "string"}} + ], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Third", "in": "header", "schema": {"type": "string"}}, + {"name": "x-first", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + assert_eq!( + field_names(headers(&forge, "a.get")), + ["x-first", "X-Second", "X-Third"] + ); +} + +#[test] +fn headers_with_equal_names_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Rate-Limit", "in": "header", "schema": {"type": "string"}}, + {"name": "X_Rate_Limit", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!( + err.contains("GET /a") && err.contains("X-Rate-Limit") && err.contains("X_Rate_Limit"), + "{err}" + ); +} + +#[test] +fn path_item_headers_differing_in_case_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [ + {"name": "X-A", "in": "header", "schema": {"type": "string"}}, + {"name": "x-a", "in": "header", "schema": {"type": "string"}} + ], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + assert!(err.contains("X-A") && err.contains("x-a"), "{err}"); +} + +#[test] +fn header_and_param_with_equal_names_are_not_an_error() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{account_id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "Account-Id", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(field_names(headers(&forge, "a.get")), ["Account-Id"]); +} + +#[test] +fn cookie_params_are_not_headers() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "session", "in": "cookie", "schema": {"type": "string"}}, + {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(field_names(headers(&forge, "a.get")), ["X-Trace"]); +} + +#[test] +fn method_with_only_cookie_params_has_no_headers() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "session", "in": "cookie", "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(method(&forge, "a.get").headers().is_none()); +} + +#[test] +fn accept_content_type_and_authorization_headers_are_kept() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "parameters": [ + {"name": "Content-Type", "in": "header", "required": true, "schema": {"type": "string"}}, + {"name": "Content-Length", "in": "header", "schema": {"type": "integer"}}, + {"name": "Accept", "in": "header", "schema": {"type": "string"}}, + {"name": "Authorization", "in": "header", "schema": {"type": "string"}} + ], + "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let headers = headers(&forge, "a.upload"); + assert_eq!( + field_names(headers), + ["Content-Type", "Content-Length", "Accept", "Authorization"] + ); + assert!(field(headers, "Content-Type").is_required()); +} + +#[test] +fn method_without_header_params_has_no_headers() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let list = method(&forge, "a.list"); + assert!(list.headers().is_none(), "got {:?}", list.headers()); +} + +#[test] +fn headers_are_not_in_the_request() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "parameters": [ + {"name": "page", "in": "query", "schema": {"type": "integer"}}, + {"name": "X-Trace", "in": "header", "schema": {"type": "string"}} + ], + "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let create = method(&forge, "a.create"); + assert_eq!(field_names(object(create.request().unwrap())), ["page", "body"]); + assert_eq!(create.location(field(create.headers().unwrap(), "X-Trace")), None); +} + +#[test] +fn method_with_only_headers_has_no_request() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let get = method(&forge, "a.get"); + assert!(get.request().is_none(), "got {:?}", get.request()); + assert_eq!(field_names(get.headers().unwrap()), ["X-Trace"]); +} + +#[test] +fn model_used_only_by_a_header_is_a_model() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "cf-r2-storage-class", "in": "header", "schema": {"$ref": "#/components/schemas/StorageClass"}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"StorageClass": {"type": "string", "enum": ["Standard", "InfrequentAccess"]}}} + }"##, + ); + + assert_eq!(models(&forge), ["StorageClass"]); + let class = field(headers(&forge, "a.b.get"), "cf-r2-storage-class"); + assert_eq!(kind(class.ty()), "Model(StorageClass)"); + let group = model(&forge, "StorageClass").group().unwrap(); + assert_eq!(group.path().collect::>(), [&Name::new("a"), &Name::new("b")]); +} + +#[test] +fn header_with_content_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "X-Filter", "in": "header", "content": {"application/json": {"schema": { + "type": "object", "properties": {"status": {"type": "string"}} + }}}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!( + err.contains("GET /a") && err.contains("X-Filter") && err.contains("content"), + "{err}" + ); +} + +#[test] +fn header_style_other_than_simple_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "X-Ids", "in": "header", "style": "form", + "schema": {"type": "array", "items": {"type": "string"}}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("X-Ids") && err.contains("form"), "{err}"); +} diff --git a/tests/parse/main.rs b/tests/parse/main.rs new file mode 100644 index 000000000..a7f49e5d8 --- /dev/null +++ b/tests/parse/main.rs @@ -0,0 +1,15 @@ +//! Tests for reading a `Forge` from an OpenAPI document, one module per +//! area. + +mod aliases; +mod bodies; +mod common; +mod document; +mod extensions; +mod groups; +mod headers; +mod methods; +mod models; +mod requests; +mod responses; +mod types; diff --git a/tests/parse/methods.rs b/tests/parse/methods.rs new file mode 100644 index 000000000..5daea4ea9 --- /dev/null +++ b/tests/parse/methods.rs @@ -0,0 +1,355 @@ +use forge::{Availability, HttpMethod, Name}; + +use crate::common::{group, method, parse, parse_err}; + +/// Returns a document with one method, `a.get`, at `/a`, whose operation +/// also has `fields`, which end with a comma. +fn operation_with(fields: &str) -> String { + format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{{fields} + "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + ) +} + +#[test] +fn each_operation_field_is_its_http_method() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "put", "responses": {"204": {"description": "ok"}}}, + "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "post", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}}, + "options": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "options", "responses": {"204": {"description": "ok"}}}, + "head": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "head", "responses": {"204": {"description": "ok"}}}, + "patch": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "patch", "responses": {"204": {"description": "ok"}}}, + "trace": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "trace", "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + for (name, want) in [ + ("get", HttpMethod::Get), + ("put", HttpMethod::Put), + ("post", HttpMethod::Post), + ("delete", HttpMethod::Delete), + ("options", HttpMethod::Options), + ("head", HttpMethod::Head), + ("patch", HttpMethod::Patch), + ("trace", HttpMethod::Trace), + ] { + let method = method(&forge, &format!("a.{name}")); + assert_eq!(method.http_method(), &want); + assert_eq!(method.path(), "/a"); + } +} + +#[test] +fn query_operation_is_query() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"query": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "search", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(method(&forge, "a.search").http_method(), &HttpMethod::Query); +} + +#[test] +fn additional_operation_is_its_method_as_written() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"additionalOperations": { + "COPY": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "copy", + "responses": {"204": {"description": "ok"}}} + }}} + }"#, + ); + + assert_eq!( + method(&forge, "a.copy").http_method(), + &HttpMethod::Other("COPY".to_string()) + ); +} + +#[test] +fn additional_operation_with_the_method_of_a_field_is_that_variant() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"additionalOperations": { + "GET": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}} + }}} + }"#, + ); + + assert_eq!(method(&forge, "a.get").http_method(), &HttpMethod::Get); +} + +#[test] +fn additional_operation_methods_are_case_sensitive() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"additionalOperations": { + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}} + }}} + }"#, + ); + + assert_eq!( + method(&forge, "a.get").http_method(), + &HttpMethod::Other("get".to_string()) + ); +} + +#[test] +fn method_has_the_summary_description_and_deprecation_of_its_operation() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "summary": "Get an a.", "description": "Gets an a.", "deprecated": true, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let get = method(&forge, "a.get"); + assert_eq!(get.summary(), "Get an a."); + assert_eq!(get.description(), "Gets an a."); + assert!(get.is_deprecated()); +} + +#[test] +fn method_without_docs_is_empty_and_not_deprecated() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let get = method(&forge, "a.get"); + assert_eq!(get.summary(), ""); + assert_eq!(get.description(), ""); + assert!(!get.is_deprecated()); +} + +#[test] +fn path_item_ref_is_followed() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"$ref": "#/components/pathItems/A"}}, + "components": {"pathItems": { + "A": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}} + }} + }"##, + ); + + assert_eq!(method(&forge, "a.get").path(), "/a"); +} + +#[test] +fn extensions_in_paths_and_path_items_are_not_operations() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "x-paths-extension": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}, + "/a": { + "x-forge-commands": {"a": {"description": "A."}}, + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}} + } + } + }"#, + ); + + assert_eq!(forge.groups().count(), 1); + assert_eq!(group(&forge, "a").methods().len(), 1); +} + +#[test] +fn additional_operation_with_the_method_of_a_present_field_is_skipped() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}, + "additionalOperations": {"GET": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "shadowed", + "responses": {"204": {"description": "ok"}}}} + }} + }"#, + ); + + let names: Vec<&Name> = group(&forge, "a").methods().map(|method| method.name()).collect(); + assert_eq!(names, [&Name::new("get")]); +} + +#[test] +fn webhooks_are_not_methods() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "webhooks": {"newPet": {"post": {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "created", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(forge.groups().count(), 0); +} + +#[test] +fn callbacks_are_not_methods() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/subscribe": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "subscribe", + "callbacks": {"event": {"{$request.query.url}": {"post": { + "x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "event", + "responses": {"204": {"description": "ok"}} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(group(&forge, "a").methods().len(), 1); +} + +#[test] +fn method_without_the_extensions_is_listed_unconfirmed_and_of_no_availability() { + let forge = parse(&operation_with("")); + + let get = method(&forge, "a.get"); + assert!(!get.is_hidden()); + assert_eq!(get.confirmation(), None); + assert_eq!(get.availability(), None); +} + +#[test] +fn hidden_method_is_hidden_and_still_a_method() { + let forge = parse(&operation_with(r#""x-forge-hidden": true,"#)); + + assert!(method(&forge, "a.get").is_hidden()); +} + +#[test] +fn method_hidden_false_is_not_hidden() { + let forge = parse(&operation_with(r#""x-forge-hidden": false,"#)); + + assert!(!method(&forge, "a.get").is_hidden()); +} + +#[test] +fn hidden_that_is_not_a_boolean_is_an_error() { + let err = parse_err(&operation_with(r#""x-forge-hidden": "true","#)); + + assert!(err.contains("expected a boolean"), "{err}"); +} + +#[test] +fn confirmation_is_the_sentence_as_written() { + let forge = parse(&operation_with( + r#""x-forge-require-confirmation": "This operation deletes the database.","#, + )); + + assert_eq!( + method(&forge, "a.get").confirmation(), + Some("This operation deletes the database.") + ); +} + +#[test] +fn empty_confirmation_is_some() { + let forge = parse(&operation_with(r#""x-forge-require-confirmation": "","#)); + + assert_eq!(method(&forge, "a.get").confirmation(), Some("")); +} + +#[test] +fn confirmation_that_is_not_a_string_is_an_error() { + let err = parse_err(&operation_with(r#""x-forge-require-confirmation": true,"#)); + + assert!(err.contains("expected a string"), "{err}"); +} + +#[test] +fn each_availability_is_its_variant() { + for (written, want) in [ + ("alpha", Availability::Alpha), + ("beta", Availability::Beta), + ("preview", Availability::Preview), + ("generally-available", Availability::GenerallyAvailable), + ("deprecated", Availability::Deprecated), + ] { + let forge = parse(&operation_with(&format!(r#""x-fern-availability": "{written}","#))); + + assert_eq!(method(&forge, "a.get").availability(), Some(want), "{written}"); + assert_eq!(want.as_str(), written); + } +} + +#[test] +fn unknown_availability_is_an_error_naming_the_operation() { + let err = parse_err(&operation_with(r#""x-fern-availability": "stable","#)); + + assert!(err.contains(r#"GET /a: x-fern-availability "stable" is not"#), "{err}"); +} + +#[test] +fn availability_is_case_sensitive() { + let err = parse_err(&operation_with(r#""x-fern-availability": "Beta","#)); + + assert!(err.contains(r#"GET /a: x-fern-availability "Beta" is not"#), "{err}"); +} + +#[test] +fn availability_that_is_not_a_string_is_an_error() { + let err = parse_err(&operation_with(r#""x-fern-availability": 1,"#)); + + assert!(err.contains("expected a string"), "{err}"); +} + +#[test] +fn availability_and_deprecation_are_apart() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "deprecated": true, "x-fern-availability": "generally-available", + "responses": {"204": {"description": "ok"}}}, + "put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "put", + "x-fern-availability": "deprecated", + "responses": {"204": {"description": "ok"}}}, + "post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "post", + "deprecated": true, "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let get = method(&forge, "a.get"); + assert!(get.is_deprecated()); + assert_eq!(get.availability(), Some(Availability::GenerallyAvailable)); + let put = method(&forge, "a.put"); + assert!(!put.is_deprecated()); + assert_eq!(put.availability(), Some(Availability::Deprecated)); + let post = method(&forge, "a.post"); + assert!(post.is_deprecated()); + assert_eq!(post.availability(), None); +} diff --git a/tests/parse/models.rs b/tests/parse/models.rs new file mode 100644 index 000000000..d6373aafe --- /dev/null +++ b/tests/parse/models.rs @@ -0,0 +1,323 @@ +use forge::{Name, Scalar}; + +use crate::common::{ + field, group, method, model, model_of, models, object, parse, responding_with_schemas, response_type, scalar, +}; + +#[test] +fn response_model_is_placed_in_its_group() { + let forge = parse( + r##"{ + "openapi": "3.0.3", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": { + "/zones": { + "get": { + "x-fern-sdk-group-name": "zones", + "x-fern-sdk-method-name": "list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Zone" } } + } + } + } + } + } + }, + "components": { + "schemas": { + "Zone": { "type": "object", "properties": { "id": { "type": "string" } } } + } + } + }"##, + ); + + assert_eq!(model(&forge, "Zone").group(), Some(group(&forge, "zones"))); +} + +#[test] +fn model_used_in_sibling_groups_is_in_their_parent() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/c": {"get": {"x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, + "/d": {"get": {"x-fern-sdk-group-name": "a.b.d", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} + }, + "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(model(&forge, "M").group(), Some(group(&forge, "a.b"))); +} + +#[test] +fn model_used_in_a_group_and_its_subgroup_is_in_the_group() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "a.b.c", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} + }, + "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(model(&forge, "M").group(), Some(group(&forge, "a"))); +} + +#[test] +fn model_used_in_top_level_groups_is_at_the_top_level() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, + "/x": {"get": {"x-fern-sdk-group-name": "x", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}} + }, + "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + let m = model(&forge, "M"); + assert_eq!(m.group(), None); + assert_eq!(forge.models().collect::>(), [m]); +} + +#[test] +fn model_used_through_another_model_is_placed_by_every_use() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/b": {"get": {"x-fern-sdk-group-name": "a.b", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Outer"}}}}}}}, + "/c": {"get": {"x-fern-sdk-group-name": "a.c", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Inner"}}}}}}} + }, + "components": {"schemas": { + "Outer": {"type": "object", "properties": {"inner": {"$ref": "#/components/schemas/Inner"}}}, + "Inner": {"type": "object", "properties": {"id": {"type": "string"}}} + }} + }"##, + ); + + assert_eq!(model(&forge, "Outer").group(), Some(group(&forge, "a.b"))); + assert_eq!(model(&forge, "Inner").group(), Some(group(&forge, "a"))); +} + +#[test] +fn params_bodies_and_errors_use_models() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "parameters": [{"name": "filter", "in": "query", "schema": {"$ref": "#/components/schemas/Filter"}}], + "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Input"}}}}, + "responses": {"400": {"description": "no", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Failure"}}}}}}}}, + "components": {"schemas": { + "Filter": {"type": "string"}, + "Input": {"type": "object", "properties": {"name": {"type": "string"}}}, + "Failure": {"type": "object", "properties": {"message": {"type": "string"}}} + }} + }"##, + ); + + let a = group(&forge, "a"); + for name in ["Filter", "Input", "Failure"] { + assert_eq!(model(&forge, name).group(), Some(a), "{name}"); + } +} + +#[test] +fn schema_no_method_uses_is_not_a_model() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Used"}}}}}}}, + "/ignored": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "ignored", "x-fern-ignore": true, + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ignored"}}}}}}}, + "/ungrouped": {"get": {"x-fern-sdk-method-name": "ungrouped", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Ungrouped"}}}}}}} + }, + "components": {"schemas": { + "Used": {"type": "string"}, + "Ignored": {"type": "string"}, + "Ungrouped": {"type": "string"}, + "Unused": {"type": "string"} + }} + }"##, + ); + + assert_eq!(models(&forge), ["Used"]); +} + +#[test] +fn model_can_refer_to_itself() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Node"}"##, + r##"{"Node": {"type": "object", "properties": {"next": {"$ref": "#/components/schemas/Node"}}}}"##, + )); + + let node = model(&forge, "Node"); + assert_eq!(model_of(field(object(node.ty()), "next").ty()), node); + assert_eq!(node.group(), Some(group(&forge, "a"))); +} + +#[test] +fn model_is_named_after_its_schema() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/zone_settings"}"##, + r#"{"zone_settings": {"type": "string"}}"#, + )); + + assert_eq!(model(&forge, "zone_settings").name(), &Name::new("zone_settings")); +} + +#[test] +fn model_is_named_by_x_fern_type_name() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/not_found"}"##, + r#"{"not_found": {"type": "object", "x-fern-type-name": "Missing"}}"#, + )); + + assert_eq!(model(&forge, "not_found").name(), &Name::new("Missing")); +} + +#[test] +fn model_description_is_its_description_or_else_title() { + let forge = parse(&responding_with_schemas( + r##"{"anyOf": [{"$ref": "#/components/schemas/Described"}, {"$ref": "#/components/schemas/Titled"}]}"##, + r#"{ + "Described": {"type": "string", "title": "Title.", "description": "Description."}, + "Titled": {"type": "integer", "title": "Title."} + }"#, + )); + + assert_eq!(model(&forge, "Described").description(), "Description."); + assert_eq!(model(&forge, "Titled").description(), "Title."); +} + +#[test] +fn model_can_be_deprecated() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Old"}"##, + r#"{"Old": {"type": "string", "deprecated": true}}"#, + )); + + assert!(model(&forge, "Old").is_deprecated()); +} + +#[test] +fn schema_in_defs_is_a_model() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tree/$defs/Node"}}}}}}}}, + "components": {"schemas": { + "Tree": {"type": "object", "$defs": {"Node": {"type": "string"}}} + }} + }"##, + ); + + let node = model_of(response_type(&forge, "a.get")); + assert_eq!(node.schema_name(), "Tree/$defs/Node"); + assert_eq!(node.name(), &Name::new("Tree Node")); + assert_eq!(models(&forge), ["Tree/$defs/Node"]); +} + +#[test] +fn model_that_is_a_ref_is_the_other_model() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Alias"}"##, + r##"{"Alias": {"$ref": "#/components/schemas/Base"}, "Base": {"type": "string"}}"##, + )); + + let base = model_of(model(&forge, "Alias").ty()); + assert_eq!(base.schema_name(), "Base"); + assert_eq!(scalar(base.ty()), Scalar::String); + assert_eq!(base.group(), Some(group(&forge, "a"))); +} + +#[test] +fn schema_used_only_by_content_that_is_not_json_is_a_model() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a.files", "x-fern-sdk-method-name": "upload", + "requestBody": {"content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Upload"}}}}, + "responses": {"200": {"description": "ok", "content": {"text/csv": {"schema": {"$ref": "#/components/schemas/Report"}}}}}}}}, + "components": {"schemas": { + "Upload": {"type": "object", "properties": {"file": {"type": "string", "format": "binary"}}}, + "Report": {"type": "string"} + }} + }"##, + ); + + assert_eq!(models(&forge), ["Report", "Upload"]); + let files = group(&forge, "a.files"); + assert_eq!(model(&forge, "Upload").group(), Some(files)); + assert_eq!(model(&forge, "Report").group(), Some(files)); + let upload = method(&forge, "a.files.upload") + .body() + .unwrap() + .contents() + .next() + .unwrap(); + let upload = model_of(upload.ty()); + assert_eq!(upload.schema_name(), "Upload"); + assert_eq!(scalar(field(object(upload.ty()), "file").ty()), Scalar::Binary); +} + +#[test] +fn use_by_content_that_is_not_json_places_a_model() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": { + "/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/M"}}}}}}}, + "/b": {"post": {"x-fern-sdk-group-name": "b", "x-fern-sdk-method-name": "upload", + "requestBody": {"content": {"multipart/form-data": {"schema": { + "type": "object", "properties": {"meta": {"$ref": "#/components/schemas/M"}} + }}}}, + "responses": {"204": {"description": "ok"}}}} + }, + "components": {"schemas": {"M": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(model(&forge, "M").group(), None); +} + +#[test] +fn schema_used_only_in_a_response_header_is_not_a_model() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "X-Trace", "in": "header", "schema": {"$ref": "#/components/schemas/Trace"}}], + "responses": {"204": {"description": "ok", + "headers": {"X-Rate-Limit": {"schema": {"$ref": "#/components/schemas/RateLimit"}}}}}}}}, + "components": {"schemas": { + "Trace": {"type": "string"}, + "RateLimit": {"type": "integer"} + }} + }"##, + ); + + // A header parameter's schema is a use, but a response header's is not. + assert_eq!(models(&forge), ["Trace"]); +} diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs new file mode 100644 index 000000000..31ad98776 --- /dev/null +++ b/tests/parse/requests.rs @@ -0,0 +1,1129 @@ +use forge::{Json, Location, Name, Number, Scalar, Type}; + +use crate::common::{ + field, field_names, intersection, kind, kinds, method, model_of, object, parse, parse_err, scalar, +}; + +#[test] +fn path_param_is_a_required_request_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": { + "/zones/{zone_id}": { + "get": { + "x-fern-sdk-group-name": "zones", + "x-fern-sdk-method-name": "get", + "parameters": [ + { "name": "zone_id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "OK" } } + } + } + } + }"#, + ); + + let get = method(&forge, "zones.get"); + let Some(Type::Object(params)) = get.request() else { + panic!("want an object request, got {:?}", get.request()); + }; + let zone_id = field(params, "zone_id"); + assert!(zone_id.is_required()); + assert!( + matches!(zone_id.ty(), Type::Scalar(Scalar::String)), + "got {:?}", + zone_id.ty() + ); + assert_eq!(get.location(zone_id), Some(Location::Path)); +} + +#[test] +fn query_param_is_optional_and_exploded_by_default() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let list = method(&forge, "a.list"); + let page = field(object(list.request().unwrap()), "page"); + assert!(!page.is_required()); + assert_eq!(list.location(page), Some(Location::Query { exploded: true })); +} + +#[test] +fn query_param_can_be_required() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "page", "in": "query", "required": true, "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let list = method(&forge, "a.list"); + assert!(field(object(list.request().unwrap()), "page").is_required()); +} + +#[test] +fn query_param_with_explode_false_is_not_exploded() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "ids", "in": "query", "style": "form", "explode": false, + "schema": {"type": "array", "items": {"type": "string"}}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let list = method(&forge, "a.list"); + let ids = field(object(list.request().unwrap()), "ids"); + assert_eq!(list.location(ids), Some(Location::Query { exploded: false })); +} + +#[test] +fn header_and_cookie_params_are_not_fields() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "X-Trace", "in": "header", "schema": {"type": "string"}}, + {"name": "session", "in": "cookie", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let get = method(&forge, "a.get"); + assert!(get.request().is_none(), "got {:?}", get.request()); +} + +#[test] +fn param_field_is_named_by_x_fern_parameter_name() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "q", "in": "query", "x-fern-parameter-name": "query", "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let q = field(object(method(&forge, "a.list").request().unwrap()), "q"); + assert_eq!(q.name(), &Name::new("query")); +} + +#[test] +fn param_description_falls_back_to_an_inline_schema() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "own", "in": "query", "description": "Param.", "schema": {"type": "string", "description": "Schema."}}, + {"name": "inherited", "in": "query", "schema": {"type": "string", "description": "Schema."}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "own").description(), "Param."); + assert_eq!(field(params, "inherited").description(), "Schema."); +} + +#[test] +fn param_description_comes_from_a_ref_schema() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "own", "in": "query", "description": "Param.", "schema": {"$ref": "#/components/schemas/Id"}}, + {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Id": {"type": "string", "description": "An ID."}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "own").description(), "Param."); + assert_eq!(field(params, "id").description(), "An ID."); +} + +#[test] +fn param_description_follows_a_chain_of_refs() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Id"}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": { + "Id": {"$ref": "#/components/schemas/Uuid"}, + "Uuid": {"type": "string", "description": "A UUID."} + }} + }"##, + ); + + let params = object(method(&forge, "a.get").request().unwrap()); + assert_eq!(field(params, "id").description(), "A UUID."); +} + +#[test] +fn param_description_is_never_a_schema_title() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "id", "in": "query", "schema": {"$ref": "#/components/schemas/Id"}}, + {"name": "inline", "in": "query", "schema": {"type": "string", "title": "Inline"}} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Id": {"type": "string", "title": "Identifier"}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "id").description(), ""); + assert_eq!(field(params, "inline").description(), ""); +} + +#[test] +fn description_next_to_a_param_schema_ref_is_its_own_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "id", "in": "query", + "schema": {"$ref": "#/components/schemas/Id", "description": "The ID to find."}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Id": {"type": "string", "description": "An ID."}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "id").description(), "The ID to find."); +} + +#[test] +fn param_description_comes_from_an_all_of_member_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "region", "in": "query", + "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Region": {"type": "string", "description": "A region."}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "region").description(), "A region."); +} + +#[test] +fn param_description_from_an_all_of_member_is_ignored_in_3_0() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "region", "in": "query", + "schema": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Region": {"type": "string", "description": "A region."}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "region").description(), ""); +} + +#[test] +fn param_ref_description_overrides_the_params_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", + "schema": {"type": "integer"}}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "page").description(), "The page of pets."); +} + +#[test] +fn param_ref_description_is_ignored_in_3_0() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"$ref": "#/components/parameters/Page", "description": "The page of pets."}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", + "schema": {"type": "integer"}}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "page").description(), "A page."); +} + +#[test] +fn param_ref_without_a_description_has_the_params() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"$ref": "#/components/parameters/Page"}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"parameters": {"Page": {"name": "page", "in": "query", "description": "A page.", + "schema": {"type": "integer"}}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "page").description(), "A page."); +} + +#[test] +fn param_field_is_never_read_only() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "readOnly": true}}, + {"name": "since", "in": "query", "schema": {"$ref": "#/components/schemas/Since", "readOnly": true}}, + {"name": "page", "in": "query", "schema": {"$ref": "#/components/schemas/Page"}} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": { + "Since": {"type": "string"}, + "Page": {"type": "integer", "readOnly": true} + }} + }"##, + ); + + let params = object(method(&forge, "a.get").request().unwrap()); + for name in ["id", "since", "page"] { + assert!(!field(params, name).is_read_only(), "{name}"); + } +} + +#[test] +fn query_string_property_is_never_read_only() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { + "type": "object", "properties": {"status": {"type": "string", "readOnly": true, "x-sensitive": true}} + }}}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let status = field(object(method(&forge, "a.list").request().unwrap()), "status"); + assert!(!status.is_read_only()); + assert!(status.is_sensitive()); +} + +#[test] +fn param_field_is_sensitive_when_its_schema_is() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "token", "in": "query", "schema": {"type": "string", "x-sensitive": true}}, + {"name": "page", "in": "query", "schema": {"type": "integer"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert!(field(params, "token").is_sensitive()); + assert!(!field(params, "page").is_sensitive()); +} + +#[test] +fn param_field_is_sensitive_when_its_ref_schema_is() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "token", "in": "query", "schema": {"$ref": "#/components/schemas/Token"}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": { + "Token": {"$ref": "#/components/schemas/Secret"}, + "Secret": {"type": "string", "x-sensitive": true} + }} + }"##, + ); + + assert!(field(object(method(&forge, "a.list").request().unwrap()), "token").is_sensitive()); +} + +#[test] +fn sensitive_next_to_a_param_schema_ref_counts_in_3_0() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "token", "in": "query", + "schema": {"$ref": "#/components/schemas/Token", "x-sensitive": true}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Token": {"type": "string"}}} + }"##, + ); + + assert!(field(object(method(&forge, "a.list").request().unwrap()), "token").is_sensitive()); +} + +#[test] +fn sensitive_on_the_param_itself_is_ignored() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "token", "in": "query", "x-sensitive": true, "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(!field(object(method(&forge, "a.list").request().unwrap()), "token").is_sensitive()); +} + +#[test] +fn param_ref_is_followed() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"$ref": "#/components/parameters/Page"}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"parameters": {"Page": {"name": "page", "in": "query", "schema": {"type": "integer"}}}} + }"##, + ); + + let list = method(&forge, "a.list"); + let page = field(object(list.request().unwrap()), "page"); + assert_eq!(list.location(page), Some(Location::Query { exploded: true })); +} + +#[test] +fn path_item_params_belong_to_each_operation() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": { + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + for name in ["a.get", "a.delete"] { + let method = method(&forge, name); + let id = field(object(method.request().unwrap()), "id"); + assert_eq!(method.location(id), Some(Location::Path)); + } +} + +#[test] +fn operation_param_replaces_the_path_item_param() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "page", "in": "query", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field_names(params), ["page"]); + assert_eq!(scalar(field(params, "page").ty()), Scalar::Number(Number::Int)); +} + +#[test] +fn undeclared_path_placeholder_is_a_required_string() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let get = method(&forge, "a.get"); + let id = field(object(get.request().unwrap()), "id"); + assert!(id.is_required()); + assert_eq!(scalar(id.ty()), Scalar::String); + assert_eq!(get.location(id), Some(Location::Path)); +} + +#[test] +fn params_are_in_path_template_then_declared_order() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{second}/{first}": { + "parameters": [{"name": "item_query", "in": "query", "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "op_query", "in": "query", "schema": {"type": "string"}}, + {"name": "first", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "second", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let params = object(method(&forge, "a.get").request().unwrap()); + assert_eq!(field_names(params), ["second", "first", "item_query", "op_query"]); +} + +#[test] +fn query_string_object_is_query_fields() { + let forge = parse( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": { + "type": "object", "properties": {"status": {"type": "string"}, "limit": {"type": "integer"}} + }}}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let list = method(&forge, "a.list"); + let params = object(list.request().unwrap()); + for name in ["status", "limit"] { + let location = list.location(field(params, name)); + assert!(matches!(location, Some(Location::Query { .. })), "{name}: {location:?}"); + } +} + +#[test] +fn no_params_and_no_body_is_no_request() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(method(&forge, "a.get").request().is_none()); +} + +#[test] +fn body_without_params_is_the_request() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(kind(method(&forge, "a.create").request().unwrap()), "Array"); +} + +#[test] +fn object_body_is_intersected_with_the_params() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "type": "object", "properties": {"name": {"type": "string"}} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let update = method(&forge, "a.update"); + let members: Vec = intersection(update.request().unwrap()).members().collect(); + let [params, body] = members[..] else { + panic!("want two members, got {members:?}"); + }; + assert_eq!(update.location(field(object(params), "id")), Some(Location::Path)); + assert_eq!(update.location(field(object(body), "name")), None); +} + +#[test] +fn model_body_is_intersected_with_the_params() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"Pet": {"type": "object", "properties": {"name": {"type": "string"}}}}} + }"##, + ); + + let members: Vec = intersection(method(&forge, "a.update").request().unwrap()) + .members() + .collect(); + assert_eq!( + members.iter().map(|m| kind(*m)).collect::>(), + ["Object", "Model(Pet)"] + ); +} + +#[test] +fn union_body_is_intersected_with_the_params() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "oneOf": [{"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}] + }}}}, + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": { + "Cat": {"type": "object", "properties": {"meows": {"type": "boolean"}}}, + "Dog": {"type": "object", "properties": {"barks": {"type": "boolean"}}} + }} + }"##, + ); + + let members: Vec = intersection(method(&forge, "a.update").request().unwrap()) + .members() + .collect(); + assert_eq!( + members.iter().map(|m| kind(*m)).collect::>(), + ["Object", "Union"] + ); +} + +#[test] +fn array_body_is_the_last_field_of_the_params() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let update = method(&forge, "a.update"); + let params = object(update.request().unwrap()); + assert_eq!(field_names(params), ["id", "body"]); + let body = field(params, "body"); + assert_eq!(kind(body.ty()), "Array"); + assert_eq!(update.location(body), None); +} + +#[test] +fn body_field_is_neither_read_only_nor_sensitive() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "type": "array", "items": {"type": "string"}, "readOnly": true, "x-sensitive": true + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let body = field(object(method(&forge, "a.update").request().unwrap()), "body"); + assert!(!body.is_read_only()); + assert!(!body.is_sensitive()); +} + +#[test] +fn read_only_field_of_a_body_model_is_kept() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}}}}}, + "components": {"schemas": {"Pet": {"type": "object", "properties": { + "id": {"type": "string", "readOnly": true}, "name": {"type": "string"} + }}}} + }"##, + ); + + let pet = object(model_of(method(&forge, "a.create").request().unwrap()).ty()); + assert_eq!(field_names(pet), ["id", "name"]); + assert!(field(pet, "id").is_read_only()); + assert!(!field(pet, "name").is_read_only()); +} + +#[test] +fn map_body_is_a_body_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "type": "object", "additionalProperties": {"type": "string"} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.update").request().unwrap()); + assert_eq!(kind(field(params, "body").ty()), "Object"); +} + +#[test] +fn unknown_body_is_a_body_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": {}}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.update").request().unwrap()); + assert_eq!(scalar(field(params, "body").ty()), Scalar::Unknown); +} + +#[test] +fn nullable_body_is_a_body_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "type": "object", "nullable": true, "properties": {"name": {"type": "string"}} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.update").request().unwrap()); + assert_eq!(kinds(field(params, "body").ty()), ["Null", "Object"]); +} + +#[test] +fn body_with_the_name_of_a_param_is_a_body_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{zone_id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "type": "object", "properties": {"zoneId": {"type": "string"}, "name": {"type": "string"}} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.update").request().unwrap()); + assert_eq!(field_names(params), ["zone_id", "body"]); +} + +#[test] +fn field_of_another_method_has_no_location() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": { + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", "responses": {"204": {"description": "ok"}}}, + "delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let get = method(&forge, "a.get"); + let delete = method(&forge, "a.delete"); + let id = field(object(delete.request().unwrap()), "id"); + assert_eq!(get.location(id), None); +} + +#[test] +fn body_that_is_not_json_is_an_unknown_body_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"multipart/form-data": {"schema": { + "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.upload").request().unwrap()); + assert_eq!(field_names(params), ["id", "body"]); + assert_eq!(scalar(field(params, "body").ty()), Scalar::Unknown); +} + +#[test] +fn body_that_is_only_multipart_is_an_unknown_request() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "upload", + "requestBody": {"content": {"multipart/form-data": {"schema": { + "type": "object", "properties": {"file": {"type": "string", "format": "binary"}} + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let upload = method(&forge, "a.upload"); + assert_eq!(scalar(upload.request().unwrap()), Scalar::Unknown); + assert_eq!(kind(upload.body().unwrap().contents().next().unwrap().ty()), "Object"); +} + +#[test] +fn path_param_missing_from_the_template_is_dropped() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "account_id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!(field_names(object(method(&forge, "a.get").request().unwrap())), ["id"]); +} + +#[test] +fn deprecated_query_param_is_a_deprecated_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "page", "in": "query", "deprecated": true, "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(field(object(method(&forge, "a.list").request().unwrap()), "page").is_deprecated()); +} + +#[test] +fn deprecated_path_param_is_not_a_deprecated_field() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "id", "in": "path", "required": true, "deprecated": true, "schema": {"type": "string"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(!field(object(method(&forge, "a.get").request().unwrap()), "id").is_deprecated()); +} + +#[test] +fn all_of_body_is_one_member_of_the_intersection() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/json": {"schema": { + "allOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}] + }}}}, + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": { + "A": {"type": "object", "properties": {"a": {"type": "string"}}}, + "B": {"type": "object", "properties": {"b": {"type": "string"}}} + }} + }"##, + ); + + let members: Vec = intersection(method(&forge, "a.update").request().unwrap()) + .members() + .collect(); + assert_eq!( + members.iter().map(|m| kind(*m)).collect::>(), + ["Object", "Intersection"] + ); +} + +#[test] +fn param_with_content_is_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "filter", "in": "query", "content": {"application/json": {"schema": { + "type": "object", "properties": {"status": {"type": "string"}} + }}}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("GET /a") && err.contains("filter"), "{err}"); +} + +#[test] +fn query_style_other_than_form_is_an_error() { + for (style, schema) in [ + ("spaceDelimited", r#"{"type": "array", "items": {"type": "string"}}"#), + ("pipeDelimited", r#"{"type": "array", "items": {"type": "string"}}"#), + ( + "deepObject", + r#"{"type": "object", "properties": {"status": {"type": "string"}}}"#, + ), + ] { + let err = parse_err(&format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{{"name": "filter", "in": "query", "style": "{style}", "schema": {schema}}}], + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + )); + + assert!(err.contains(style), "{style}: {err}"); + } +} + +#[test] +fn two_query_string_params_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "one", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}, + {"name": "two", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("GET /a"), "{err}"); +} + +#[test] +fn query_and_query_string_params_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": { + "parameters": [{"name": "page", "in": "query", "schema": {"type": "integer"}}], + "get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "filter", "in": "querystring", "content": {"application/x-www-form-urlencoded": {"schema": {"type": "object"}}}}], + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + assert!(err.contains("GET /a"), "{err}"); +} + +#[test] +fn body_field_is_required_when_the_body_is() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": { + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "replace", + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"204": {"description": "ok"}}}, + "patch": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "append", + "requestBody": {"content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}}}, + "responses": {"204": {"description": "ok"}}} + }} + }"#, + ); + + let body = |name| field(object(method(&forge, name).request().unwrap()), "body"); + assert!(body("a.replace").is_required()); + assert!(!body("a.append").is_required()); +} + +#[test] +fn params_with_equal_names_are_an_error() { + let err = parse_err( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{zone_id}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [ + {"name": "zone_id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "zoneId", "in": "query", "schema": {"type": "string"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert!(err.contains("zone_id") && err.contains("zoneId"), "{err}"); +} + +#[test] +fn path_style_other_than_simple_is_an_error() { + for style in ["label", "matrix"] { + let err = parse_err(&format!( + r#"{{ + "openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, + "paths": {{"/a/{{id}}": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "parameters": [{{"name": "id", "in": "path", "required": true, "style": "{style}", "schema": {{"type": "string"}}}}], + "responses": {{"204": {{"description": "ok"}}}}}}}}}} + }}"# + )); + + assert!(err.contains(style), "{style}: {err}"); + } +} + +#[test] +fn param_field_has_the_default_of_its_schema() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "per_page", "in": "query", "schema": {"type": "integer", "default": 20}}, + {"name": "page", "in": "query", "schema": {"type": "integer"}} + ], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "per_page").default(), Some(&Json::Number(20.0))); + assert_eq!(field(params, "page").default(), None); +} + +#[test] +fn param_field_has_the_default_of_its_ref_schema() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{order}": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [ + {"name": "order", "in": "path", "required": true, "schema": {"$ref": "#/components/schemas/Order"}}, + {"$ref": "#/components/parameters/Direction"} + ], + "responses": {"204": {"description": "ok"}}}}}, + "components": { + "parameters": {"Direction": {"name": "direction", "in": "query", "schema": {"$ref": "#/components/schemas/Direction"}}}, + "schemas": { + "Order": {"$ref": "#/components/schemas/Field"}, + "Field": {"type": "string", "default": "name"}, + "Direction": {"type": "string", "enum": ["asc", "desc"], "default": "asc"} + } + } + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "order").default(), Some(&Json::String("name".into()))); + assert_eq!(field(params, "direction").default(), Some(&Json::String("asc".into()))); +} + +#[test] +fn default_next_to_a_param_schema_ref_is_its_own_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "per_page", "in": "query", + "schema": {"$ref": "#/components/schemas/PerPage", "default": 50}}], + "responses": {"204": {"description": "ok"}}}}}, + "components": {"schemas": {"PerPage": {"type": "integer", "default": 20}}} + }"##, + ); + + let params = object(method(&forge, "a.list").request().unwrap()); + assert_eq!(field(params, "per_page").default(), Some(&Json::Number(50.0))); +} + +#[test] +fn default_on_the_param_itself_is_ignored() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "list", + "parameters": [{"name": "per_page", "in": "query", "default": 20, "schema": {"type": "integer"}}], + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + assert_eq!( + field(object(method(&forge, "a.list").request().unwrap()), "per_page").default(), + None + ); +} + +#[test] +fn body_field_and_undeclared_placeholder_have_no_default() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a/{id}": {"put": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "update", + "requestBody": {"content": {"application/json": {"schema": { + "type": "array", "items": {"type": "string"}, "default": ["x"] + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let params = object(method(&forge, "a.update").request().unwrap()); + assert_eq!(field(params, "body").default(), None); + assert_eq!(field(params, "id").default(), None); +} + +#[test] +fn array_body_keeps_its_max_items() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/json": {"schema": { + "type": "array", "items": {"type": "string"}, "maxItems": 100 + }}}}, + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let Some(Type::Array(body)) = method(&forge, "a.create").request() else { + panic!("want an array request"); + }; + assert_eq!(body.max_items(), Some(100)); +} diff --git a/tests/parse/responses.rs b/tests/parse/responses.rs new file mode 100644 index 000000000..a34167acc --- /dev/null +++ b/tests/parse/responses.rs @@ -0,0 +1,117 @@ +use forge::Scalar; + +use crate::common::{method, parse, scalar}; + +#[test] +fn responses_are_the_2xx_sorted_by_status() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "responses": { + "2XX": {"description": "ok"}, + "201": {"description": "ok"}, + "400": {"description": "no"}, + "200": {"description": "ok"} + }}}} + }"#, + ); + + let statuses: Vec<&str> = method(&forge, "a.create") + .responses() + .map(|response| response.status()) + .collect(); + assert_eq!(statuses, ["200", "201", "2XX"]); +} + +#[test] +fn errors_are_the_rest_sorted_with_default_last() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": { + "default": {"description": "no"}, + "500": {"description": "no"}, + "4XX": {"description": "no"}, + "404": {"description": "no"}, + "200": {"description": "ok"}, + "400": {"description": "no"}, + "304": {"description": "no"} + }}}} + }"#, + ); + + let statuses: Vec<&str> = method(&forge, "a.get") + .errors() + .map(|response| response.status()) + .collect(); + assert_eq!(statuses, ["304", "400", "404", "4XX", "500", "default"]); +} + +#[test] +fn response_has_its_description() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "The pet."}}}}} + }"#, + ); + + let ok = method(&forge, "a.get").responses().next().unwrap(); + assert_eq!(ok.description(), "The pet."); +} + +#[test] +fn response_without_content_has_none() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"delete": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "delete", + "responses": {"204": {"description": "ok"}}}}} + }"#, + ); + + let ok = method(&forge, "a.delete").responses().next().unwrap(); + assert_eq!(ok.contents().len(), 0); + assert!(ok.json().is_none()); +} + +#[test] +fn response_ref_is_followed() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"4XX": {"$ref": "#/components/responses/Failure"}}}}}, + "components": {"responses": { + "Failure": {"description": "Failed.", "content": {"application/json": {"schema": {"type": "string"}}}} + }} + }"##, + ); + + let failure = method(&forge, "a.get").errors().next().unwrap(); + assert_eq!(failure.status(), "4XX"); + assert_eq!(failure.description(), "Failed."); + assert_eq!(scalar(failure.json().unwrap().ty()), Scalar::String); +} + +#[test] +fn response_content_that_is_not_json_is_typed() { + let forge = parse( + r#"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "download", + "responses": {"200": {"description": "ok", "content": { + "application/octet-stream": {"schema": {"type": "string", "format": "binary"}}, + "text/csv": {"schema": {"type": "string"}} + }}}}}} + }"#, + ); + + let ok = method(&forge, "a.download").responses().next().unwrap(); + assert!(ok.json().is_none()); + let types: Vec = ok.contents().map(|content| scalar(content.ty())).collect(); + assert_eq!(types, [Scalar::Binary, Scalar::String]); +} diff --git a/tests/parse/types.rs b/tests/parse/types.rs new file mode 100644 index 000000000..2a20f4a2d --- /dev/null +++ b/tests/parse/types.rs @@ -0,0 +1,11 @@ +//! Types built from schemas. Each test puts a schema in the JSON response +//! of `a.get`, or in a model that response refers to. + +mod arrays; +mod enums; +mod intersections; +mod nulls; +mod objects; +mod refs; +mod scalars; +mod unions; diff --git a/tests/parse/types/arrays.rs b/tests/parse/types/arrays.rs new file mode 100644 index 000000000..ccc0b4ae0 --- /dev/null +++ b/tests/parse/types/arrays.rs @@ -0,0 +1,155 @@ +use forge::{Array, Scalar, Type}; + +use crate::common::{ + elem, field, model_of, object, parse, parse_err, responding_with, responding_with_schemas, response_type, scalar, +}; + +/// Returns the array `ty` is. +#[track_caller] +fn array(ty: Type<'_>) -> Array<'_> { + let Type::Array(array) = ty else { + panic!("want an array, got {ty:?}"); + }; + array +} + +#[test] +fn array_of_items() { + let forge = parse(&responding_with(r#"{"type": "array", "items": {"type": "string"}}"#)); + + assert_eq!(scalar(elem(response_type(&forge, "a.get"))), Scalar::String); +} + +#[test] +fn empty_items_are_unknown() { + let forge = parse(&responding_with(r#"{"type": "array", "items": {}}"#)); + + assert_eq!(scalar(elem(response_type(&forge, "a.get"))), Scalar::Unknown); +} + +#[test] +fn items_without_a_type_are_an_array() { + let forge = parse(&responding_with(r#"{"items": {"type": "string"}}"#)); + + assert_eq!(scalar(elem(response_type(&forge, "a.get"))), Scalar::String); +} + +#[test] +fn array_without_items_has_unknown_elements() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "array" + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(elem(response_type(&forge, "a.get"))), Scalar::Unknown); +} + +#[test] +fn tuple_is_unknown() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "array", "prefixItems": [{"type": "string"}, {"type": "integer"}], "items": false + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Unknown); +} + +#[test] +fn max_items_is_the_most_elements() { + let forge = parse(&responding_with( + r#"{"type": "array", "items": {"type": "string"}, "maxItems": 3}"#, + )); + + assert_eq!(array(response_type(&forge, "a.get")).max_items(), Some(3)); +} + +#[test] +fn array_without_max_items_has_no_limit() { + let forge = parse(&responding_with(r#"{"type": "array", "items": {"type": "string"}}"#)); + + assert_eq!(array(response_type(&forge, "a.get")).max_items(), None); +} + +#[test] +fn max_items_zero_is_a_limit() { + let forge = parse(&responding_with(r#"{"type": "array", "items": {}, "maxItems": 0}"#)); + + assert_eq!(array(response_type(&forge, "a.get")).max_items(), Some(0)); +} + +#[test] +fn max_items_of_an_array_property() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"tags": {"type": "array", "items": {"type": "string"}, "maxItems": 20}}}"#, + )); + + let tags = field(object(response_type(&forge, "a.get")), "tags"); + assert_eq!(array(tags.ty()).max_items(), Some(20)); +} + +#[test] +fn arrays_of_the_same_items_with_different_limits_are_apart() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": { + "few": {"type": "array", "items": {"type": "string"}, "maxItems": 2}, + "many": {"type": "array", "items": {"type": "string"}, "maxItems": 50}, + "any": {"type": "array", "items": {"type": "string"}} + }}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(array(field(object, "few").ty()).max_items(), Some(2)); + assert_eq!(array(field(object, "many").ty()).max_items(), Some(50)); + assert_eq!(array(field(object, "any").ty()).max_items(), None); +} + +#[test] +fn array_model_keeps_the_limit_of_its_schema() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": { + "tags": {"$ref": "#/components/schemas/Tags"}, + "names": {"type": "array", "items": {"type": "string"}} + }}"##, + r#"{"Tags": {"type": "array", "items": {"type": "string"}, "maxItems": 5}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + let tags = model_of(field(object, "tags").ty()); + assert_eq!(array(tags.ty()).max_items(), Some(5)); + assert_eq!(array(field(object, "names").ty()).max_items(), None); +} + +#[test] +fn max_items_with_no_fractional_part_is_an_integer() { + let forge = parse(&responding_with(r#"{"type": "array", "items": {}, "maxItems": 4.0}"#)); + + assert_eq!(array(response_type(&forge, "a.get")).max_items(), Some(4)); +} + +#[test] +fn max_items_that_is_not_a_non_negative_integer_is_an_error() { + for value in ["-1", "2.5", r#""3""#, "null"] { + let err = parse_err(&responding_with(&format!( + r#"{{"type": "array", "items": {{}}, "maxItems": {value}}}"# + ))); + + assert!(err.contains("expected a non-negative integer"), "{value}: {err}"); + } +} + +#[test] +fn max_items_on_a_schema_that_is_not_an_array_is_ignored() { + let forge = parse(&responding_with(r#"{"type": "string", "maxItems": 2}"#)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::String); +} diff --git a/tests/parse/types/enums.rs b/tests/parse/types/enums.rs new file mode 100644 index 000000000..d2b3b497e --- /dev/null +++ b/tests/parse/types/enums.rs @@ -0,0 +1,77 @@ +use forge::{EnumValue, Scalar}; + +use crate::common::{parse, responding_with, response_type, scalar, values}; + +#[test] +fn string_enum() { + let forge = parse(&responding_with(r#"{"type": "string", "enum": ["a", "b"]}"#)); + + let a_b = [EnumValue::String("a"), EnumValue::String("b")]; + assert_eq!(values(response_type(&forge, "a.get")), a_b); +} + +#[test] +fn number_enum() { + let forge = parse(&responding_with(r#"{"type": "integer", "enum": [1, 2.5]}"#)); + + assert_eq!( + values(response_type(&forge, "a.get")), + [EnumValue::Number(1.0), EnumValue::Number(2.5)] + ); +} + +#[test] +fn boolean_enum() { + let forge = parse(&responding_with(r#"{"type": "boolean", "enum": [true]}"#)); + + assert_eq!(values(response_type(&forge, "a.get")), [EnumValue::Boolean(true)]); +} + +#[test] +fn enum_without_a_type() { + let forge = parse(&responding_with(r#"{"enum": ["a"]}"#)); + + assert_eq!(values(response_type(&forge, "a.get")), [EnumValue::String("a")]); +} + +#[test] +fn enum_of_mixed_values() { + let forge = parse(&responding_with(r#"{"enum": ["a", 1, false]}"#)); + + let want = [ + EnumValue::String("a"), + EnumValue::Number(1.0), + EnumValue::Boolean(false), + ]; + assert_eq!(values(response_type(&forge, "a.get")), want); +} + +#[test] +fn enum_values_are_different() { + let forge = parse(&responding_with(r#"{"type": "string", "enum": ["a", "b", "a"]}"#)); + + let a_b = [EnumValue::String("a"), EnumValue::String("b")]; + assert_eq!(values(response_type(&forge, "a.get")), a_b); +} + +#[test] +fn const_is_an_enum_of_one_value() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "const": "a" + }}}}}}}} + }"#, + ); + + assert_eq!(values(response_type(&forge, "a.get")), [EnumValue::String("a")]); +} + +#[test] +fn enum_of_arrays_is_unknown() { + let forge = parse(&responding_with(r#"{"type": "array", "items": {}, "enum": [[]]}"#)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Unknown); +} diff --git a/tests/parse/types/intersections.rs b/tests/parse/types/intersections.rs new file mode 100644 index 000000000..7c5d6564b --- /dev/null +++ b/tests/parse/types/intersections.rs @@ -0,0 +1,171 @@ +use forge::{Scalar, Type}; + +use crate::common::{field, intersection, kind, kinds, object, parse, responding_with_schemas, response_type, scalar}; + +/// Two object models, `A` and `B`. +const A_AND_B: &str = r#"{ + "A": {"type": "object", "properties": {"a": {"type": "string"}}}, + "B": {"type": "object", "properties": {"b": {"type": "string"}}} +}"#; + +#[test] +fn all_of_is_an_intersection() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}]}"##, + A_AND_B, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(A)", "Model(B)"]); +} + +#[test] +fn all_of_one_member_is_the_member() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [{"$ref": "#/components/schemas/A"}]}"##, + A_AND_B, + )); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(A)"); +} + +#[test] +fn members_without_a_shape_are_dropped() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [ + {"$ref": "#/components/schemas/A"}, + {"type": "object"}, + {}, + {"description": "Described."}, + {"$ref": "#/components/schemas/B"} + ]}"##, + A_AND_B, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(A)", "Model(B)"]); +} + +#[test] +fn true_member_is_dropped() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "allOf": [{"$ref": "#/components/schemas/A"}, true, {"$ref": "#/components/schemas/B"}] + }}}}}}}}, + "components": {"schemas": { + "A": {"type": "object", "properties": {"a": {"type": "string"}}}, + "B": {"type": "object", "properties": {"b": {"type": "string"}}} + }} + }"##, + ); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(A)", "Model(B)"]); +} + +#[test] +fn member_left_after_dropping_is_the_type() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [{"description": "Described."}, {"$ref": "#/components/schemas/A"}]}"##, + A_AND_B, + )); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(A)"); +} + +#[test] +fn type_next_to_all_of_is_dropped() { + let forge = parse(&responding_with_schemas( + r##"{"type": "string", "allOf": [{"$ref": "#/components/schemas/Reason"}]}"##, + r#"{"Reason": {"type": "string", "enum": ["spam", "abuse"]}}"#, + )); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(Reason)"); +} + +#[test] +fn properties_next_to_all_of_are_a_member() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"id": {"type": "string"}}, "allOf": [{"$ref": "#/components/schemas/A"}]}"##, + A_AND_B, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(A)", "Object"]); +} + +#[test] +fn all_of_member_that_is_an_all_of_is_flattened() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [ + {"$ref": "#/components/schemas/A"}, + {"allOf": [{"$ref": "#/components/schemas/B"}, {"type": "object", "properties": {"c": {"type": "string"}}}]} + ]}"##, + A_AND_B, + )); + + assert_eq!( + kinds(response_type(&forge, "a.get")), + ["Model(A)", "Model(B)", "Object"] + ); +} + +#[test] +fn required_member_makes_sibling_fields_required() { + let forge = parse(&responding_with_schemas( + r#"{"allOf": [ + {"type": "object", "properties": {"name": {"type": "string"}, "note": {"type": "string"}}}, + {"required": ["name"]} + ]}"#, + "{}", + )); + + let object = object(response_type(&forge, "a.get")); + assert!(field(object, "name").is_required()); + assert!(!field(object, "note").is_required()); +} + +#[test] +fn required_member_copies_the_fields_of_a_model() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [{"$ref": "#/components/schemas/Rule"}, {"required": ["name"]}]}"##, + r#"{"Rule": {"type": "object", "properties": {"name": {"type": "string"}, "note": {"type": "string"}}}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert!(field(object, "name").is_required()); + assert!(!field(object, "note").is_required()); +} + +#[test] +fn unevaluated_properties_is_an_object_member() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "allOf": [{"$ref": "#/components/schemas/A"}], + "unevaluatedProperties": {"type": "string"} + }}}}}}}}, + "components": {"schemas": {"A": {"type": "object", "properties": {"a": {"type": "string"}}}}} + }"##, + ); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Model(A)", "Object"]); + let rest = intersection(ty) + .members() + .find(|m| matches!(m, Type::Object(_))) + .unwrap(); + assert_eq!(object(rest).fields().len(), 0); + assert_eq!(scalar(object(rest).extra().unwrap()), Scalar::String); +} + +#[test] +fn array_next_to_all_of_is_dropped() { + let forge = parse(&responding_with_schemas( + r##"{"type": "array", "items": {"type": "object"}, "allOf": [{"$ref": "#/components/schemas/Messages"}]}"##, + r#"{"Messages": {"type": "array", "items": {"type": "object", "properties": {"code": {"type": "integer"}}}}}"#, + )); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(Messages)"); +} diff --git a/tests/parse/types/nulls.rs b/tests/parse/types/nulls.rs new file mode 100644 index 000000000..a3794823e --- /dev/null +++ b/tests/parse/types/nulls.rs @@ -0,0 +1,151 @@ +use forge::{EnumValue, Scalar}; + +use crate::common::{ + kind, kinds, model_of, non_null, parse, responding_with, responding_with_schemas, response_type, scalar, values, +}; + +#[test] +fn nullable_is_a_union_with_null() { + let forge = parse(&responding_with(r#"{"type": "string", "nullable": true}"#)); + + assert_eq!(scalar(non_null(response_type(&forge, "a.get"))), Scalar::String); +} + +#[test] +fn nullable_false_is_not_a_union() { + let forge = parse(&responding_with(r#"{"type": "string", "nullable": false}"#)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::String); +} + +#[test] +fn nullable_unknown_is_unknown() { + let forge = parse(&responding_with(r#"{"nullable": true}"#)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Unknown); +} + +#[test] +fn nullable_enum_is_a_union_with_null() { + let forge = parse(&responding_with( + r#"{"type": "string", "enum": ["a", "b"], "nullable": true}"#, + )); + + let a_b = [EnumValue::String("a"), EnumValue::String("b")]; + assert_eq!(values(non_null(response_type(&forge, "a.get"))), a_b); +} + +#[test] +fn null_in_an_enum_is_a_union_with_null() { + let forge = parse(&responding_with(r#"{"type": "string", "enum": ["a", null]}"#)); + + let a = [EnumValue::String("a")]; + assert_eq!(values(non_null(response_type(&forge, "a.get"))), a); +} + +#[test] +fn enum_of_only_null_is_null() { + let forge = parse(&responding_with( + r#"{"type": "object", "enum": [null], "nullable": true}"#, + )); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Null); +} + +#[test] +fn nullable_union_has_null_as_a_member() { + let forge = parse(&responding_with( + r#"{"nullable": true, "oneOf": [{"type": "string"}, {"type": "boolean"}]}"#, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Boolean", "Null", "String"]); +} + +#[test] +fn nullable_member_of_a_union_is_null_in_the_union() { + let forge = parse(&responding_with( + r#"{"oneOf": [{"type": "string", "nullable": true}, {"type": "boolean"}]}"#, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Boolean", "Null", "String"]); +} + +#[test] +fn null_enum_member_of_a_union_is_null() { + let forge = parse(&responding_with(r#"{"anyOf": [{"type": "string"}, {"enum": [null]}]}"#)); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Null", "String"]); +} + +#[test] +fn nullable_all_of_is_a_union_with_null() { + let forge = parse(&responding_with_schemas( + r##"{"allOf": [{"$ref": "#/components/schemas/Reason"}], "nullable": true}"##, + r#"{"Reason": {"type": "string"}}"#, + )); + + assert_eq!( + model_of(non_null(response_type(&forge, "a.get"))).schema_name(), + "Reason" + ); +} + +#[test] +fn nullable_is_read_in_3_1() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "string", "nullable": true + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(non_null(response_type(&forge, "a.get"))), Scalar::String); +} + +#[test] +fn null_in_a_type_array_is_a_union_with_null() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": ["string", "null"] + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(non_null(response_type(&forge, "a.get"))), Scalar::String); +} + +#[test] +fn null_type_is_null() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "null" + }}}}}}}} + }"#, + ); + + assert_eq!(kind(response_type(&forge, "a.get")), "Null"); +} + +#[test] +fn const_null_is_null() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "const": null + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Null); +} diff --git a/tests/parse/types/objects.rs b/tests/parse/types/objects.rs new file mode 100644 index 000000000..1b5139d46 --- /dev/null +++ b/tests/parse/types/objects.rs @@ -0,0 +1,299 @@ +use forge::{Json, Name, Number, Scalar}; + +use crate::common::{field, field_names, object, parse, parse_err, responding_with, response_type, scalar}; + +#[test] +fn properties_are_fields_and_allow_no_other_keys() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string"}}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(scalar(field(object, "id").ty()), Scalar::String); + assert!(object.extra().is_none(), "{object:?}"); +} + +#[test] +fn required_fields_are_listed_in_required() { + let forge = parse(&responding_with( + r#"{"type": "object", "required": ["id"], "properties": {"id": {"type": "string"}, "name": {"type": "string"}}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert!(field(object, "id").is_required()); + assert!(!field(object, "name").is_required()); +} + +#[test] +fn required_names_without_a_property_are_ignored() { + let forge = parse(&responding_with( + r#"{"type": "object", "required": ["port", "protocol"], "properties": {"port": {"type": "integer"}}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field_names(object), ["port"]); + assert!(field(object, "port").is_required()); +} + +#[test] +fn field_is_named_by_x_fern_property_name() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"owner": {"type": "string", "x-fern-property-name": "person"}}}"#, + )); + + let owner = field(object(response_type(&forge, "a.get")), "owner"); + assert_eq!(owner.name(), &Name::new("person")); +} + +#[test] +fn field_is_named_after_its_property() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"created_at": {"type": "string"}}}"#, + )); + + let created_at = field(object(response_type(&forge, "a.get")), "created_at"); + assert_eq!(created_at.name(), &Name::new("created_at")); +} + +#[test] +fn field_has_the_description_and_deprecation_of_its_property() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"old": {"type": "string", "description": "Old.", "deprecated": true}}}"#, + )); + + let old = field(object(response_type(&forge, "a.get")), "old"); + assert_eq!(old.description(), "Old."); + assert!(old.is_deprecated()); +} + +#[test] +fn field_is_read_only_when_its_property_is() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string", "readOnly": true}}}"#, + )); + + assert!(field(object(response_type(&forge, "a.get")), "id").is_read_only()); +} + +#[test] +fn field_is_neither_read_only_nor_sensitive_by_default() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"name": {"type": "string"}, "shown": {"type": "string", "readOnly": false, "x-sensitive": false}}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + for name in ["name", "shown"] { + assert!(!field(object, name).is_read_only(), "{name}"); + assert!(!field(object, name).is_sensitive(), "{name}"); + } +} + +#[test] +fn read_only_that_is_not_a_boolean_is_an_error() { + let err = parse_err(&responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string", "readOnly": "yes"}}}"#, + )); + + assert!(err.contains("expected a boolean"), "{err}"); +} + +#[test] +fn field_is_sensitive_when_its_property_is() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "x-sensitive": true}}}"#, + )); + + assert!(field(object(response_type(&forge, "a.get")), "password").is_sensitive()); +} + +#[test] +fn sensitive_that_is_not_a_boolean_is_an_error() { + let err = parse_err(&responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "x-sensitive": "true"}}}"#, + )); + + assert!(err.contains("expected a boolean"), "{err}"); +} + +#[test] +fn property_named_ref_is_a_field() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"$ref": {"type": "string", "format": "uri"}}}"#, + )); + + assert_eq!(field_names(object(response_type(&forge, "a.get"))), ["$ref"]); +} + +#[test] +fn properties_without_a_type_are_an_object() { + let forge = parse(&responding_with(r#"{"properties": {"id": {"type": "string"}}}"#)); + + assert_eq!(field_names(object(response_type(&forge, "a.get"))), ["id"]); +} + +#[test] +fn object_without_properties_allows_any_keys() { + let forge = parse(&responding_with(r#"{"type": "object"}"#)); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(object.fields().len(), 0); + assert_eq!(scalar(object.extra().unwrap()), Scalar::Unknown); +} + +#[test] +fn additional_properties_true_allows_any_other_keys() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string"}}, "additionalProperties": true}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field_names(object), ["id"]); + assert_eq!(scalar(object.extra().unwrap()), Scalar::Unknown); +} + +#[test] +fn additional_properties_false_with_properties_allows_no_other_keys() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string"}}, "additionalProperties": false}"#, + )); + + assert!(object(response_type(&forge, "a.get")).extra().is_none()); +} + +#[test] +fn additional_properties_false_without_properties_is_the_empty_object() { + let forge = parse(&responding_with(r#"{"type": "object", "additionalProperties": false}"#)); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(object.fields().len(), 0); + assert!(object.extra().is_none(), "{object:?}"); +} + +#[test] +fn additional_properties_schema_is_the_type_of_other_keys() { + let forge = parse(&responding_with( + r#"{"type": "object", "additionalProperties": {"type": "integer"}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(object.fields().len(), 0); + assert_eq!(scalar(object.extra().unwrap()), Scalar::Number(Number::Int)); +} + +#[test] +fn empty_additional_properties_schema_is_unknown() { + let forge = parse(&responding_with(r#"{"type": "object", "additionalProperties": {}}"#)); + + assert_eq!( + scalar(object(response_type(&forge, "a.get")).extra().unwrap()), + Scalar::Unknown + ); +} + +#[test] +fn pattern_properties_are_ignored() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"id": {"type": "string"}}, "patternProperties": {"^x-": {"type": "string"}} + }}}}}}}} + }"#, + ); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field_names(object), ["id"]); + assert!(object.extra().is_none(), "{object:?}"); +} + +#[test] +fn false_property_is_no_field() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"id": {"type": "string"}, "gone": false} + }}}}}}}} + }"#, + ); + + assert_eq!(field_names(object(response_type(&forge, "a.get"))), ["id"]); +} + +#[test] +fn property_skipped_by_x_stainless_skip_is_no_field() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"id": {"type": "string"}, "old_id": {"type": "string", "x-stainless-skip": true}}}"#, + )); + + assert_eq!(field_names(object(response_type(&forge, "a.get"))), ["id"]); +} + +#[test] +fn fields_with_equal_names_are_an_error() { + let err = parse_err(&responding_with( + r#"{"type": "object", "properties": {"user_id": {"type": "string"}, "userId": {"type": "string"}}}"#, + )); + + assert!(err.contains("user_id") && err.contains("userId"), "{err}"); +} + +#[test] +fn field_has_the_default_of_its_property() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": { + "name": {"type": "string", "default": "pet"}, + "limit": {"type": "integer", "default": 20}, + "ratio": {"type": "number", "default": 0.5}, + "enabled": {"type": "boolean", "default": false} + }}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field(object, "name").default(), Some(&Json::String("pet".into()))); + assert_eq!(field(object, "limit").default(), Some(&Json::Number(20.0))); + assert_eq!(field(object, "ratio").default(), Some(&Json::Number(0.5))); + assert_eq!(field(object, "enabled").default(), Some(&Json::Bool(false))); +} + +#[test] +fn object_and_array_defaults_are_kept_as_written() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": { + "settings": {"type": "object", "default": {"b": 1, "a": [true]}}, + "tags": {"type": "array", "items": {"type": "string"}, "default": ["x", "y"]} + }}"#, + )); + + let object = object(response_type(&forge, "a.get")); + let settings = Json::Object(vec![ + ("b".into(), Json::Number(1.0)), + ("a".into(), Json::Array(vec![Json::Bool(true)])), + ]); + assert_eq!(field(object, "settings").default(), Some(&settings)); + let tags = Json::Array(vec![Json::String("x".into()), Json::String("y".into())]); + assert_eq!(field(object, "tags").default(), Some(&tags)); +} + +#[test] +fn null_default_is_apart_from_no_default() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"parent": {"type": "string", "nullable": true, "default": null}, "name": {"type": "string"}}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field(object, "parent").default(), Some(&Json::Null)); + assert_eq!(field(object, "name").default(), None); +} + +#[test] +fn default_is_not_checked_against_the_schema() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": {"limit": {"type": "integer", "default": "ten"}}}"#, + )); + + let limit = field(object(response_type(&forge, "a.get")), "limit"); + assert_eq!(limit.default(), Some(&Json::String("ten".into()))); +} diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs new file mode 100644 index 000000000..0e39e86e3 --- /dev/null +++ b/tests/parse/types/refs.rs @@ -0,0 +1,486 @@ +use forge::{Json, Scalar}; + +use crate::common::{ + field, kind, kinds, model_of, models, non_null, object, parse, parse_err, responding_with_schemas, response_type, + scalar, +}; + +#[test] +fn ref_to_a_schema_is_a_model_even_a_primitive() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Id"}"##, + r#"{"Id": {"type": "string"}}"#, + )); + + let id = model_of(response_type(&forge, "a.get")); + assert_eq!(id.schema_name(), "Id"); + assert_eq!(scalar(id.ty()), Scalar::String); +} + +#[test] +fn ref_into_a_schema_is_written_in_place() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Account/properties/id"}"##, + r#"{"Account": {"type": "object", "properties": {"id": {"type": "string"}}}}"#, + )); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::String); + assert!(models(&forge).is_empty(), "{:?}", models(&forge)); +} + +#[test] +fn recursive_ref_written_in_place_is_an_error() { + let err = parse_err(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Tree/properties/node"}"##, + r##"{"Tree": {"type": "object", "properties": { + "node": {"type": "object", "properties": {"next": {"$ref": "#/components/schemas/Tree/properties/node"}}} + }}}"##, + )); + + assert!(err.contains("#/components/schemas/Tree/properties/node"), "{err}"); +} + +#[test] +fn external_ref_is_an_error() { + let err = parse_err(&responding_with_schemas( + r#"{"$ref": "other.json#/components/schemas/Zone"}"#, + "{}", + )); + + assert!(err.contains("other.json#/components/schemas/Zone"), "{err}"); +} + +#[test] +fn siblings_of_a_ref_are_ignored_in_3_0() { + let forge = parse(&responding_with_schemas( + r##"{"$ref": "#/components/schemas/Zone", "nullable": true, "properties": {"extra": {"type": "string"}}}"##, + r#"{"Zone": {"type": "object", "properties": {"id": {"type": "string"}}}}"#, + )); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(Zone)"); +} + +#[test] +fn annotations_next_to_a_ref_keep_the_model_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "$ref": "#/components/schemas/Zone", "description": "The zone.", "deprecated": true + }}}}}}}}, + "components": {"schemas": {"Zone": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(Zone)"); +} + +#[test] +fn shape_next_to_a_ref_is_intersected_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "$ref": "#/components/schemas/Zone", "properties": {"extra": {"type": "string"}} + }}}}}}}}, + "components": {"schemas": {"Zone": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(Zone)", "Object"]); +} + +#[test] +fn nullable_next_to_a_ref_is_a_union_with_null_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "$ref": "#/components/schemas/Zone", "nullable": true + }}}}}}}}, + "components": {"schemas": {"Zone": {"type": "object", "properties": {"id": {"type": "string"}}}}} + }"##, + ); + + assert_eq!(model_of(non_null(response_type(&forge, "a.get"))).schema_name(), "Zone"); +} + +#[test] +fn empty_ref_is_an_error() { + let err = parse_err(&responding_with_schemas(r#"{"$ref": ""}"#, "{}")); + + assert!(err.contains(r#"$ref """#), "{err}"); +} + +#[test] +fn property_that_is_a_ref_has_the_description_of_its_schema() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"zone": {"$ref": "#/components/schemas/Zone"}}}"##, + r#"{"Zone": {"type": "string", "description": "A zone."}}"#, + )); + + let zone = field(object(response_type(&forge, "a.get")), "zone"); + assert_eq!(zone.description(), "A zone."); +} + +#[test] +fn property_description_follows_a_chain_of_refs() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id"}}}"##, + r##"{"Id": {"$ref": "#/components/schemas/Uuid"}, "Uuid": {"type": "string", "description": "A UUID."}}"##, + )); + + let id = field(object(response_type(&forge, "a.get")), "id"); + assert_eq!(id.description(), "A UUID."); +} + +#[test] +fn property_description_is_never_the_title_of_its_schema() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"zone": {"$ref": "#/components/schemas/Zone"}}}"##, + r#"{"Zone": {"type": "string", "title": "Zone"}}"#, + )); + + let zone = field(object(response_type(&forge, "a.get")), "zone"); + assert_eq!(zone.description(), ""); +} + +#[test] +fn description_next_to_a_property_ref_is_its_own_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"home": {"$ref": "#/components/schemas/Zone", "description": "The home zone."}} + }}}}}}}}, + "components": {"schemas": {"Zone": {"type": "string", "description": "A zone."}}} + }"##, + ); + + let home = field(object(response_type(&forge, "a.get")), "home"); + assert_eq!(home.description(), "The home zone."); +} + +#[test] +fn description_next_to_a_property_ref_is_ignored_in_3_0() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"home": {"$ref": "#/components/schemas/Zone", "description": "The home zone."}}}"##, + r#"{"Zone": {"type": "string", "description": "A zone."}}"#, + )); + + let home = field(object(response_type(&forge, "a.get")), "home"); + assert_eq!(home.description(), "A zone."); +} + +/// Returns a 3.1 document responding with `schema`, with `schemas` as its +/// schemas. +fn responding_with_schemas_3_1(schema: &str, schemas: &str) -> String { + responding_with_schemas(schema, schemas).replacen("3.0.3", "3.1.0", 1) +} + +#[test] +fn property_description_comes_from_an_all_of_member_in_3_1() { + let forge = parse(&responding_with_schemas_3_1( + r##"{"type": "object", "properties": { + "region": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}, + "until": {"allOf": [{"type": "string", "description": "The end."}, {"$ref": "#/components/schemas/Region"}]} + }}"##, + r#"{"Region": {"type": "string", "description": "A region."}}"#, + )); + + let ty = object(response_type(&forge, "a.get")); + assert_eq!(field(ty, "region").description(), "A region."); + assert_eq!(field(ty, "until").description(), "The end."); +} + +#[test] +fn property_description_comes_from_a_one_member_all_of_in_3_1() { + let forge = parse(&responding_with_schemas_3_1( + r##"{"type": "object", "properties": {"page": {"allOf": [{"$ref": "#/components/schemas/Page"}]}}}"##, + r#"{"Page": {"type": "integer", "description": "The page."}}"#, + )); + + assert_eq!( + field(object(response_type(&forge, "a.get")), "page").description(), + "The page." + ); +} + +#[test] +fn property_description_follows_all_of_inside_a_ref_in_3_1() { + let forge = parse(&responding_with_schemas_3_1( + r##"{"type": "object", "properties": {"region": {"$ref": "#/components/schemas/Region"}}}"##, + r##"{ + "Region": {"allOf": [{"$ref": "#/components/schemas/Code"}]}, + "Code": {"type": "string", "description": "A code."} + }"##, + )); + + assert_eq!( + field(object(response_type(&forge, "a.get")), "region").description(), + "A code." + ); +} + +#[test] +fn own_description_comes_before_all_of_members_in_3_1() { + let forge = parse(&responding_with_schemas_3_1( + r##"{"type": "object", "properties": { + "region": {"description": "Where.", "allOf": [{"$ref": "#/components/schemas/Region"}]} + }}"##, + r#"{"Region": {"type": "string", "description": "A region."}}"#, + )); + + assert_eq!( + field(object(response_type(&forge, "a.get")), "region").description(), + "Where." + ); +} + +#[test] +fn ref_description_comes_before_all_of_members_in_3_1() { + let forge = parse(&responding_with_schemas_3_1( + r##"{"type": "object", "properties": { + "region": {"$ref": "#/components/schemas/Region", "allOf": [{"description": "Other."}]} + }}"##, + r#"{"Region": {"type": "string", "description": "A region."}}"#, + )); + + assert_eq!( + field(object(response_type(&forge, "a.get")), "region").description(), + "A region." + ); +} + +#[test] +fn property_description_never_comes_from_one_of_or_any_of_members() { + let forge = parse(&responding_with_schemas_3_1( + r#"{"type": "object", "properties": { + "since": {"oneOf": [{"type": "string", "description": "A date."}, {"type": "integer"}]}, + "until": {"anyOf": [{"type": "string", "description": "A date."}, {"type": "integer"}]} + }}"#, + "{}", + )); + + let ty = object(response_type(&forge, "a.get")); + assert_eq!(field(ty, "since").description(), ""); + assert_eq!(field(ty, "until").description(), ""); +} + +#[test] +fn all_of_member_description_is_ignored_in_3_0() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": { + "region": {"allOf": [{"$ref": "#/components/schemas/Region"}, {"default": "us"}]}, + "page": {"allOf": [{"$ref": "#/components/schemas/Region"}]} + }}"##, + r#"{"Region": {"type": "string", "description": "A region."}}"#, + )); + + let ty = object(response_type(&forge, "a.get")); + assert_eq!(field(ty, "region").description(), ""); + assert_eq!(field(ty, "page").description(), ""); +} + +#[test] +fn all_of_that_leads_back_to_itself_has_no_description() { + let forge = parse(&responding_with_schemas_3_1( + r##"{"type": "object", "properties": {"a": {"$ref": "#/components/schemas/A"}}}"##, + r##"{ + "A": {"type": "object", "allOf": [{"$ref": "#/components/schemas/B"}, {"$ref": "#/components/schemas/A"}]}, + "B": {"type": "object", "allOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}]} + }"##, + )); + + assert_eq!(field(object(response_type(&forge, "a.get")), "a").description(), ""); +} + +#[test] +fn property_that_is_a_ref_is_read_only_when_its_schema_is() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id"}}}"##, + r#"{"Id": {"type": "string", "readOnly": true}}"#, + )); + + assert!(field(object(response_type(&forge, "a.get")), "id").is_read_only()); +} + +#[test] +fn property_read_only_follows_a_chain_of_refs() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id"}}}"##, + r##"{"Id": {"$ref": "#/components/schemas/Uuid"}, "Uuid": {"type": "string", "readOnly": true}}"##, + )); + + assert!(field(object(response_type(&forge, "a.get")), "id").is_read_only()); +} + +#[test] +fn read_only_next_to_a_property_ref_counts_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": true}} + }}}}}}}}, + "components": {"schemas": {"Id": {"type": "string"}}} + }"##, + ); + + assert!(field(object(response_type(&forge, "a.get")), "id").is_read_only()); +} + +#[test] +fn read_only_next_to_a_property_ref_is_ignored_in_3_0() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": true}}}"##, + r#"{"Id": {"type": "string"}}"#, + )); + + assert!(!field(object(response_type(&forge, "a.get")), "id").is_read_only()); +} + +#[test] +fn read_only_false_next_to_a_ref_does_not_undo_its_schemas_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"id": {"$ref": "#/components/schemas/Id", "readOnly": false}} + }}}}}}}}, + "components": {"schemas": {"Id": {"type": "string", "readOnly": true}}} + }"##, + ); + + assert!(field(object(response_type(&forge, "a.get")), "id").is_read_only()); +} + +#[test] +fn property_that_is_a_ref_is_sensitive_when_its_schema_is() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token"}}}"##, + r##"{"Token": {"$ref": "#/components/schemas/Secret"}, "Secret": {"type": "string", "x-sensitive": true}}"##, + )); + + assert!(field(object(response_type(&forge, "a.get")), "token").is_sensitive()); +} + +#[test] +fn sensitive_next_to_a_property_ref_counts_in_3_0() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-sensitive": true}}}"##, + r#"{"Token": {"type": "string"}}"#, + )); + + assert!(field(object(response_type(&forge, "a.get")), "token").is_sensitive()); +} + +#[test] +fn sensitive_next_to_a_property_ref_counts_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"token": {"$ref": "#/components/schemas/Token", "x-sensitive": true}} + }}}}}}}}, + "components": {"schemas": {"Token": {"type": "string"}}} + }"##, + ); + + assert!(field(object(response_type(&forge, "a.get")), "token").is_sensitive()); +} + +#[test] +fn property_that_is_a_ref_has_the_default_of_its_schema() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}}}"##, + r#"{"Size": {"type": "integer", "default": 10}}"#, + )); + + let size = field(object(response_type(&forge, "a.get")), "size"); + assert_eq!(size.default(), Some(&Json::Number(10.0))); +} + +#[test] +fn property_default_follows_a_chain_of_refs() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}}}"##, + r##"{"Size": {"$ref": "#/components/schemas/Count"}, "Count": {"type": "integer", "default": 10}}"##, + )); + + let size = field(object(response_type(&forge, "a.get")), "size"); + assert_eq!(size.default(), Some(&Json::Number(10.0))); +} + +#[test] +fn property_default_is_the_first_along_a_chain_of_refs_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}} + }}}}}}}}, + "components": {"schemas": { + "Size": {"$ref": "#/components/schemas/Count", "default": 5}, + "Count": {"type": "integer", "default": 10} + }} + }"##, + ); + + let size = field(object(response_type(&forge, "a.get")), "size"); + assert_eq!(size.default(), Some(&Json::Number(5.0))); +} + +#[test] +fn property_that_is_a_ref_without_a_default_has_none() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"size": {"$ref": "#/components/schemas/Size"}}}"##, + r#"{"Size": {"type": "integer"}}"#, + )); + + assert_eq!(field(object(response_type(&forge, "a.get")), "size").default(), None); +} + +#[test] +fn default_next_to_a_property_ref_is_its_own_in_3_1() { + let forge = parse( + r##"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", "properties": { + "size": {"$ref": "#/components/schemas/Size", "default": 5}, + "none": {"$ref": "#/components/schemas/Size", "default": null} + } + }}}}}}}}, + "components": {"schemas": {"Size": {"type": "integer", "default": 10}}} + }"##, + ); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field(object, "size").default(), Some(&Json::Number(5.0))); + assert_eq!(field(object, "none").default(), Some(&Json::Null)); +} + +#[test] +fn default_next_to_a_property_ref_is_ignored_in_3_0() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": { + "size": {"$ref": "#/components/schemas/Size", "default": 5}, + "count": {"$ref": "#/components/schemas/Count", "default": 5} + }}"##, + r#"{"Size": {"type": "integer", "default": 10}, "Count": {"type": "integer"}}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(field(object, "size").default(), Some(&Json::Number(10.0))); + assert_eq!(field(object, "count").default(), None); +} diff --git a/tests/parse/types/scalars.rs b/tests/parse/types/scalars.rs new file mode 100644 index 000000000..d7fe0fccd --- /dev/null +++ b/tests/parse/types/scalars.rs @@ -0,0 +1,148 @@ +use forge::{Number, Scalar}; + +use crate::common::{field, object, parse, responding_with, response_type, scalar}; + +#[test] +fn primitive_types() { + for (schema, want) in [ + (r#"{"type": "string"}"#, Scalar::String), + (r#"{"type": "boolean"}"#, Scalar::Boolean), + (r#"{"type": "integer"}"#, Scalar::Number(Number::Int)), + (r#"{"type": "number"}"#, Scalar::Number(Number::Double)), + ] { + let forge = parse(&responding_with(schema)); + + assert_eq!(scalar(response_type(&forge, "a.get")), want, "{schema}"); + } +} + +#[test] +fn number_format_is_its_size() { + for (schema, want) in [ + (r#"{"type": "integer", "format": "int8"}"#, Number::Int8), + (r#"{"type": "integer", "format": "int16"}"#, Number::Int16), + (r#"{"type": "integer", "format": "int32"}"#, Number::Int32), + (r#"{"type": "integer", "format": "int64"}"#, Number::Int64), + (r#"{"type": "integer", "format": "uint8"}"#, Number::Uint8), + (r#"{"type": "integer", "format": "uint16"}"#, Number::Uint16), + (r#"{"type": "integer", "format": "uint32"}"#, Number::Uint32), + (r#"{"type": "integer", "format": "uint64"}"#, Number::Uint64), + (r#"{"type": "number", "format": "float"}"#, Number::Float), + (r#"{"type": "number", "format": "double"}"#, Number::Double), + ] { + let forge = parse(&responding_with(schema)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Number(want), "{schema}"); + } +} + +#[test] +fn number_format_decides_the_size_whatever_the_type() { + for (schema, want) in [ + (r#"{"type": "number", "format": "int32"}"#, Number::Int32), + (r#"{"type": "number", "format": "int64"}"#, Number::Int64), + ] { + let forge = parse(&responding_with(schema)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Number(want), "{schema}"); + } +} + +#[test] +fn number_format_that_is_not_a_size_is_ignored() { + for (schema, want) in [ + (r#"{"type": "integer", "format": "timestamp"}"#, Number::Int), + (r#"{"type": "number", "format": "decimal"}"#, Number::Double), + ] { + let forge = parse(&responding_with(schema)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Number(want), "{schema}"); + } +} + +#[test] +fn string_formats() { + for (schema, want) in [ + (r#"{"type": "string", "format": "date-time"}"#, Scalar::DateTime), + (r#"{"type": "string", "format": "date"}"#, Scalar::Date), + (r#"{"type": "string", "format": "byte"}"#, Scalar::Bytes), + (r#"{"type": "string", "format": "binary"}"#, Scalar::Binary), + (r#"{"type": "string", "format": "uuid"}"#, Scalar::String), + (r#"{"type": "string", "format": "uri"}"#, Scalar::String), + ] { + let forge = parse(&responding_with(schema)); + + assert_eq!(scalar(response_type(&forge, "a.get")), want, "{schema}"); + } +} + +#[test] +fn base64_content_encoding_is_bytes() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "string", "contentEncoding": "base64" + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Bytes); +} + +#[test] +fn binary_format_is_binary_even_with_base64_content_encoding() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "string", "format": "binary", "contentEncoding": "base64" + }}}}}}}} + }"#, + ); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Binary); +} + +#[test] +fn json_field_with_binary_format_is_binary_and_byte_format_is_bytes() { + let forge = parse(&responding_with( + r#"{"type": "object", "properties": { + "raw": {"type": "string", "format": "binary"}, + "encoded": {"type": "string", "format": "byte"} + }}"#, + )); + + let object = object(response_type(&forge, "a.get")); + assert_eq!(scalar(field(object, "raw").ty()), Scalar::Binary); + assert_eq!(scalar(field(object, "encoded").ty()), Scalar::Bytes); +} + +#[test] +fn empty_schema_is_unknown() { + let forge = parse(&responding_with("{}")); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Unknown); +} + +#[test] +fn schema_with_only_annotations_is_unknown() { + let forge = parse(&responding_with(r#"{"description": "Anything.", "example": 1}"#)); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Unknown); +} + +#[test] +fn true_schema_is_unknown() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": true}}}}}}} + }"#, + ); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::Unknown); +} diff --git a/tests/parse/types/unions.rs b/tests/parse/types/unions.rs new file mode 100644 index 000000000..3aec6f3a8 --- /dev/null +++ b/tests/parse/types/unions.rs @@ -0,0 +1,444 @@ +use forge::{Discriminator, EnumValue, Name, Scalar, Type}; + +use crate::common::{ + field_names, kind, kinds, object, parse, responding_with, responding_with_schemas, response_type, scalar, union, + values, +}; + +/// Two object models, `A` and `B`, with a `kind` property. +const A_AND_B: &str = r#"{ + "A": {"type": "object", "properties": {"kind": {"type": "string"}, "a": {"type": "string"}}}, + "B": {"type": "object", "properties": {"kind": {"type": "string"}, "b": {"type": "string"}}} +}"#; + +/// Returns each case of `discriminator` as its value and the [`kind`] of +/// its member, sorted. +fn cases<'f>(discriminator: Discriminator<'f>) -> Vec<(&'f str, String)> { + let mut cases: Vec<_> = discriminator.cases().map(|(value, ty)| (value, kind(ty))).collect(); + cases.sort(); + cases +} + +#[test] +fn one_of_is_a_union() { + let forge = parse(&responding_with_schemas( + r##"{"oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}]}"##, + A_AND_B, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Model(A)", "Model(B)"]); + assert!(union(ty).discriminator().is_none()); +} + +#[test] +fn any_of_is_a_union() { + let forge = parse(&responding_with( + r#"{"anyOf": [{"type": "string"}, {"type": "boolean"}]}"#, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Boolean", "String"]); +} + +#[test] +fn one_of_is_exclusive() { + let forge = parse(&responding_with( + r#"{"oneOf": [{"type": "string"}, {"type": "boolean"}]}"#, + )); + + assert!(union(response_type(&forge, "a.get")).is_exclusive()); +} + +#[test] +fn any_of_is_not_exclusive() { + let forge = parse(&responding_with( + r#"{"anyOf": [{"type": "string"}, {"type": "boolean"}]}"#, + )); + + assert!(!union(response_type(&forge, "a.get")).is_exclusive()); +} + +#[test] +fn any_of_with_a_discriminator_is_not_exclusive() { + let forge = parse(&responding_with_schemas( + r##"{ + "anyOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind"} + }"##, + A_AND_B, + )); + + assert!(!union(response_type(&forge, "a.get")).is_exclusive()); +} + +#[test] +fn type_array_is_exclusive() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": ["string", "integer"] + }}}}}}}} + }"#, + ); + + assert!(union(response_type(&forge, "a.get")).is_exclusive()); +} + +#[test] +fn type_and_null_are_exclusive() { + let forge = parse(&responding_with(r#"{"anyOf": [{"type": "string"}, {"type": "null"}]}"#)); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Null", "String"]); + assert!(union(ty).is_exclusive()); +} + +#[test] +fn nullable_any_of_is_not_exclusive() { + let forge = parse(&responding_with( + r#"{"anyOf": [{"type": "string"}, {"type": "boolean"}], "nullable": true}"#, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Boolean", "Null", "String"]); + assert!(!union(ty).is_exclusive()); +} + +#[test] +fn one_of_with_an_any_of_member_is_not_exclusive() { + let forge = parse(&responding_with( + r#"{"oneOf": [{"type": "boolean"}, {"anyOf": [{"type": "string"}, {"type": "integer"}]}]}"#, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Boolean", "Number(Int)", "String"]); + assert!(!union(ty).is_exclusive()); +} + +#[test] +fn discriminated_member_keeps_its_own_exclusivity() { + let forge = parse(&responding_with_schemas( + r##"{"anyOf": [ + {"oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], "discriminator": {"propertyName": "kind"}}, + {"type": "string"} + ]}"##, + A_AND_B, + )); + + let ty = response_type(&forge, "a.get"); + assert!(!union(ty).is_exclusive()); + let inner = union(ty).members().find(|m| matches!(m, Type::Union(_))).unwrap(); + assert!(union(inner).is_exclusive()); +} + +#[test] +fn types_in_a_type_array_are_a_union() { + let forge = parse( + r#"{ + "openapi": "3.1.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": ["string", "integer"] + }}}}}}}} + }"#, + ); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Number(Int)", "String"]); +} + +#[test] +fn union_of_one_member_is_the_member() { + let forge = parse(&responding_with_schemas( + r##"{"oneOf": [{"$ref": "#/components/schemas/A"}]}"##, + A_AND_B, + )); + + assert_eq!(kind(response_type(&forge, "a.get")), "Model(A)"); +} + +#[test] +fn union_of_the_same_type_twice_is_the_type() { + let forge = parse(&responding_with( + r#"{"anyOf": [{"type": "string"}, {"type": "string", "description": "Again."}]}"#, + )); + + assert_eq!(scalar(response_type(&forge, "a.get")), Scalar::String); +} + +#[test] +fn union_member_that_is_a_union_is_flattened() { + let forge = parse(&responding_with_schemas( + r##"{"oneOf": [ + {"$ref": "#/components/schemas/A"}, + {"oneOf": [{"type": "string"}, {"$ref": "#/components/schemas/B"}]} + ]}"##, + A_AND_B, + )); + + assert_eq!( + kinds(response_type(&forge, "a.get")), + ["Model(A)", "Model(B)", "String"] + ); +} + +#[test] +fn enum_members_are_merged() { + let forge = parse(&responding_with( + r#"{"oneOf": [{"enum": ["x", "y"]}, {"type": "boolean"}, {"enum": ["y", "z"]}]}"#, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Boolean", "Enum"]); + let enum_ty = union(ty).members().find(|m| matches!(m, Type::Enum(_))).unwrap(); + let xyz = ["x", "y", "z"].map(EnumValue::String); + assert_eq!(values(enum_ty), xyz); +} + +#[test] +fn discriminator_mapping_gives_the_cases() { + let forge = parse(&responding_with_schemas( + r##"{ + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind", "mapping": {"a": "#/components/schemas/A", "b": "#/components/schemas/B"}} + }"##, + A_AND_B, + )); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!(discriminator.wire_name(), "kind"); + assert_eq!(discriminator.name(), &Name::new("kind")); + assert_eq!( + cases(discriminator), + [("a", "Model(A)".into()), ("b", "Model(B)".into())] + ); +} + +#[test] +fn discriminator_mapping_can_be_a_schema_name() { + let forge = parse(&responding_with_schemas( + r##"{ + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind", "mapping": {"a": "A", "b": "B"}} + }"##, + A_AND_B, + )); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!( + cases(discriminator), + [("a", "Model(A)".into()), ("b", "Model(B)".into())] + ); +} + +#[test] +fn discriminator_without_mapping_uses_schema_names() { + let forge = parse(&responding_with_schemas( + r##"{ + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind"} + }"##, + A_AND_B, + )); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!( + cases(discriminator), + [("A", "Model(A)".into()), ("B", "Model(B)".into())] + ); +} + +#[test] +fn discriminator_without_mapping_uses_the_one_value_of_an_enum() { + let forge = parse(&responding_with_schemas( + r##"{ + "oneOf": [{"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}], + "discriminator": {"propertyName": "kind"} + }"##, + r#"{ + "Cat": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["cat"]}}}, + "Dog": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["dog"]}}} + }"#, + )); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!( + cases(discriminator), + [("cat", "Model(Cat)".into()), ("dog", "Model(Dog)".into())] + ); +} + +#[test] +fn discriminator_of_members_written_in_place_uses_the_one_value_of_an_enum() { + let forge = parse(&responding_with( + r#"{ + "discriminator": {"propertyName": "type"}, + "oneOf": [ + {"type": "object", "title": "Int32", "properties": {"type": {"type": "string", "enum": ["int32"]}}}, + {"type": "object", "title": "Struct", "properties": {"type": {"type": "string", "enum": ["struct"]}, "fields": {"type": "array", "items": {"type": "string"}}}} + ] + }"#, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Object", "Object"]); + let discriminator = union(ty).discriminator().unwrap(); + assert_eq!( + cases(discriminator), + [("int32", "Object".into()), ("struct", "Object".into())] + ); +} + +#[test] +fn discriminator_is_named_by_x_fern_property_name() { + let forge = parse(&responding_with_schemas( + r##"{ + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind"} + }"##, + r#"{ + "A": {"type": "object", "properties": {"kind": {"type": "string", "x-fern-property-name": "pet_kind"}}}, + "B": {"type": "object", "properties": {"kind": {"type": "string", "x-fern-property-name": "pet_kind"}}} + }"#, + )); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!(discriminator.name(), &Name::new("pet_kind")); +} + +#[test] +fn discriminator_of_any_of() { + let forge = parse(&responding_with_schemas( + r##"{ + "anyOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind", "mapping": {"a": "#/components/schemas/A", "b": "#/components/schemas/B"}} + }"##, + A_AND_B, + )); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!( + cases(discriminator), + [("a", "Model(A)".into()), ("b", "Model(B)".into())] + ); +} + +#[test] +fn discriminator_default_mapping_is_the_default() { + let forge = parse( + r##"{ + "openapi": "3.2.0", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "get", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind", "mapping": {"a": "#/components/schemas/A"}, "defaultMapping": "#/components/schemas/B"} + }}}}}}}}, + "components": {"schemas": { + "A": {"type": "object", "properties": {"kind": {"type": "string"}}}, + "B": {"type": "object", "properties": {"kind": {"type": "string"}}} + }} + }"##, + ); + + let discriminator = union(response_type(&forge, "a.get")).discriminator().unwrap(); + assert_eq!(discriminator.default().unwrap().schema_name(), "B"); +} + +#[test] +fn discriminated_union_is_a_member_of_another_union() { + let forge = parse(&responding_with_schemas( + r##"{"oneOf": [ + {"oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], "discriminator": {"propertyName": "kind"}}, + {"type": "string"} + ]}"##, + A_AND_B, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["String", "Union"]); + assert!(union(ty).discriminator().is_none()); + let inner = union(ty).members().find(|m| matches!(m, Type::Union(_))).unwrap(); + assert!(union(inner).discriminator().is_some()); +} + +#[test] +fn nullable_discriminated_union_is_a_member_of_a_union_with_null() { + let forge = parse(&responding_with_schemas( + r##"{ + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}], + "discriminator": {"propertyName": "kind"}, + "nullable": true + }"##, + A_AND_B, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Null", "Union"]); + assert!(union(ty).discriminator().is_none()); +} + +#[test] +fn members_with_only_required_are_dropped() { + let forge = parse(&responding_with( + r#"{ + "type": "object", + "properties": {"appeals": {"type": "string"}, "type": {"type": "string"}}, + "oneOf": [{"type": "object", "required": ["appeals"]}, {"type": "object", "required": ["type"]}] + }"#, + )); + + let mut names = field_names(object(response_type(&forge, "a.get"))); + names.sort(); + assert_eq!(names, ["appeals", "type"]); +} + +#[test] +fn member_with_only_constraints_is_dropped() { + let forge = parse(&responding_with_schemas( + r##"{"anyOf": [ + {"$ref": "#/components/schemas/A"}, + {"$ref": "#/components/schemas/B"}, + {"not": {"required": ["a", "b"]}} + ]}"##, + A_AND_B, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(A)", "Model(B)"]); +} + +#[test] +fn object_member_without_properties_allows_any_keys() { + let forge = parse(&responding_with( + r#"{"oneOf": [{"type": "object"}, {"type": "string"}]}"#, + )); + + let ty = response_type(&forge, "a.get"); + assert_eq!(kinds(ty), ["Object", "String"]); + let object = object(union(ty).members().find(|m| matches!(m, Type::Object(_))).unwrap()); + assert_eq!(object.fields().len(), 0); + assert_eq!(scalar(object.extra().unwrap()), Scalar::Unknown); +} + +#[test] +fn type_next_to_a_union_is_dropped() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "anyOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}]}"##, + A_AND_B, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Model(A)", "Model(B)"]); +} + +#[test] +fn properties_next_to_a_union_are_intersected_with_it() { + let forge = parse(&responding_with_schemas( + r##"{ + "type": "object", "properties": {"id": {"type": "string"}}, + "oneOf": [{"$ref": "#/components/schemas/A"}, {"$ref": "#/components/schemas/B"}] + }"##, + A_AND_B, + )); + + assert_eq!(kinds(response_type(&forge, "a.get")), ["Object", "Union"]); +} diff --git a/tests/wasm.rs b/tests/wasm.rs new file mode 100644 index 000000000..6efe095ea --- /dev/null +++ b/tests/wasm.rs @@ -0,0 +1,513 @@ +//! Runs the TypeScript generator compiled to a wasm plugin, from +//! tests/wasm/ts, and checks that it generates the same files as the +//! native generator. +//! +//! `bench` compares their speed on the documents in `FORGE_SPEC`, one path +//! or several separated like `PATH`: +//! +//! ```sh +//! FORGE_SPEC=openapi.json cargo test --release --features wasm --test wasm -- --ignored --nocapture +//! ``` + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use forge::wasm::{WIT, WasmPlugin}; +use forge::{Err, File, Forge, Plugin}; +use rayon::prelude::*; + +/// Writes out everything forge's public API gives, which the guest also +/// writes, with its `dump` feature, through the WIT. +#[path = "wasm/dump.rs"] +mod dump; + +/// The native generator, from src/bin/forge/ts.rs. +#[path = "../src/bin/forge"] +mod bin { + pub mod ts; +} + +/// A document with nested groups, path and query parameters, bodies, +/// errors, and models that are enums, unions with a discriminator, +/// intersections, maps and nullable, and that refer to each other in a +/// cycle. +const SPEC: &str = r##"{ + "openapi": "3.1.0", + "info": {"title": "Pets", "version": "2", "description": "A pet store."}, + "servers": [{"url": "https://api.example.com/v1"}], + "paths": { + "/pets": { + "get": { + "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "list", "summary": "Lists pets.", + "parameters": [ + {"name": "limit", "in": "query", "schema": {"type": "integer", "format": "int32"}}, + {"name": "tags", "in": "query", "explode": false, "schema": {"type": "array", "items": {"type": "string"}}} + ], + "responses": { + "200": {"description": "ok", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}}}}, + "default": {"description": "error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + }, + "post": { + "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "create", "deprecated": true, + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewPet"}}}}, + "responses": {"201": {"description": "created", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}} + } + }, + "/pets/{pet_id}": { + "get": { + "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "get", + "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}], + "responses": { + "200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}, + "404": {"description": "missing", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + } + }, + "/pets/{pet_id}/photos": { + "put": { + "x-fern-sdk-group-name": "pets.photos", "x-fern-sdk-method-name": "upload", + "parameters": [{"name": "pet_id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}, + "responses": {"204": {"description": "done"}} + } + }, + "/owners": { + "get": { + "x-fern-sdk-group-name": "owners", "x-fern-sdk-method-name": "list", + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": { + "type": "object", + "properties": { + "owners": {"type": "array", "items": {"$ref": "#/components/schemas/Owner"}}, + "next": {"type": ["string", "null"]} + }, + "required": ["owners"] + }}}}} + } + } + }, + "components": {"schemas": { + "Pet": {"description": "A pet.", "allOf": [ + {"$ref": "#/components/schemas/NewPet"}, + {"type": "object", "properties": { + "id": {"type": "string", "format": "uuid"}, + "created_at": {"type": "string", "format": "date-time"}, + "owner": {"$ref": "#/components/schemas/Owner"} + }, "required": ["id"]} + ]}, + "NewPet": {"type": "object", "properties": { + "name": {"type": "string", "description": "The name."}, + "kind": {"$ref": "#/components/schemas/Kind"}, + "tags": {"type": "object", "additionalProperties": {"type": "string"}}, + "weight": {"type": "number", "format": "float", "deprecated": true} + }, "required": ["name", "kind"]}, + "Kind": { + "oneOf": [{"$ref": "#/components/schemas/Cat"}, {"$ref": "#/components/schemas/Dog"}], + "discriminator": {"propertyName": "type", "mapping": {"cat": "#/components/schemas/Cat", "dog": "#/components/schemas/Dog"}} + }, + "Cat": {"type": "object", "properties": {"type": {"type": "string", "enum": ["cat"]}, "lives": {"type": "integer"}}, "required": ["type"]}, + "Dog": {"type": "object", "properties": {"type": {"type": "string", "enum": ["dog"]}, "good": {"type": "boolean"}}, "required": ["type"]}, + "Owner": {"type": "object", "properties": { + "name": {"type": "string"}, + "status": {"type": "string", "enum": ["active", "away"]}, + "pets": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}, + "birthday": {"type": "string", "format": "date"}, + "avatar": {"type": "string", "format": "byte"} + }}, + "Error": {"type": "object", "properties": { + "code": {"type": "integer", "format": "int64"}, + "message": {"type": "string"} + }, "required": ["message"]} + }} +}"##; + +/// A document with everything forge reads that the TypeScript generator +/// doesn't use: header parameters, availability, hidden methods, +/// confirmations, aliases, extensions of every JSON type, read-only, +/// sensitive and defaulted fields, limits on arrays, binary content, +/// descriptions of content, unions that are not exclusive, and groups +/// described with dotted keys. +const FEATURES: &str = r##"{ + "openapi": "3.1.0", + "info": {"title": "Features", "version": "3"}, + "x-forge-commands": {"pets": {"description": "Pets."}, "admin.pets": {"description": "Pets, for admins."}}, + "paths": { + "/pets/{id}": { + "parameters": [{"name": "X-Trace", "in": "header", "schema": {"type": "string"}}], + "get": { + "x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "get", + "x-fern-availability": "beta", "x-forge-hidden": true, + "x-custom": {"b": 1, "a": [1.5, "two \"quoted\"\n", null, true, {}], "b": 2}, + "x-flag": false, "x-count": 9007199254740993, "x-text": "é 😀", + "parameters": [ + {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10}}, + {"name": "x-trace", "in": "header", "required": true, "description": "Traces the call.", "schema": {"type": "string"}}, + {"name": "Authorization", "in": "header", "schema": {"type": "string", "x-sensitive": true}} + ], + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Pet"}}}}} + }, + "delete": { + "x-forge-aliases": [ + {"x-fern-sdk-group-name": "pets", "x-fern-sdk-method-name": "remove", "x-fern-availability": "deprecated", + "x-forge-require-confirmation": "This deletes the pet.", "x-alias": [1]}, + {"x-fern-sdk-group-name": "admin.pets", "x-fern-sdk-method-name": "delete", "x-forge-require-confirmation": ""} + ], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": {"204": {"description": "gone"}} + } + }, + "/pets/{id}/photo": { + "put": { + "x-fern-sdk-group-name": "pets.photos", "x-fern-sdk-method-name": "upload", + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], + "requestBody": {"required": true, "content": { + "application/octet-stream": {"schema": {"type": "string", "format": "binary", "description": "The photo."}}, + "multipart/form-data": {"schema": {"type": "object", "properties": { + "file": {"type": "string", "format": "binary"}, + "caption": {"type": "string", "default": "A pet."} + }, "required": ["file"]}} + }}, + "responses": {"204": {"description": "done"}} + } + } + }, + "components": {"schemas": { + "Pet": {"type": "object", "properties": { + "id": {"type": "string", "readOnly": true}, + "password": {"type": "string", "x-sensitive": true}, + "tags": {"type": "array", "items": {"type": "string"}, "maxItems": 3}, + "kind": {"type": "string", "enum": ["cat", "dog"], "default": "cat"}, + "meta": {"type": "object", "additionalProperties": true, "default": {"z": 1, "a": [true, null]}}, + "owner": {"$ref": "#/components/schemas/Owner", "description": "Who has the pet."}, + "nickname": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + "age": {"oneOf": [{"type": "string"}, {"type": "integer"}]} + }, "required": ["id"]}, + "Owner": {"type": "object", "description": "A person.", "properties": { + "pets": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}} + }} + }} +}"##; + +/// The plugin reads, through the WIT, everything forge's API gives: the +/// guest's copy of the API, run on what the host sends, writes the same +/// dump as forge itself. +#[test] +fn reads_all_of_the_forge() { + let plugin = WasmPlugin::from_file(dump_guest()).unwrap(); + for spec in [FEATURES, SPEC] { + let forge = parse(spec.as_bytes()); + let native = dump::dump(&forge); + // Every feature is in the dump, so the comparison covers it. + if spec == FEATURES { + for seen in [ + "availability Some(\"beta\") hidden true", + "confirmation Some(\"This deletes the pet.\")", + "confirmation Some(\"\")", + "extension \"x-custom\"", + "extension \"x-alias\"", + "headers", + "read only true", + "sensitive true", + "default Some(", + "max items Some(3)", + "Binary", + "description \"The photo.\"", + "union exclusive false", + "union exclusive true", + "Pets, for admins.", + ] { + assert!(native.contains(seen), "the dump has no {seen:?}:\n{native}"); + } + } + let files = wasm(&forge, &plugin).unwrap(); + let wasm = String::from_utf8(files["dump.txt"].clone()).unwrap(); + assert_eq!(wasm, native); + } +} + +#[test] +fn generates_what_the_native_generator_does() { + let forge = parse(SPEC.as_bytes()); + let native = native(&forge).unwrap(); + assert!(native.len() > 10, "only {} files", native.len()); + same(&native, &wasm(&forge, plugin()).unwrap()); +} + +/// An error of the generator comes back from the plugin as it is. +#[test] +fn returns_errors() { + let spec = SPEC.replace("\"Error\"", "\"-\"").replace("schemas/Error", "schemas/-"); + let forge = parse(spec.as_bytes()); + let native = native(&forge).expect_err("the native generator succeeded").to_string(); + let wasm = wasm(&forge, plugin()).expect_err("the plugin succeeded").to_string(); + assert_eq!(wasm, native); +} + +/// `forge --target wasm://PATH` runs the plugin at PATH, and writes what +/// `--target ts` does. +#[test] +fn cli() { + let out = std::env::temp_dir().join(format!("forge-cli-wasm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let target = format!("wasm://{}", guest().display()); + let result = forge_cli(&["-", "--target", &target, "--out", out.to_str().unwrap()]); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + + let mut written = Files::new(); + read_dir(&out, &out, &mut written); + same(&native(&parse(SPEC.as_bytes())).unwrap(), &written); + std::fs::remove_dir_all(&out).unwrap(); +} + +#[test] +fn cli_needs_a_path() { + let result = forge_cli(&["-", "--target", "wasm://", "--out", "sdk"]); + assert!(!result.status.success()); + let err = String::from_utf8_lossy(&result.stderr); + assert!(err.contains("wasm:// needs the path of a plugin"), "{err}"); +} + +#[test] +fn cli_missing_plugin() { + let result = forge_cli(&["-", "--target", "wasm://missing.wasm", "--out", "sdk"]); + assert!(!result.status.success()); + let err = String::from_utf8_lossy(&result.stderr); + assert!(err.starts_with("forge: missing.wasm: "), "{err}"); +} + +/// Runs `forge` with `args`, writing [`SPEC`] to it. +fn forge_cli(args: &[&str]) -> std::process::Output { + use std::io::Write; + use std::process::Stdio; + + let mut child = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(SPEC.as_bytes()).unwrap(); + child.wait_with_output().unwrap() +} + +/// Adds every file under `dir` to `files`, by its path from `root`. +fn read_dir(root: &Path, dir: &Path, files: &mut Files) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + read_dir(root, &path, files); + } else { + let rel = path.strip_prefix(root).unwrap(); + let rel: Vec<_> = rel.iter().map(|part| part.to_str().unwrap()).collect(); + files.insert(rel.join("/"), std::fs::read(&path).unwrap()); + } + } +} + +/// The guest's copy of the WIT is the one `forge::wasm` has. +#[test] +fn wit_is_current() { + plugin(); + assert_eq!(std::fs::read_to_string(guest_dir().join("wit/forge.wit")).unwrap(), WIT); +} + +#[test] +#[ignore = "set FORGE_SPEC to the documents to benchmark, and build with --release"] +fn bench() { + let paths = std::env::var_os("FORGE_SPEC").expect("missing FORGE_SPEC"); + let wasm_path = guest(); + println!( + "\nplugin: {} ({} KB)", + wasm_path.display(), + std::fs::metadata(wasm_path).unwrap().len() / 1024 + ); + + let start = Instant::now(); + let plugin = WasmPlugin::from_file(wasm_path).unwrap(); + println!("compile: {:?} (once per plugin; cacheable)", start.elapsed()); + + let threads = rayon::current_num_threads(); + let single = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + + for path in std::env::split_paths(&paths).filter(|path| !path.as_os_str().is_empty()) { + let json = std::fs::read(&path).unwrap(); + let forge = parse(&json); + let groups = forge.groups().count(); + + let start = Instant::now(); + let files = wasm(&forge, &plugin).unwrap(); + let cold = start.elapsed(); + same(&native(&forge).unwrap(), &files); + let dumped = wasm(&forge, &WasmPlugin::from_file(dump_guest()).unwrap()).unwrap(); + assert!( + dumped["dump.txt"] == dump::dump(&forge).into_bytes(), + "the dumps of {} differ", + path.display() + ); + + println!( + "\n{} ({} MB, {groups} groups, {} files, identical output and dump)", + path.display(), + json.len() / 1_000_000, + files.len(), + ); + println!(" first wasm run, instantiating: {cold:?}"); + println!(" {:<22} {:>10} {:>10} {:>8}", "", "native", "wasm", "ratio"); + for (name, pool) in [ + (format!("{threads} threads"), None), + ("1 thread".to_owned(), Some(&single)), + ] { + let run = |f: &(dyn Fn() + Sync)| match pool { + Some(pool) => pool.install(f), + None => f(), + }; + let native = median(|| run(&|| drop(native(&forge).unwrap()))); + let wasm = median(|| run(&|| drop(wasm(&forge, &plugin).unwrap()))); + row(&name, native, wasm); + } + + // `run` calls `generate` alone before the groups, so it bounds + // how much the threads can help. + let native = median(|| { + let files = bin::ts::Generator.generate(&forge).unwrap(); + files.for_each(|file| drop(file.content().unwrap())); + }); + let wasm = median(|| plugin.generate(&forge).unwrap().for_each(drop)); + row("serial `generate` only", native, wasm); + + // A group runs on one thread, so the slowest one bounds it too. + let (native, wasm) = forge + .groups() + .map(|group| { + let native = median(|| { + let files = bin::ts::Generator.generate_group(group).unwrap(); + files.for_each(|file| drop(file.content().unwrap())); + }); + let wasm = median(|| plugin.generate_group(group).unwrap().for_each(drop)); + (native, wasm) + }) + .max() + .unwrap(); + row("slowest group", native, wasm); + } +} + +fn row(name: &str, native: Duration, wasm: Duration) { + println!( + " {name:<22} {:>10} {:>10} {:>7.2}x", + format!("{native:.1?}"), + format!("{wasm:.1?}"), + wasm.as_secs_f64() / native.as_secs_f64(), + ); +} + +/// Returns the median time `f` takes, of several runs. +fn median(f: impl Fn()) -> Duration { + let mut times: Vec = (0..7) + .map(|_| { + let start = Instant::now(); + f(); + start.elapsed() + }) + .collect(); + times.sort(); + times[times.len() / 2] +} + +fn parse(json: &[u8]) -> Forge { + serde_json::from_slice(json).unwrap() +} + +/// The files of an SDK, by path. +type Files = BTreeMap>; + +/// Runs `plugin` and renders its files as `forge::write` does, in +/// parallel, without writing them. +fn render(forge: &Forge, plugin: &P) -> Result { + let files = forge::run(forge, plugin)?; + files + .into_par_iter() + .map(|file| Ok((file.path().to_owned(), file.content()?.into_owned()))) + .filter(|file| !matches!(file, Ok((_, content)) if content.is_empty())) + .collect() +} + +fn native(forge: &Forge) -> Result { + render(forge, &bin::ts::Generator) +} + +fn wasm(forge: &Forge, plugin: &WasmPlugin) -> Result { + render(forge, plugin) +} + +#[track_caller] +fn same(native: &Files, wasm: &Files) { + let paths = |files: &Files| files.keys().cloned().collect::>(); + assert_eq!(paths(wasm), paths(native), "the plugin generated other files"); + for (path, content) in native { + assert!( + wasm[path] == *content, + "{path} differs:\n--- native\n{}\n--- wasm\n{}", + String::from_utf8_lossy(content), + String::from_utf8_lossy(&wasm[path]), + ); + } +} + +fn plugin() -> &'static WasmPlugin { + static PLUGIN: OnceLock = OnceLock::new(); + PLUGIN.get_or_init(|| WasmPlugin::from_file(guest()).unwrap()) +} + +fn guest_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/wasm/ts") +} + +/// Builds the guest, after giving it the current WIT, and returns the +/// path of its component. +fn guest() -> &'static Path { + static GUEST: OnceLock = OnceLock::new(); + GUEST.get_or_init(|| build_guest("wasm-guest", &[])) +} + +/// Builds the guest with the `dump` feature, whose `generate` returns +/// tests/wasm/dump.rs's dump, and returns the path of its component. +fn dump_guest() -> &'static Path { + static GUEST: OnceLock = OnceLock::new(); + GUEST.get_or_init(|| build_guest("wasm-guest-dump", &["--features", "dump"])) +} + +/// Builds the guest into `target/`, with `args`, after giving it the +/// current WIT. Builds run one at a time, since they share the guest's +/// sources. +fn build_guest(dir: &str, args: &[&str]) -> PathBuf { + static BUILD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _build = BUILD.lock().unwrap_or_else(|err| err.into_inner()); + + let source = guest_dir(); + let wit = source.join("wit/forge.wit"); + if std::fs::read_to_string(&wit).ok().as_deref() != Some(WIT) { + std::fs::write(&wit, WIT).unwrap(); + } + + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target").join(dir); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args(["build", "--release", "--target", "wasm32-wasip2"]) + .args(args) + .arg("--target-dir") + .arg(&target) + .current_dir(&source) + .status() + .expect("run cargo"); + assert!( + status.success(), + "building the guest failed; is the wasm32-wasip2 target installed?" + ); + target.join("wasm32-wasip2/release/forge_ts_wasm.wasm") +} diff --git a/tests/wasm/dump.rs b/tests/wasm/dump.rs new file mode 100644 index 000000000..1d615b28e --- /dev/null +++ b/tests/wasm/dump.rs @@ -0,0 +1,233 @@ +//! Writes out everything a forge's public API gives, for tests/wasm.rs to +//! compare. It compiles against forge itself, and in the guest, with the +//! `dump` feature, against the copy of the API the guest reads through the +//! WIT, so two dumps that agree show the WIT carries all of it. + +use std::fmt::Write; + +use forge::{Content, Field, Forge, Group, Method, Model, Name, Object, Type}; + +/// Returns everything `forge` has, as text. +pub fn dump(forge: &Forge) -> String { + let mut out = String::new(); + let o = &mut out; + line(o, 0, format_args!("version {:?}", forge.version)); + line(o, 0, format_args!("description {:?}", forge.description)); + line(o, 0, format_args!("base url {:?}", forge.base_url)); + for model in forge.models() { + self::model(o, 0, model); + } + for group in forge.groups() { + self::group(o, group); + } + out +} + +fn line(out: &mut String, depth: usize, args: std::fmt::Arguments<'_>) { + let _ = writeln!(out, "{}{args}", " ".repeat(depth)); +} + +fn name(name: &Name) -> String { + name.words().join(" ") +} + +fn group(o: &mut String, group: Group<'_>) { + let path: Vec = group.path().map(name).collect(); + line(o, 0, format_args!("group {path:?} {:?}", group.description())); + let parent = group.parent().map(|parent| name(parent.name())); + let children: Vec = group.children().map(|child| name(child.name())).collect(); + let descendants: Vec = group.descendants().map(|d| name(d.name())).collect(); + line( + o, + 1, + format_args!("parent {parent:?} children {children:?} descendants {descendants:?}"), + ); + for model in group.models() { + self::model(o, 1, model); + } + for method in group.methods() { + self::method(o, method); + } +} + +fn model(o: &mut String, depth: usize, model: Model<'_>) { + let group = model.group().map(|group| group.path().map(name).collect::>()); + line( + o, + depth, + format_args!( + "model {:?} name {:?} group {group:?} description {:?} deprecated {}", + model.schema_name(), + name(model.name()), + model.description(), + model.is_deprecated(), + ), + ); + ty(o, depth + 1, None, model.ty()); +} + +fn method(o: &mut String, method: Method<'_>) { + line( + o, + 1, + format_args!( + "method {:?} {} {:?} in {:?}", + name(method.name()), + method.http_method().as_str(), + method.path(), + name(method.group().name()), + ), + ); + line( + o, + 2, + format_args!("summary {:?} description {:?}", method.summary(), method.description()), + ); + line( + o, + 2, + format_args!( + "deprecated {} availability {:?} hidden {} confirmation {:?}", + method.is_deprecated(), + method.availability().map(|a| a.as_str()), + method.is_hidden(), + method.confirmation(), + ), + ); + for (key, value) in method.extensions() { + line(o, 2, format_args!("extension {key:?} = {value:?}")); + assert_eq!(method.extension(key), Some(value)); + } + if let Some(request) = method.request() { + line(o, 2, format_args!("request")); + ty(o, 3, Some(method), request); + } + if let Some(headers) = method.headers() { + line(o, 2, format_args!("headers")); + object(o, 3, Some(method), headers); + } + if let Some(body) = method.body() { + line( + o, + 2, + format_args!( + "body required {} description {:?}", + body.is_required(), + body.description() + ), + ); + let json = body.json().map(|c| c.media_type()); + line(o, 3, format_args!("json {json:?}")); + for content in body.contents() { + self::content(o, content); + } + } + for (kind, responses) in [ + ("response", method.responses().collect::>()), + ("error", method.errors().collect()), + ] { + for response in responses { + let json = response.json().map(|c| c.media_type()); + line( + o, + 2, + format_args!( + "{kind} {:?} {:?} json {json:?}", + response.status(), + response.description() + ), + ); + for content in response.contents() { + self::content(o, content); + } + } + } +} + +fn content(o: &mut String, content: Content<'_>) { + line( + o, + 3, + format_args!( + "content {:?} json {} description {:?}", + content.media_type(), + content.is_json(), + content.description(), + ), + ); + ty(o, 4, None, content.ty()); +} + +/// Writes `ty`, and the location in `method` of each field it has. +fn ty(o: &mut String, depth: usize, method: Option>, ty: Type<'_>) { + match ty { + Type::Scalar(scalar) => line(o, depth, format_args!("{scalar:?}")), + Type::Enum(values) => line(o, depth, format_args!("enum {:?}", values.values().collect::>())), + Type::Array(array) => { + line(o, depth, format_args!("array max items {:?}", array.max_items())); + self::ty(o, depth + 1, method, array.elem()); + } + Type::Object(object) => self::object(o, depth, method, object), + Type::Union(union) => { + line(o, depth, format_args!("union exclusive {}", union.is_exclusive())); + if let Some(d) = union.discriminator() { + let cases: Vec<&str> = d.cases().map(|(value, _)| value).collect(); + let default = d.default().map(|m| m.schema_name()); + line( + o, + depth + 1, + format_args!( + "discriminator {:?} {:?} cases {cases:?} default {default:?}", + d.wire_name(), + name(d.name()) + ), + ); + for (_, member) in d.cases() { + self::ty(o, depth + 2, method, member); + } + } + for member in union.members() { + self::ty(o, depth + 1, method, member); + } + } + Type::Intersection(intersection) => { + line(o, depth, format_args!("intersection")); + for member in intersection.members() { + self::ty(o, depth + 1, method, member); + } + } + // A model is written once, where it is declared, so cycles end here. + Type::Model(model) => line(o, depth, format_args!("model {:?}", model.schema_name())), + } +} + +fn object(o: &mut String, depth: usize, method: Option>, object: Object<'_>) { + line(o, depth, format_args!("object")); + for field in object.fields() { + self::field(o, depth + 1, method, field); + } + if let Some(extra) = object.extra() { + line(o, depth + 1, format_args!("extra")); + ty(o, depth + 2, method, extra); + } +} + +fn field(o: &mut String, depth: usize, method: Option>, field: Field<'_>) { + let location = method.and_then(|method| method.location(field)); + line( + o, + depth, + format_args!( + "field {:?} {:?} required {} deprecated {} read only {} sensitive {} default {:?} location {location:?} description {:?}", + field.wire_name(), + name(field.name()), + field.is_required(), + field.is_deprecated(), + field.is_read_only(), + field.is_sensitive(), + field.default(), + field.description(), + ), + ); + ty(o, depth + 1, method, field.ty()); +} diff --git a/tests/wasm/ts/Cargo.lock b/tests/wasm/ts/Cargo.lock new file mode 100644 index 000000000..4b7ba2030 --- /dev/null +++ b/tests/wasm/ts/Cargo.lock @@ -0,0 +1,361 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "forge-ts-wasm" +version = "0.1.0" +dependencies = [ + "elsa", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[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.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/wasm/ts/Cargo.toml b/tests/wasm/ts/Cargo.toml new file mode 100644 index 000000000..a81338482 --- /dev/null +++ b/tests/wasm/ts/Cargo.toml @@ -0,0 +1,33 @@ +# The TypeScript generator of the `forge` command, compiled to a forge wasm +# plugin. tests/wasm.rs builds it with: +# +# cargo build --release --target wasm32-wasip2 +# +# Its own workspace, so it builds apart from forge, for another target. +[workspace] + +[package] +name = "forge-ts-wasm" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[features] +# Makes `generate` return everything the API gives, from tests/wasm/dump.rs, +# rather than the SDK. +dump = [] + +[dependencies] +elsa = "1" +# Reads the JSON text the host sends for a `Json`, keeping objects in order. +serde_json = { version = "1", features = ["preserve_order"] } +wit-bindgen = "0.57" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = true diff --git a/tests/wasm/ts/src/json.rs b/tests/wasm/ts/src/json.rs new file mode 100644 index 000000000..da6180617 --- /dev/null +++ b/tests/wasm/ts/src/json.rs @@ -0,0 +1,80 @@ +//! `forge::Json`, as src/json.rs has it, read from the JSON text the host +//! sends for a value. + +/// A JSON value. See `forge::Json`. +#[derive(Clone, Debug, PartialEq)] +pub enum Json { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(Vec<(String, Json)>), +} + +impl Json { + /// Reads `text`, which the host writes, so it is always JSON. + pub(crate) fn parse(text: &str) -> Json { + let value: serde_json::Value = serde_json::from_str(text).expect("the host sends JSON"); + Json::from(value) + } + + pub fn is_null(&self) -> bool { + matches!(self, Json::Null) + } + + pub fn as_bool(&self) -> Option { + match *self { + Json::Bool(b) => Some(b), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match *self { + Json::Number(n) => Some(n), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Json::String(s) => Some(s), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Json]> { + match self { + Json::Array(elements) => Some(elements), + _ => None, + } + } + + pub fn as_object(&self) -> Option<&[(String, Json)]> { + match self { + Json::Object(members) => Some(members), + _ => None, + } + } + + pub fn get(&self, key: &str) -> Option<&Json> { + self.as_object()?.iter().find(|(k, _)| k == key).map(|(_, value)| value) + } +} + +impl From for Json { + fn from(value: serde_json::Value) -> Json { + match value { + serde_json::Value::Null => Json::Null, + serde_json::Value::Bool(b) => Json::Bool(b), + serde_json::Value::Number(n) => Json::Number(n.as_f64().unwrap_or(f64::NAN)), + serde_json::Value::String(s) => Json::String(s), + serde_json::Value::Array(elements) => Json::Array(elements.into_iter().map(Json::from).collect()), + // `preserve_order` keeps the members in the order written. + serde_json::Value::Object(members) => { + Json::Object(members.into_iter().map(|(k, v)| (k, Json::from(v))).collect()) + } + } + } +} diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs new file mode 100644 index 000000000..42ff7a416 --- /dev/null +++ b/tests/wasm/ts/src/lib.rs @@ -0,0 +1,1212 @@ +//! The TypeScript generator of the `forge` command, compiled to a forge +//! wasm plugin. +//! +//! The generator's source, in src/bin/forge/ts, and the `forge::ts` +//! library it prints with, in src/plugin/ts, compile here unchanged. This +//! crate calls itself `forge` and mirrors forge's public API on top of the +//! host's `forge` interface (see `forge::wasm::WIT`). +//! +//! The forge stays in the host. A part is fetched the first time the +//! plugin reads it in a call, and kept until the call ends. + +#![allow(dead_code)] + +extern crate self as forge; + +use std::{fmt, iter}; + +use elsa::FrozenMap; + +mod json; +mod name; + +pub use json::Json; +pub use name::Name; + +mod wit { + wit_bindgen::generate!({ path: "wit/forge.wit", world: "plugin" }); +} + +use wit::forge::plugin::forge as host; + +/// `forge::plugin`: the traits from src/plugin.rs, and the `ts` library. +#[path = "../../../../src/plugin"] +pub mod plugin { + use std::borrow::Cow; + + use crate::{Forge, Group}; + + pub mod ts; + + /// The error that plugins and files return. + pub type Err = Box; + + /// A plugin that generates files. + pub trait Plugin: Sync { + type File: File; + + fn generate(&self, _: &Forge) -> Result, Err> { + Ok(std::iter::empty()) + } + + fn generate_group(&self, _: Group<'_>) -> Result, Err> { + Ok(std::iter::empty()) + } + } + + /// A file generated by a plugin. + pub trait File: Send { + fn path(&self) -> &str; + fn content(&self) -> Result, Err>; + } +} + +pub use plugin::{Err, File, Plugin, ts}; + +/// The generator, from src/bin/forge/ts.rs. +#[path = "../../../../src/bin/forge"] +mod bin { + pub mod ts; +} + +struct Component; + +/// Everything the API gives, from tests/wasm/dump.rs, which tests/wasm.rs +/// compares with forge's own. +#[cfg(feature = "dump")] +#[path = "../../dump.rs"] +mod dump; + +impl wit::Guest for Component { + /// With the `dump` feature, returns the dump of the forge instead, as + /// `dump.txt`. + #[cfg(feature = "dump")] + fn generate() -> Result, String> { + Ok(vec![wit::File { + path: "dump.txt".to_owned(), + content: dump::dump(&Forge::fetch()).into_bytes(), + }]) + } + + #[cfg(not(feature = "dump"))] + fn generate() -> Result, String> { + let forge = Forge::fetch(); + render(bin::ts::Generator.generate(&forge).map_err(|err| err.to_string())?) + } + + fn generate_group(group: u32) -> Result, String> { + let forge = Forge::fetch(); + render( + bin::ts::Generator + .generate_group(forge.group(group)) + .map_err(|err| err.to_string())?, + ) + } +} + +wit::export!(Component with_types_in wit); + +/// Renders the content of each of `files`. +fn render(files: impl Iterator) -> Result, String> { + files + .map(|file| { + let content = file.content().map_err(|err| format!("{}: {err}", file.path()))?; + Ok(wit::File { + path: file.path().to_owned(), + content: content.into_owned(), + }) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// The forge, read from the host. + +/// `forge::Forge`, read from the host a part at a time. +pub struct Forge { + pub version: String, + pub description: String, + pub base_url: String, + + groups: Vec, + top_models: Vec, + + group_data: FrozenMap>, + method_data: FrozenMap>, + model_data: FrozenMap>, + type_data: FrozenMap>, +} + +impl Forge { + fn fetch() -> Forge { + let info = host::info(); + Forge { + version: info.version, + description: info.description, + base_url: info.base_url, + groups: host::groups(), + top_models: host::models(), + group_data: FrozenMap::new(), + method_data: FrozenMap::new(), + model_data: FrozenMap::new(), + type_data: FrozenMap::new(), + } + } + + pub fn groups(&self) -> impl Iterator> { + self.groups.iter().map(|&id| self.group(id)) + } + + pub fn models(&self) -> impl ExactSizeIterator> { + self.top_models.iter().map(|&id| self.model(id)) + } + + fn group(&self, id: u32) -> Group<'_> { + Group { forge: self, id } + } + + fn method(&self, id: u32) -> Method<'_> { + Method { forge: self, id } + } + + fn model(&self, id: u32) -> Model<'_> { + Model { forge: self, id } + } + + fn depth_first<'f>(&'f self, ids: &'f [u32]) -> impl Iterator> { + let mut stack: Vec = ids.iter().rev().copied().collect(); + iter::from_fn(move || { + let group = self.group(stack.pop()?); + stack.extend(group.data().children.iter().rev()); + Some(group) + }) + } + + fn group_data(&self, id: u32) -> &GroupData { + if let Some(data) = self.group_data.get(&id) { + return data; + } + let g = host::get_group(id); + self.group_data.insert( + id, + Box::new(GroupData { + name: Name::from_words(g.name), + description: g.description, + path: g.path, + children: g.children, + methods: g.methods, + models: g.models, + }), + ) + } + + fn method_data(&self, id: u32) -> &MethodData { + if let Some(data) = self.method_data.get(&id) { + return data; + } + let m = host::get_method(id); + self.method_data.insert( + id, + Box::new(MethodData { + name: Name::from_words(m.name), + group: m.group, + http_method: match m.http_method { + host::HttpMethod::Get => HttpMethod::Get, + host::HttpMethod::Put => HttpMethod::Put, + host::HttpMethod::Post => HttpMethod::Post, + host::HttpMethod::Delete => HttpMethod::Delete, + host::HttpMethod::Options => HttpMethod::Options, + host::HttpMethod::Head => HttpMethod::Head, + host::HttpMethod::Patch => HttpMethod::Patch, + host::HttpMethod::Trace => HttpMethod::Trace, + host::HttpMethod::Query => HttpMethod::Query, + host::HttpMethod::Connect => HttpMethod::Connect, + host::HttpMethod::Other(method) => HttpMethod::Other(method), + }, + path: m.path, + summary: m.summary, + description: m.description, + deprecated: m.deprecated, + availability: m.availability.map(|availability| match availability { + host::Availability::Alpha => Availability::Alpha, + host::Availability::Beta => Availability::Beta, + host::Availability::Preview => Availability::Preview, + host::Availability::GenerallyAvailable => Availability::GenerallyAvailable, + host::Availability::Deprecated => Availability::Deprecated, + }), + hidden: m.hidden, + confirmation: m.confirmation, + request: m.request, + headers: m.headers, + body: m.body.map(|b| BodyData { + required: b.required, + description: b.description, + contents: contents(b.contents), + json: b.json, + }), + responses: m.responses.into_iter().map(response).collect(), + errors: m.errors.into_iter().map(response).collect(), + extensions: m + .extensions + .into_iter() + .map(|(name, value)| (name, Json::parse(&value))) + .collect(), + }), + ) + } + + fn model_data(&self, id: u32) -> &ModelData { + if let Some(data) = self.model_data.get(&id) { + return data; + } + let m = host::get_model(id); + self.model_data.insert( + id, + Box::new(ModelData { + schema_name: m.schema_name, + name: Name::from_words(m.name), + group: m.group, + description: m.description, + deprecated: m.deprecated, + ty: m.ty, + }), + ) + } + + fn type_data(&self, id: u32) -> &TypeData { + if let Some(data) = self.type_data.get(&id) { + return data; + } + let data = match host::get_type(id) { + host::TypeInfo::Scalar(scalar) => TypeData::Scalar(match scalar { + host::Scalar::Unknown => Scalar::Unknown, + host::Scalar::Null => Scalar::Null, + host::Scalar::Boolean => Scalar::Boolean, + host::Scalar::String => Scalar::String, + host::Scalar::Number(number) => Scalar::Number(match number { + host::Number::Int => Number::Int, + host::Number::Int8 => Number::Int8, + host::Number::Int16 => Number::Int16, + host::Number::Int32 => Number::Int32, + host::Number::Int64 => Number::Int64, + host::Number::Uint8 => Number::Uint8, + host::Number::Uint16 => Number::Uint16, + host::Number::Uint32 => Number::Uint32, + host::Number::Uint64 => Number::Uint64, + host::Number::Float => Number::Float, + host::Number::Double => Number::Double, + }), + host::Scalar::DateTime => Scalar::DateTime, + host::Scalar::Date => Scalar::Date, + host::Scalar::Bytes => Scalar::Bytes, + host::Scalar::Binary => Scalar::Binary, + }), + host::TypeInfo::Enum(values) => TypeData::Enum( + values + .into_iter() + .map(|value| match value { + host::EnumValue::String(s) => EnumData::String(s), + host::EnumValue::Number(n) => EnumData::Number(n), + host::EnumValue::Boolean(b) => EnumData::Boolean(b), + }) + .collect(), + ), + host::TypeInfo::Array(array) => TypeData::Array(ArrayData { + elem: array.elem, + max_items: array.max_items, + }), + host::TypeInfo::Object(object) => TypeData::Object(ObjectData { + fields: object + .fields + .into_iter() + .map(|f| FieldData { + wire_name: f.wire_name, + name: Name::from_words(f.name), + required: f.required, + ty: f.ty, + description: f.description, + deprecated: f.deprecated, + read_only: f.read_only, + sensitive: f.sensitive, + default: f.default.as_deref().map(Json::parse), + }) + .collect(), + extra: object.extra, + }), + host::TypeInfo::Union(union) => TypeData::Union(UnionData { + members: union.members, + exclusive: union.exclusive, + discriminator: union.discriminator.map(|d| DiscriminatorData { + wire_name: d.wire_name, + name: Name::from_words(d.name), + cases: d.cases, + default: d.default, + }), + }), + host::TypeInfo::Intersection(members) => TypeData::Intersection(members), + host::TypeInfo::Model(id) => TypeData::Model(id), + }; + self.type_data.insert(id, Box::new(data)) + } +} + +fn contents(contents: Vec) -> Vec { + contents + .into_iter() + .map(|c| ContentData { + media_type: c.media_type, + ty: c.ty, + description: c.description, + }) + .collect() +} + +fn response(r: host::ResponseInfo) -> ResponseData { + ResponseData { + status: r.status, + description: r.description, + contents: contents(r.contents), + json: r.json, + } +} + +struct GroupData { + name: Name, + description: String, + path: Vec, + children: Vec, + methods: Vec, + models: Vec, +} + +struct MethodData { + name: Name, + group: u32, + http_method: HttpMethod, + path: String, + summary: String, + description: String, + deprecated: bool, + availability: Option, + hidden: bool, + confirmation: Option, + request: Option, + headers: Option, + body: Option, + responses: Vec, + errors: Vec, + extensions: Vec<(String, Json)>, +} + +struct BodyData { + required: bool, + description: String, + contents: Vec, + json: Option, +} + +struct ResponseData { + status: String, + description: String, + contents: Vec, + json: Option, +} + +struct ContentData { + media_type: String, + ty: u32, + description: String, +} + +struct ModelData { + schema_name: String, + name: Name, + group: Option, + description: String, + deprecated: bool, + ty: u32, +} + +enum TypeData { + Scalar(Scalar), + Enum(Vec), + Array(ArrayData), + Object(ObjectData), + Union(UnionData), + Intersection(Vec), + Model(u32), +} + +enum EnumData { + String(String), + Number(f64), + Boolean(bool), +} + +struct ArrayData { + elem: u32, + max_items: Option, +} + +struct ObjectData { + fields: Vec, + extra: Option, +} + +struct FieldData { + wire_name: String, + name: Name, + required: bool, + ty: u32, + description: String, + deprecated: bool, + read_only: bool, + sensitive: bool, + default: Option, +} + +struct UnionData { + members: Vec, + exclusive: bool, + discriminator: Option, +} + +struct DiscriminatorData { + wire_name: String, + name: Name, + cases: Vec<(String, u32)>, + default: Option, +} + +// --------------------------------------------------------------------------- +// The public API, as src/lib.rs, src/types.rs and src/method.rs have it. + +#[derive(Clone, Copy)] +pub struct Group<'f> { + forge: &'f Forge, + id: u32, +} + +impl<'f> Group<'f> { + pub fn name(self) -> &'f Name { + &self.data().name + } + + pub fn description(self) -> &'f str { + &self.data().description + } + + pub fn path(self) -> impl ExactSizeIterator + DoubleEndedIterator { + let forge = self.forge; + self.data().path.iter().map(move |&id| forge.group(id).name()) + } + + pub fn parent(self) -> Option> { + let [.., parent, _] = self.data().path[..] else { + return None; + }; + Some(self.forge.group(parent)) + } + + pub fn children(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().children.iter().map(move |&id| forge.group(id)) + } + + pub fn descendants(self) -> impl Iterator> { + self.forge.depth_first(&self.data().children) + } + + pub fn methods(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().methods.iter().map(move |&id| forge.method(id)) + } + + pub fn models(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().models.iter().map(move |&id| forge.model(id)) + } + + fn data(self) -> &'f GroupData { + self.forge.group_data(self.id) + } +} + +impl PartialEq for Group<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.forge, other.forge) && self.id == other.id + } +} + +impl Eq for Group<'_> {} + +impl std::hash::Hash for Group<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.forge, state); + self.id.hash(state); + } +} + +impl fmt::Debug for Group<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Group") + .field("path", &self.path().collect::>()) + .finish() + } +} + +#[derive(Clone, Copy)] +pub struct Method<'f> { + forge: &'f Forge, + id: u32, +} + +impl<'f> Method<'f> { + pub fn name(self) -> &'f Name { + &self.data().name + } + + pub fn group(self) -> Group<'f> { + self.forge.group(self.data().group) + } + + pub fn http_method(self) -> &'f HttpMethod { + &self.data().http_method + } + + pub fn path(self) -> &'f str { + &self.data().path + } + + pub fn summary(self) -> &'f str { + &self.data().summary + } + + pub fn description(self) -> &'f str { + &self.data().description + } + + pub fn is_deprecated(self) -> bool { + self.data().deprecated + } + + pub fn availability(self) -> Option { + self.data().availability + } + + pub fn is_hidden(self) -> bool { + self.data().hidden + } + + pub fn confirmation(self) -> Option<&'f str> { + self.data().confirmation.as_deref() + } + + pub fn request(self) -> Option> { + Some(Type::new(self.forge, self.data().request?)) + } + + pub fn headers(self) -> Option> { + let id = self.data().headers?; + let TypeData::Object(data) = self.forge.type_data(id) else { + return None; + }; + Some(Object { + forge: self.forge, + id, + data, + }) + } + + pub fn location(self, field: Field<'f>) -> Option { + host::get_location(self.id, field.object, field.index).map(|location| match location { + host::Location::Path => Location::Path, + host::Location::Query(exploded) => Location::Query { exploded }, + }) + } + + pub fn body(self) -> Option> { + Some(Body { + forge: self.forge, + data: self.data().body.as_ref()?, + }) + } + + pub fn responses(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().responses.iter().map(move |data| Response { forge, data }) + } + + pub fn errors(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data().errors.iter().map(move |data| Response { forge, data }) + } + + pub fn extension(self, name: &str) -> Option<&'f Json> { + self.extensions().find(|&(n, _)| n == name).map(|(_, value)| value) + } + + pub fn extensions(self) -> impl ExactSizeIterator { + self.data() + .extensions + .iter() + .map(|(name, value)| (name.as_str(), value)) + } + + fn data(self) -> &'f MethodData { + self.forge.method_data(self.id) + } +} + +impl PartialEq for Method<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.forge, other.forge) && self.id == other.id + } +} + +impl Eq for Method<'_> {} + +impl std::hash::Hash for Method<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.forge, state); + self.id.hash(state); + } +} + +impl fmt::Debug for Method<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Method") + .field("group", &self.group().path().collect::>()) + .field("name", self.name()) + .field("http_method", self.http_method()) + .field("path", &self.path()) + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum HttpMethod { + Get, + Put, + Post, + Delete, + Options, + Head, + Patch, + Trace, + Query, + Connect, + Other(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Availability { + Alpha, + Beta, + Preview, + GenerallyAvailable, + Deprecated, +} + +impl Availability { + pub fn as_str(self) -> &'static str { + match self { + Availability::Alpha => "alpha", + Availability::Beta => "beta", + Availability::Preview => "preview", + Availability::GenerallyAvailable => "generally-available", + Availability::Deprecated => "deprecated", + } + } +} + +impl HttpMethod { + pub fn as_str(&self) -> &str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Put => "PUT", + HttpMethod::Post => "POST", + HttpMethod::Delete => "DELETE", + HttpMethod::Options => "OPTIONS", + HttpMethod::Head => "HEAD", + HttpMethod::Patch => "PATCH", + HttpMethod::Trace => "TRACE", + HttpMethod::Query => "QUERY", + HttpMethod::Connect => "CONNECT", + HttpMethod::Other(method) => method, + } + } +} + +#[derive(Clone, Copy)] +pub struct Model<'f> { + forge: &'f Forge, + id: u32, +} + +impl<'f> Model<'f> { + pub fn schema_name(self) -> &'f str { + &self.data().schema_name + } + + pub fn name(self) -> &'f Name { + &self.data().name + } + + pub fn group(self) -> Option> { + Some(self.forge.group(self.data().group?)) + } + + pub fn description(self) -> &'f str { + &self.data().description + } + + pub fn is_deprecated(self) -> bool { + self.data().deprecated + } + + pub fn ty(self) -> Type<'f> { + Type::new(self.forge, self.data().ty) + } + + fn data(self) -> &'f ModelData { + self.forge.model_data(self.id) + } +} + +impl PartialEq for Model<'_> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.forge, other.forge) && self.id == other.id + } +} + +impl Eq for Model<'_> {} + +impl std::hash::Hash for Model<'_> { + fn hash(&self, state: &mut H) { + std::ptr::hash(self.forge, state); + self.id.hash(state); + } +} + +impl fmt::Display for Model<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.schema_name()) + } +} + +impl fmt::Debug for Model<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Model") + .field("group", &self.group().map(|group| group.path().collect::>())) + .field("schema_name", &self.schema_name()) + .finish() + } +} + +#[derive(Clone, Copy, Debug)] +pub enum Type<'f> { + Scalar(Scalar), + Enum(Enum<'f>), + Array(Array<'f>), + Object(Object<'f>), + Union(Union<'f>), + Intersection(Intersection<'f>), + Model(Model<'f>), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Scalar { + Unknown, + Null, + Boolean, + String, + Number(Number), + DateTime, + Date, + Bytes, + Binary, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Number { + Int, + Int8, + Int16, + Int32, + Int64, + Uint8, + Uint16, + Uint32, + Uint64, + Float, + Double, +} + +impl Number { + pub fn is_integer(self) -> bool { + !matches!(self, Number::Float | Number::Double) + } +} + +#[derive(Clone, Copy)] +pub struct Enum<'f> { + values: &'f [EnumData], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EnumValue<'f> { + String(&'f str), + Number(f64), + Boolean(bool), +} + +#[derive(Clone, Copy)] +pub struct Array<'f> { + forge: &'f Forge, + data: &'f ArrayData, +} + +#[derive(Clone, Copy)] +pub struct Object<'f> { + forge: &'f Forge, + id: u32, + data: &'f ObjectData, +} + +#[derive(Clone, Copy)] +pub struct Field<'f> { + forge: &'f Forge, + /// The object type the field is in, and its index there, which the + /// host knows it by. + object: u32, + index: u32, + data: &'f FieldData, +} + +#[derive(Clone, Copy)] +pub struct Union<'f> { + forge: &'f Forge, + data: &'f UnionData, +} + +#[derive(Clone, Copy)] +pub struct Intersection<'f> { + forge: &'f Forge, + members: &'f [u32], +} + +#[derive(Clone, Copy)] +pub struct Discriminator<'f> { + forge: &'f Forge, + data: &'f DiscriminatorData, +} + +impl<'f> Type<'f> { + fn new(forge: &'f Forge, id: u32) -> Type<'f> { + match forge.type_data(id) { + TypeData::Scalar(scalar) => Type::Scalar(*scalar), + TypeData::Enum(values) => Type::Enum(Enum { values }), + TypeData::Array(data) => Type::Array(Array { forge, data }), + TypeData::Object(data) => Type::Object(Object { forge, id, data }), + TypeData::Union(data) => Type::Union(Union { forge, data }), + TypeData::Intersection(members) => Type::Intersection(Intersection { forge, members }), + TypeData::Model(id) => Type::Model(forge.model(*id)), + } + } +} + +impl<'f> Enum<'f> { + pub fn values(self) -> impl ExactSizeIterator> { + self.values.iter().map(|value| match value { + EnumData::String(s) => EnumValue::String(s), + EnumData::Number(n) => EnumValue::Number(*n), + EnumData::Boolean(b) => EnumValue::Boolean(*b), + }) + } +} + +impl<'f> Array<'f> { + pub fn elem(self) -> Type<'f> { + Type::new(self.forge, self.data.elem) + } + + pub fn max_items(self) -> Option { + self.data.max_items + } +} + +impl<'f> Object<'f> { + pub fn fields(self) -> impl ExactSizeIterator> { + let Object { forge, id, data } = self; + data.fields.iter().enumerate().map(move |(index, data)| Field { + forge, + object: id, + index: index as u32, + data, + }) + } + + pub fn extra(self) -> Option> { + Some(Type::new(self.forge, self.data.extra?)) + } +} + +impl<'f> Field<'f> { + pub fn wire_name(self) -> &'f str { + &self.data.wire_name + } + + pub fn name(self) -> &'f Name { + &self.data.name + } + + pub fn is_required(self) -> bool { + self.data.required + } + + pub fn ty(self) -> Type<'f> { + Type::new(self.forge, self.data.ty) + } + + pub fn description(self) -> &'f str { + &self.data.description + } + + pub fn is_deprecated(self) -> bool { + self.data.deprecated + } + + pub fn is_read_only(self) -> bool { + self.data.read_only + } + + pub fn is_sensitive(self) -> bool { + self.data.sensitive + } + + pub fn default(self) -> Option<&'f Json> { + self.data.default.as_ref() + } +} + +impl<'f> Union<'f> { + pub fn members(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data.members.iter().map(move |&id| Type::new(forge, id)) + } + + pub fn is_exclusive(self) -> bool { + self.data.exclusive + } + + pub fn discriminator(self) -> Option> { + Some(Discriminator { + forge: self.forge, + data: self.data.discriminator.as_ref()?, + }) + } +} + +impl<'f> Intersection<'f> { + pub fn members(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.members.iter().map(move |&id| Type::new(forge, id)) + } +} + +impl<'f> Discriminator<'f> { + pub fn wire_name(self) -> &'f str { + &self.data.wire_name + } + + pub fn name(self) -> &'f Name { + &self.data.name + } + + pub fn cases(self) -> impl ExactSizeIterator)> { + let forge = self.forge; + self.data + .cases + .iter() + .map(move |(value, id)| (value.as_str(), Type::new(forge, *id))) + } + + pub fn default(self) -> Option> { + Some(self.forge.model(self.data.default?)) + } +} + +impl fmt::Debug for Enum<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.values()).finish() + } +} + +impl fmt::Debug for Array<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.elem().fmt(f) + } +} + +impl fmt::Debug for Object<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Object") + .field("fields", &self.fields().collect::>()) + .field("extra", &self.extra()) + .finish() + } +} + +impl fmt::Debug for Field<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Field") + .field("wire_name", &self.wire_name()) + .field("required", &self.is_required()) + .field("ty", &self.ty()) + .finish() + } +} + +impl fmt::Debug for Union<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Union") + .field("members", &self.members().collect::>()) + .field("exclusive", &self.is_exclusive()) + .field("discriminator", &self.discriminator()) + .finish() + } +} + +impl fmt::Debug for Intersection<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Intersection") + .field("members", &self.members().collect::>()) + .finish() + } +} + +impl fmt::Debug for Discriminator<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Discriminator") + .field("wire_name", &self.wire_name()) + .field("cases", &self.cases().collect::>()) + .field("default", &self.default()) + .finish() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Location { + Path, + Query { exploded: bool }, +} + +#[derive(Clone, Copy)] +pub struct Body<'f> { + forge: &'f Forge, + data: &'f BodyData, +} + +#[derive(Clone, Copy)] +pub struct Response<'f> { + forge: &'f Forge, + data: &'f ResponseData, +} + +#[derive(Clone, Copy)] +pub struct Content<'f> { + forge: &'f Forge, + data: &'f ContentData, +} + +impl<'f> Body<'f> { + pub fn is_required(self) -> bool { + self.data.required + } + + pub fn description(self) -> &'f str { + &self.data.description + } + + pub fn contents(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data.contents.iter().map(move |data| Content { forge, data }) + } + + pub fn json(self) -> Option> { + Some(Content { + forge: self.forge, + data: &self.data.contents[self.data.json? as usize], + }) + } +} + +impl<'f> Response<'f> { + pub fn status(self) -> &'f str { + &self.data.status + } + + pub fn description(self) -> &'f str { + &self.data.description + } + + pub fn contents(self) -> impl ExactSizeIterator> { + let forge = self.forge; + self.data.contents.iter().map(move |data| Content { forge, data }) + } + + pub fn json(self) -> Option> { + Some(Content { + forge: self.forge, + data: &self.data.contents[self.data.json? as usize], + }) + } +} + +impl<'f> Content<'f> { + pub fn media_type(self) -> &'f str { + &self.data.media_type + } + + pub fn description(self) -> &'f str { + &self.data.description + } + + pub fn is_json(self) -> bool { + let base = self.media_type().split(';').next().unwrap_or_default().trim(); + let base = base.to_ascii_lowercase(); + base == "application/json" || base.ends_with("+json") + } + + pub fn ty(self) -> Type<'f> { + Type::new(self.forge, self.data.ty) + } +} + +impl fmt::Debug for Body<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Body") + .field("required", &self.is_required()) + .field("contents", &self.contents().collect::>()) + .finish() + } +} + +impl fmt::Debug for Response<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Response") + .field("status", &self.status()) + .field("contents", &self.contents().collect::>()) + .finish() + } +} + +impl fmt::Debug for Content<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Content") + .field("media_type", &self.media_type()) + .field("ty", &self.ty()) + .finish() + } +} diff --git a/tests/wasm/ts/src/name.rs b/tests/wasm/ts/src/name.rs new file mode 100644 index 000000000..e7645f676 --- /dev/null +++ b/tests/wasm/ts/src/name.rs @@ -0,0 +1,70 @@ +//! `forge::Name`, as src/name.rs has it, made from the words the host +//! sends rather than parsed. + +/// A name from the description, as lower-case words, which a generator +/// spells in the casing of its language. See `forge::Name`. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Name { + words: Vec, +} + +impl Name { + /// Returns the name with `words`, which the host gives in lower case. + pub(crate) fn from_words(words: Vec) -> Name { + Name { words } + } + + /// Returns the lower-case words of the name. + pub fn words(&self) -> &[String] { + &self.words + } + + /// Reports whether the name has no words. + pub fn is_empty(&self) -> bool { + self.words.is_empty() + } + + /// Returns the name in camelCase: `petStore`. + pub fn camel(&self) -> String { + let mut out = String::with_capacity(self.words_len()); + for (i, word) in self.words.iter().enumerate() { + if i == 0 { + out.push_str(word); + } else { + push_upper_first(&mut out, word); + } + } + out + } + + /// Returns the name in PascalCase: `PetStore`. + pub fn pascal(&self) -> String { + let mut out = String::with_capacity(self.words_len()); + for word in &self.words { + push_upper_first(&mut out, word); + } + out + } + + /// Returns the name in snake_case: `pet_store`. + pub fn snake(&self) -> String { + self.words.join("_") + } + + /// Returns the name in kebab-case: `pet-store`. + pub fn kebab(&self) -> String { + self.words.join("-") + } + + fn words_len(&self) -> usize { + self.words.iter().map(String::len).sum() + } +} + +fn push_upper_first(out: &mut String, word: &str) { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + } +} diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit new file mode 100644 index 000000000..fc75f5614 --- /dev/null +++ b/tests/wasm/ts/wit/forge.wit @@ -0,0 +1,222 @@ + +package forge:plugin@0.2.0; + +/// Read-only access to the forge a plugin runs on, which the host keeps. +/// Each id is an index the host gave out. An id the host never gave out +/// traps. +/// +/// Everything a part has is returned in one call, and orders and meanings +/// are as the Rust API documents them. +interface forge { + type group-id = u32; + type method-id = u32; + type model-id = u32; + type type-id = u32; + + /// The lower-case words of a `Name`. + type name = list; + + /// A JSON value, which forge keeps without reading, as JSON text that + /// `JSON.parse` reads back: an object's members in the order they are + /// written, each key once. WIT has no recursive types, so a value is + /// its text. + type json = string; + + record forge-info { + version: string, + description: string, + base-url: string, + } + + /// How many of each part the forge has. Ids run from 0 to the count. + record forge-counts { + groups: u32, + methods: u32, + models: u32, + types: u32, + } + + record group-info { + name: name, + description: string, + /// The groups from the top level down to this one, ending with it. + path: list, + /// The groups directly inside, sorted by name. + children: list, + /// The methods directly inside, sorted by name. + methods: list, + models: list, + } + + variant http-method { + get, put, post, delete, options, head, patch, trace, query, connect, + other(string), + } + + variant location { + path, + /// Whether the parameter is exploded. + query(bool), + } + + record content-info { + media-type: string, + ty: type-id, + /// The description of the content's schema. + description: string, + } + + record body-info { + required: bool, + description: string, + contents: list, + /// The index in `contents` of the JSON content. + json: option, + } + + record response-info { + status: string, + description: string, + contents: list, + /// The index in `contents` of the JSON content. + json: option, + } + + /// The stage of the API a method is in, from `x-fern-availability`. + enum availability { + alpha, beta, preview, generally-available, deprecated, + } + + record method-info { + name: name, + group: group-id, + http-method: http-method, + path: string, + summary: string, + description: string, + deprecated: bool, + availability: option, + /// Whether to leave the method out of listings, from `x-forge-hidden`. + hidden: bool, + /// The sentence to show before calling the method, from + /// `x-forge-require-confirmation`, or none if it needs no + /// confirmation. + confirmation: option, + request: option, + /// The header parameters: an object type, with a field for each. + /// Its fields have no location. + headers: option, + body: option, + responses: list, + errors: list, + /// The extensions of the operation, or alias, that forge does not + /// read itself, in the order they are written. + extensions: list>, + } + + record model-info { + schema-name: string, + name: name, + group: option, + description: string, + deprecated: bool, + ty: type-id, + } + + enum number { + int, int8, int16, int32, int64, uint8, uint16, uint32, uint64, float, double, + } + + variant scalar { + unknown, null, boolean, %string, number(number), date-time, date, bytes, binary, + } + + variant enum-value { + %string(string), + number(f64), + boolean(bool), + } + + record field-info { + wire-name: string, + name: name, + required: bool, + ty: type-id, + description: string, + deprecated: bool, + /// Whether the server sets the property, so a client doesn't send it. + read-only: bool, + /// Whether the value is a secret, such as a password or a token. + sensitive: bool, + /// The `default` of the property's schema. + default: option, + } + + record array-info { + elem: type-id, + /// The `maxItems`, or none if the array has no limit. + max-items: option, + } + + record object-info { + fields: list, + extra: option, + } + + record discriminator-info { + wire-name: string, + name: name, + cases: list>, + default: option, + } + + record union-info { + members: list, + /// Whether a value is at most one of the members. + exclusive: bool, + discriminator: option, + } + + variant type-info { + scalar(scalar), + %enum(list), + array(array-info), + object(object-info), + union(union-info), + intersection(list), + model(model-id), + } + + info: func() -> forge-info; + counts: func() -> forge-counts; + /// Every group, depth first. + groups: func() -> list; + /// The models at the top level, in no group. + models: func() -> list; + get-group: func(id: group-id) -> group-info; + get-method: func(id: method-id) -> method-info; + get-model: func(id: model-id) -> model-info; + get-type: func(id: type-id) -> type-info; + /// Where method `id` sends field `index` of the object type `object`, + /// or none if it is not one of its parameters. + get-location: func(id: method-id, object: type-id, index: u32) -> option; +} + +world plugin { + import forge; + use forge.{group-id}; + + /// A generated file: its path relative to the output directory, using + /// `/` as a separator, and its content. A file with no content is not + /// written. + record file { + path: string, + content: list, + } + + /// Generates files for the forge. Called exactly once. + export generate: func() -> result, string>; + /// Generates files for a group. Called exactly once for each group, in + /// parallel with other groups, each in an instance of its own. + export generate-group: func(group: group-id) -> result, string>; +}