Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ src/
├── context.rs # GlobalContext (scopes, registry, externals)
├── external_map.rs # `--external Foo=::path::Bar` resolution
├── parse/ # .d.ts → IR
│ ├── docs.rs # JSDoc extraction (incl. @throws)
│ ├── annotate.rs # `--annotate` side files: layer @rust/@throws by name
│ ├── docs.rs # JSDoc extraction (incl. @throws, @rust)
│ ├── members.rs # interface / class member converters
│ ├── merge.rs # var + interface merging
│ ├── classify.rs # interface classification (class-like vs dictionary)
Expand Down
128 changes: 128 additions & 0 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ in sync with the snapshot fixtures (`tests/fixtures/*.d.ts` paired with
* [`toString()` is `Display`](#tostring-is-display)
* [`Promise<T>` returns become `async fn`](#promiset-returns-become-async-fn)
* [`@throws` JSDoc → typed error](#throws-jsdoc--typed-error)
* [`@rust` JSDoc → Rust scalar](#rust-jsdoc--rust-scalar)
* [`--annotate` side files](#--annotate-side-files)
* [Subtyping LUB across unions](#subtyping-lub-across-unions)
* [Module declarations and namespace nesting](#module-declarations-and-namespace-nesting)
* [Type aliases and `export { X as Y }`](#type-aliases-and-export--x-as-y-)
Expand Down Expand Up @@ -3389,6 +3391,132 @@ impl CompressionStream {
setter taking a named union alias is wrapped like any binding
(`set_relative_to(&self, val: impl Into<ZonedDateTimeLike>)`).

## `@rust` JSDoc → Rust scalar

TS `number` lowers to `f64`, which is wrong-shaped for most of the
numbers a platform API actually carries (`status: number` is a `u16`,
`port: number` a `u16`, `limit: number` a `u32`). A `@rust` tag forces
the Rust scalar; wasm-bindgen converts the JS number at the boundary:

```ts
interface Response {
/** @rust u16 */
readonly status: number;
/**
* @rust status: u16
* @rust u32
*/
withStatus(status: number, reason?: string): number;
/** @rust u32 */
count(): Promise<number>;
}
```

```rust
pub fn status(this: &Response) -> u16;
pub fn with_status(this: &Response, status: u16) -> u32;
pub async fn count(this: &Response) -> Result<u32, JsValue>;
```

* `@rust <type>` applies to the member's own type: a property (getter,
setter and builder method alike) or a callable's return.
* `@rust <param>: <type>` applies to the named parameter of a method,
constructor or function; several tags may be stacked.
* The scalar replaces the leaf through `| null` / `| undefined` and an
optional declaration (read as `Nullable<u16>` / `Option<u16>`), `T[]`
(`Vec<u8>` / `&[u8]`) and an async return's `Promise<…>`
(`Result<u32, _>`).
* A generic handle's argument must be a JS type, so inside one the
tagged leaf keeps the JS number it narrows: `Number`, or `BigInt` for
`u64`/`i64`. A `Promise<number>` getter or argument is
`Promise<Number>`, `number[][]` is `Vec<Array<Number>>` and
`(number | null)[]` is `Vec<JsNullable<Number>>`; the tag still
applies where the leaf reaches a Rust position.
* Accepted scalars: `u8 u16 u32 u64 i8 i16 i32 i64 f32 f64 usize`.
wasm-bindgen crosses `u64`/`i64` as a JS `BigInt`, so they apply only
to a `bigint` leaf; every other scalar applies only to a `number`
leaf. A `@rust u64` on a `number` would make the JS glue reject the
value at runtime, so it is refused: sizes beyond `u32` stay `f64`
(exact to 2^53). An unknown scalar, an incompatible leaf, or a
parameter name that does not exist is an error diagnostic and the
tag is ignored.

Why a JSDoc tag rather than a branded alias (`type u16 = number`): the
alias form needs the upstream declaration to *use* the alias, which an
upstream file never does, so annotations would still have to be
layered on — and a tag reads as what it is, a Rust-side refinement,
while a `u16` alias in `.d.ts` suggests a TS-level distinction that
does not exist.

## `--annotate` side files

Upstream declaration files (`@cloudflare/workers-types`,
`lib.dom.d.ts`) carry no `@rust` or `@throws` tags, and editing a
vendored copy does not survive an upgrade. `--annotate` takes side
`.d.ts` files that re-declare **only the members they tag**; the tags
are applied to the input's declarations by declaration name and member
name, and the side file is otherwise not emitted:

```ts
// annotations.d.ts
interface Response {
/** @rust u16 */
readonly status: number;
/** @throws {TypeError} if the body has already been used */
clone(): Response;
}
interface Console { /** @throws {never} */ log(...data: any[]): void; }
declare abstract class Cache { /** @rust ttl: u32 */ put(url: string, ttl?: number): Promise<void>; }
```

```sh
ts-gen --input node_modules/@cloudflare/workers-types/index.d.ts \
--annotate annotations.d.ts …
```

* `@rust` applies as if written on the input member; a property's tag
reaches its setter too. Parameters match by name.
* `@throws` replaces the input member's failure mode. Getters are
infallible, so on a property it contributes only its doc.
* A side member applies to every overload of the input method or
function, and the `## Errors` section its `@throws` renders is
appended to the input's doc once. The rest of the side doc is not
carried over, so a member copied from upstream with its description
does not repeat it. Overloads may differ in return
type (`delete(key): Promise<boolean>`, `delete(keys: string[]):
Promise<number>`), so a return `@rust` applies to the overloads that
can carry it.
* A declaration, member or parameter named in the side file that does
not exist in the input fails the run, so a typo cannot silently miss
a binding; so does an invalid `@rust` tag, or one whose scalar the
input's type cannot carry. Side members without tags are ignored,
whatever their description.
* Only classes, interfaces and functions can be annotated: a
`declare namespace`, `declare module`, variable, or alias that sets no
type-parameter default (see below) in the side file fails the run
rather than being skipped, as does a bare name
that the input declares more than once (globally and inside a
module), since names are matched without scope.
* A side declaration's type-parameter defaults replace the input's, by
parameter name; a default for a parameter the input lacks fails the
run. The output changes wherever a default is read: a class or
interface default becomes the emitted Rust default, and an alias's
expansion takes it. The `annotate` fixture, modelled on
workers-types, declares `Request<Meta = unknown, Cf = CfProperties>`
and `RequestInfo<Meta = unknown, Cf = CfProperties> = Request<Meta,
Cf> | string`, where `Cf` defaults to a union of the incoming and
outgoing shapes (`CfProperties = Incoming | Outgoing`), which erases.
The side file's `interface Request<Meta = unknown, Cf = Incoming>`
makes the emitted type `Request<Meta: JsGeneric = JsValue, Cf:
JsGeneric = Incoming>`, so a bare `Request` reads `cf()` as
`Incoming`. Its `type RequestInfo<Meta = unknown, Cf = Incoming> = …`
(whose target is ignored) makes the expansion `Request<JsValue,
Incoming>`, which is now `Request`'s defaults and so collapses to the
bare name: `fetch(input: &Request)`.
* The side file may use `interface` for a class and need not repeat
type parameters or parameter types other than tagged ones; only names
matter.

## Subtyping LUB across unions

`TypeRef::Union` resolution applies a Least Upper Bound across its members
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ ts-gen --input node_modules/@cloudflare/workers-types/index.d.ts --output src/bi
--export Request --export Response --export KVNamespace --export cloudflare:workers
```

Layer `@rust` / `@throws` tags onto a large declaration file from a side
file (see `CONVENTIONS.md`, "`--annotate` side files"):

```sh
ts-gen --input node_modules/@cloudflare/workers-types/index.d.ts \
--output src/bindings.rs \
--annotate types/annotations.d.ts
```

Opt generic imports into wasm-bindgen's experimental per-monomorphization
codegen:

Expand Down
36 changes: 36 additions & 0 deletions integration-tests/tests/rust_tags.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#![cfg(target_arch = "wasm32")]

use js_sys::{Array, BigInt, Function, JsNullable, Nullable, Number, Promise, Undefined};

use ts_gen_integration_tests::rust_tags::*;

// This fixture has no backing JavaScript module, so it provides compile-only
// coverage that every `@rust` scalar has a wasm-bindgen ABI, including a
// tagged leaf inside a generic handle, which stays a JS `Number`.
#[allow(dead_code)]
async fn rust_tag_signatures_compile(response: &Response, callback: &Function<fn() -> Undefined>) {
let _: u16 = response.status();
let _: Nullable<u32> = response.content_length();
let _: u32 = response.with_status(200);
let _: u32 = response.with_status_with_reason(200, "OK");
let _: u32 = response.count().await.unwrap();
let _: Nullable<u32> = response.maybe_count().await.unwrap();
response.upload_part(1);
let _: Vec<u8> = response.bytes();
response.listen(&[80, 443]);
let _: u64 = response.offset();

let closed: Promise<Number> = response.closed();
let _: Number = response.closed().await.unwrap();
let _: BigInt = response.total().await.unwrap();
response.when_ready(&closed);
let matrix: Vec<Array<Number>> = response.matrix();
response.set_matrix(&matrix);
let codes: Vec<JsNullable<Number>> = response.codes();
response.set_codes(&codes);

let init = ResponseInit::builder().status(204).build();
let _: Option<u16> = init.status();
let _: i32 = set_timeout(callback);
let _: i32 = set_timeout_with_ms(callback, 10u32);
}
41 changes: 41 additions & 0 deletions integration-tests/tests/rust_tags_mono.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! @ts-gen --experimental-generic-mono

#![cfg(target_arch = "wasm32")]

use js_sys::{Array, BigInt, Function, JsNullable, Nullable, Number, Promise, Undefined};

use ts_gen_integration_tests::rust_tags_mono::*;

// This fixture has no backing JavaScript module, so it provides compile-only
// coverage that every `@rust` scalar has a wasm-bindgen ABI, including a
// tagged leaf inside a generic handle, which stays a JS `Number`.
#[allow(dead_code)]
async fn rust_tag_mono_signatures_compile(
response: &Response,
callback: &Function<fn() -> Undefined>,
) {
let _: u16 = response.status();
let _: Nullable<u32> = response.content_length();
let _: u32 = response.with_status(200);
let _: u32 = response.with_status_with_reason(200, "OK");
let _: u32 = response.count().await.unwrap();
let _: Nullable<u32> = response.maybe_count().await.unwrap();
response.upload_part(1);
let _: Vec<u8> = response.bytes();
response.listen(&[80, 443]);
let _: u64 = response.offset();

let closed: Promise<Number> = response.closed();
let _: Number = response.closed().await.unwrap();
let _: BigInt = response.total().await.unwrap();
response.when_ready(&closed);
let matrix: Vec<Array<Number>> = response.matrix();
response.set_matrix(&matrix);
let codes: Vec<JsNullable<Number>> = response.codes();
response.set_codes(&codes);

let init = ResponseInit::builder().status(204).build();
let _: Option<u16> = init.status();
let _: i32 = set_timeout(callback);
let _: i32 = set_timeout_with_ms(callback, 10u32);
}
1 change: 1 addition & 0 deletions src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ fn dynamic_union_variant_name(ty: &TypeRef) -> String {
match ty {
T::Boolean | T::BooleanLiteral(_) => "Bool".to_string(),
T::Number | T::NumberLiteral(_) => "F64".to_string(),
T::RustScalar(s) => s.as_str().to_uppercase(),
T::String | T::StringLiteral(_) => "String".to_string(),
T::BigInt => "BigInt".to_string(),
T::Void | T::Undefined => "Undefined".to_string(),
Expand Down
1 change: 1 addition & 0 deletions src/codegen/signatures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ pub fn collect_type_params(
| TypeRef::ArrayBufferView
| TypeRef::PropertyKey
| TypeRef::TypedSlice(..)
| TypeRef::RustScalar(_)
| TypeRef::StringLiteral(_)
| TypeRef::NumberLiteral(_)
| TypeRef::BooleanLiteral(_)
Expand Down
1 change: 1 addition & 0 deletions src/codegen/surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,7 @@ fn type_refs(ty: &TypeRef, scope: ScopeId, out: &mut Vec<(String, ScopeId)>) {
| TypeRef::ArrayBufferView
| TypeRef::PropertyKey
| TypeRef::TypedSlice(..)
| TypeRef::RustScalar(_)
| TypeRef::StringLiteral(_)
| TypeRef::NumberLiteral(_)
| TypeRef::BooleanLiteral(_)
Expand Down
23 changes: 19 additions & 4 deletions src/codegen/typemap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ fn write_type_ref_key(buf: &mut String, ty: &TypeRef) {
TypeRef::ArrayBufferView => buf.push_str("ArrayBufferView"),
TypeRef::PropertyKey => buf.push_str("PropertyKey"),
TypeRef::TypedSlice(e, _) => write!(buf, "slice:{}", e.typed_array()).unwrap(),
TypeRef::RustScalar(s) => buf.push_str(s.as_str()),
TypeRef::StringLiteral(s) => write!(buf, "lit:{s:?}").unwrap(),
TypeRef::NumberLiteral(n) => write!(buf, "lit:{n}").unwrap(),
TypeRef::BooleanLiteral(b) => write!(buf, "lit:{b}").unwrap(),
Expand Down Expand Up @@ -1371,6 +1372,7 @@ impl<'a> CodegenContext<'a> {
| TypeRef::ArrayBufferView
| TypeRef::PropertyKey
| TypeRef::TypedSlice(..)
| TypeRef::RustScalar(_)
| TypeRef::StringLiteral(_)
| TypeRef::NumberLiteral(_)
| TypeRef::BooleanLiteral(_)
Expand Down Expand Up @@ -2034,6 +2036,11 @@ pub fn to_syn_type(
match ty {
TypeRef::Boolean | TypeRef::BooleanLiteral(_) => return quote! { Boolean },
TypeRef::Number | TypeRef::NumberLiteral(_) => return quote! { Number },
// A generic argument must be a JS type, so a `@rust` scalar
// inside a handle (`Promise<…>` that is not an async return,
// `Array<…>`, `JsNullable<…>`) keeps the JS number it narrows.
TypeRef::RustScalar(s) if s.is_bigint() => return quote! { BigInt },
TypeRef::RustScalar(_) => return quote! { Number },
TypeRef::String | TypeRef::StringLiteral(_) => return quote! { JsString },
TypeRef::Void | TypeRef::Undefined => return quote! { Undefined },
_ => {}
Expand Down Expand Up @@ -2086,6 +2093,11 @@ pub fn to_syn_type(
TypeRef::Unknown => maybe_ref(quote! { JsValue }, borrow),
TypeRef::Object => maybe_ref(quote! { Object }, borrow),
TypeRef::Symbol => maybe_ref(quote! { Symbol }, borrow),
// Always by value; wasm-bindgen converts the JS number.
TypeRef::RustScalar(s) => {
Comment thread
wasm-bindgen-bot[bot] marked this conversation as resolved.
Resolved
let ident = syn::Ident::new(s.as_str(), proc_macro2::Span::call_site());
quote! { #ident }
}

// === TS-only synthetic ===
// `ArrayBufferView` is a TS-only union alias (typed-array
Expand Down Expand Up @@ -3233,6 +3245,7 @@ fn is_mono_native_leaf(ty: &TypeRef, ctx: &CodegenContext<'_>, scope: ScopeId) -
| TypeRef::BooleanLiteral(_)
| TypeRef::Number
| TypeRef::NumberLiteral(_)
| TypeRef::RustScalar(_)
| TypeRef::String
| TypeRef::StringLiteral(_)
) || ctx.enum_kind(ty, scope).is_some()
Expand Down Expand Up @@ -4036,8 +4049,8 @@ fn is_mono_type_param(ty: &TypeRef, ctx: Option<&CodegenContext<'_>>, scope: Sco
}

/// Whether a top-level `T[]` element keeps its Rust-native form
/// (`String` / `f64`) inside the slice / `Vec`, rather than its handle
/// form. Enums count as the primitive they carry.
/// (`String` / `f64` / a `@rust` scalar) inside the slice / `Vec`, rather
/// than its handle form. Enums count as the primitive they carry.
fn is_native_array_element(
elem: &TypeRef,
ctx: Option<&CodegenContext<'_>>,
Expand All @@ -4049,6 +4062,7 @@ fn is_native_array_element(
| TypeRef::StringLiteral(_)
| TypeRef::Number
| TypeRef::NumberLiteral(_)
| TypeRef::RustScalar(_)
| TypeRef::Boolean
| TypeRef::BooleanLiteral(_)
)
Expand Down Expand Up @@ -4160,15 +4174,16 @@ pub(crate) fn is_mono_string_list_return(
)
}

/// Whether a top-level `T[]` element lowers to `f64`.
/// Whether a top-level `T[]` element is a JS number: `f64`, or the scalar
/// a `@rust` tag forces.
fn is_numeric_array_element(
elem: &TypeRef,
ctx: Option<&CodegenContext<'_>>,
scope: ScopeId,
) -> bool {
matches!(
native_array_leaf(elem, ctx, scope).as_ref(),
TypeRef::Number | TypeRef::NumberLiteral(_)
TypeRef::Number | TypeRef::NumberLiteral(_) | TypeRef::RustScalar(_)
)
}

Expand Down
Loading
Loading