diff --git a/AGENTS.md b/AGENTS.md index dbe2e38741a..315bec51277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,7 +167,7 @@ Be aware that workerd uses tcmalloc for memory allocation in the typical case. W - **`src/cloudflare/`** - Cloudflare-specific APIs (TypeScript) - **`src/node/`** - Node.js compatibility layer (TypeScript) - **`src/pyodide/`** - Python runtime support via Pyodide -- **`src/rust/`** - Rust integration components +- **`src/rust/`** - Rust integration components; see `src/rust/AGENTS.md` for the full macro reference and GC tracing guide ### Configuration System diff --git a/src/rust/AGENTS.md b/src/rust/AGENTS.md index 952710b88d5..3f1338841e6 100644 --- a/src/rust/AGENTS.md +++ b/src/rust/AGENTS.md @@ -9,7 +9,7 @@ | Crate | Purpose | | -------------------- | ------------------------------------------------------------------------------------------------------ | | `jsg/` | Rust JSG bindings: `Lock`, `Rc`, `Resource`, `Struct`, `Type`, `Realm`, `FeatureFlags`, module registration; V8 handle types including typed arrays, `ArrayBuffer`, `ArrayBufferView`, `SharedArrayBuffer`, `BackingStore` | -| `jsg-macros/` | Proc macros: `#[jsg_struct]`, `#[jsg_method]`, `#[jsg_resource]`, `#[jsg_oneof]`, `#[jsg_static_constant]` | +| `jsg-macros/` | Proc macros: `#[jsg_struct]`, `#[jsg_method]`, `#[jsg_resource]`, `#[jsg_oneof]`, `#[jsg_static_constant]`, `#[jsg_constructor]` | | `jsg-test/` | Test harness (`Harness`) for JSG Rust bindings | | `api/` | Rust-implemented Node.js APIs; registers modules via `register_nodejs_modules()` | | `dns/` | DNS record parsing (CAA, NAPTR) via CXX bridge; legacy duplicate of `api/dns.rs`, pending removal | @@ -25,11 +25,13 @@ - **CXX bridge**: `#[cxx::bridge(namespace = "workerd::rust::")]` with companion `ffi.c++`/`ffi.h` files - **Namespace**: always `workerd::rust::*` except `python-parser` → `edgeworker::rust::python_parser` - **Errors**: `thiserror` for library crates; `jsg::Error` with `ExceptionType` for JSG-facing crates -- **JSG resources**: `#[jsg_resource]` on struct + impl block; `#[jsg_method]` auto-converts `snake_case` → `camelCase`; methods with `&self`/`&mut self` become instance methods, methods without a receiver become static methods; `#[jsg_static_constant]` on `const` items exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase); resources integrate with GC via the `GarbageCollected` trait (auto-derived for `Rc`, `WeakRc`, `Option>`, and `Nullable>` fields) +- **JSG resources**: `#[jsg_resource]` on struct + impl block; `#[jsg_method]` auto-converts `snake_case` → `camelCase`; methods with `&self`/`&mut self` become instance methods, methods without a receiver become static methods; `#[jsg_static_constant]` on `const` items exposes read-only numeric constants on both constructor and prototype (name kept as-is, no camelCase); resources integrate with GC via `Traced` + `GarbageCollected`: every named field is traced via `Traced::trace(&self.field, visitor)` and all non-traceable types use no-op `Traced` impls - **JSG properties**: two property macros on `#[jsg_resource]` impl blocks — `#[jsg_property(prototype|instance [, name = "..."] [, readonly])]` (registers an accessor; `prototype` maps to `JSG_PROTOTYPE_PROPERTY`, `instance` maps to `JSG_INSTANCE_PROPERTY`; `readonly` is a compile-time check preventing a paired setter; `name = "..."` overrides the JS name; prefer `prototype` in almost all cases), and `#[jsg_inspect_property]` (registered under a unique symbol, invisible to normal enumeration and string-key lookup, surfaced by `node:util` `inspect()`, equivalent to `JSG_INSPECT_PROPERTY`); setter auto-detected from `set_` prefix; read-only when no setter present; getter/setter `.length` and `.name` are set correctly when `spec_compliant_property_attributes` compat flag is enabled +- **`Traced`**: core tracing trait in `jsg::wrappable`; built-ins include no-op impls for primitives/value types and delegating impls for wrappers/collections (`Option`, `Nullable`, `Vec`, maps/sets, `Cell`, `jsg::Rc`, `jsg::Weak`, `jsg::v8::Global`) +- **`#[jsg_resource(custom_trace)]`**: suppresses the auto-generated `Traced` impl so the user can write their own; `GarbageCollected` (`memory_name`), `jsg::Type`, `jsg::ToJS`, and `jsg::FromJS` are still generated - **Formatting**: `rustfmt.toml` — `group_imports = "StdExternalCrate"`, `imports_granularity = "Item"` (one `use` per import) - **Linting**: `just clippy ` — pedantic+nursery; `allow-unwrap-in-tests` -- **Tests**: inline `#[cfg(test)]` modules; JSG tests use `jsg_test::Harness::run_in_context()` +- **Tests**: inline `#[cfg(test)]` modules; JSG tests use `jsg_test::Harness::run_in_context()`. Always run the full `src/rust/...` test suite (`bazel test //src/rust/...`) rather than targeting a single crate — changes in shared crates like `jsg` or `jsg-macros` can break downstream consumers - **FFI pointers**: functions receiving raw pointers must be `unsafe fn` (see `jsg/README.md`) - **Parameter ordering**: `&Lock` / `&mut Lock` must always be the first parameter in any function that takes a lock (matching the C++ convention where `jsg::Lock&` is always first). This applies to free functions, trait methods, and associated functions (excluding `&self`/`&mut self` receivers which come before `lock`). - **Method naming**: do not use `get_` prefixes on methods — e.g. `buf.backing_store()` not `buf.get_backing_store()`. Static constructors belong on the marker struct (`impl ArrayBuffer { fn new(...) }`) not on `impl Local<'_, ArrayBuffer>`. diff --git a/src/rust/jsg-macros/README.md b/src/rust/jsg-macros/README.md index e0e63ab8894..eb2bc6c9696 100644 --- a/src/rust/jsg-macros/README.md +++ b/src/rust/jsg-macros/README.md @@ -1,16 +1,30 @@ # JSG Macros -Procedural macros for JSG (JavaScript Glue) Rust bindings. These macros reduce boilerplate when implementing the JSG type system. +Procedural macros for the JSG (JavaScript Glue) Rust bindings. These macros eliminate +boilerplate when implementing the JSG type system for Rust-backed JavaScript APIs. + +## Crate layout + +| File | Contents | +|---------------|--------------------------------------------------------------------------------| +| `lib.rs` | Public macro entry points — thin dispatchers only | +| `resource.rs` | Code generation for `#[jsg_resource]` on structs and impl blocks | +| `trace.rs` | GC trace code generation — field classification and `trace()` body emission | +| `utils.rs` | Shared helpers: `extract_named_fields`, `snake_to_camel`, `is_lock_ref`, etc. | + +--- ## `#[jsg_struct]` -Generates the `jsg::Struct` and `jsg::Type` implementations for data structures. Only public fields are exposed to JavaScript. Automatically implements `class_name()` using the struct name, or a custom name if provided via the `name` parameter. +Generates `jsg::Struct`, `jsg::Type`, `jsg::ToJS`, and `jsg::FromJS` for a plain data +struct. Only `pub` fields are projected into the JavaScript object. Use +`#[jsg_struct(name = "MyName")]` to override the JavaScript class name. ```rust #[jsg_struct] pub struct CaaRecord { pub critical: f64, - pub field: String, + pub tag: String, pub value: String, } @@ -20,129 +34,108 @@ pub struct MyRecord { } ``` -## `#[jsg_method]` - -Generates FFI callback functions for JSG resource methods. The `name` parameter is optional and defaults to converting the method name from `snake_case` to `camelCase`. - -The macro automatically detects whether a method is an instance method or a static method based on the presence of a receiver (`&self` or `&mut self`): +--- -- **Instance methods** (with `&self`/`&mut self`) are placed on the prototype, called on instances (e.g., `obj.getName()`). -- **Static methods** (without a receiver) are placed on the constructor, called on the class itself (e.g., `MyClass.create()`). +## `#[jsg_method]` -Parameters and return values are handled via the `jsg::FromJS` and `jsg::ToJS` traits. Any type implementing these traits can be used as a parameter or return value: +Generates a V8 `FunctionCallback` for a method on a `#[jsg_resource]` type. -- `Option` - accepts `T` or `undefined`, rejects `null` -- `Nullable` - accepts `T`, `null`, or `undefined` -- `NonCoercible` - rejects values that would require JavaScript coercion +- **Instance methods** (`&self` / `&mut self`) are placed on the prototype. +- **Static methods** (no receiver) are placed on the constructor. +- Return types of `Result` automatically throw a JavaScript exception on `Err`. +- The Rust `snake_case` name is converted to `camelCase` for JavaScript; override with + `#[jsg_method(name = "jsName")]`. +- The first typed parameter may be `&mut Lock` / `&mut jsg::Lock` to receive the + isolate lock directly — it is not exposed as a JavaScript argument. ```rust +#[jsg_resource] impl DnsUtil { - // Instance method: called as obj.parseCaaRecord(...) - #[jsg_method(name = "parseCaaRecord")] - pub fn parse_caa_record(&self, record: String) -> Result { - // Errors are thrown as JavaScript exceptions - } - - // Instance method: called as obj.getName() + // Instance — obj.parseCaaRecord(…) #[jsg_method] - pub fn get_name(&self) -> String { - self.name.clone() - } + pub fn parse_caa_record(&self, record: String) -> Result { … } - // Instance method: void methods return undefined in JavaScript + // Instance — obj.getName() #[jsg_method] - pub fn reset(&self) { - } + pub fn get_name(&self) -> String { … } - // Static method: called as DnsUtil.create(...) + // Static — DnsUtil.create(…) #[jsg_method] - pub fn create(name: String) -> Result { - Ok(name) - } + pub fn create(name: String) -> Result, jsg::Error> { … } } ``` +--- + ## `#[jsg_resource]` -Generates boilerplate for JSG resources. Applied to both struct definitions and impl blocks. Automatically implements `jsg::Type::class_name()` using the struct name, or a custom name if provided via the `name` parameter. +Generates JSG boilerplate for a resource type and its impl block. + +**On a struct** — emits `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, and +`jsg::GarbageCollected`. The `trace()` body is synthesised automatically for every +field whose type is or contains a traceable JSG handle (see [Garbage Collection](#garbage-collection) below). +Use `#[jsg_resource(name = "JSName")]` to override the JavaScript class name. + +**On an impl block** — emits `jsg::Resource::members()`, registering every +`#[jsg_method]`, `#[jsg_constructor]`, and `#[jsg_static_constant]` item. ```rust #[jsg_resource] -pub struct DnsUtil {} - -#[jsg_resource(name = "CustomUtil")] -pub struct MyUtil { - pub value: u32, +pub struct DnsUtil { + cache: HashMap>, // traced automatically + name: String, // plain data, ignored by tracer } #[jsg_resource] impl DnsUtil { #[jsg_method] - pub fn parse_caa_record(&self, record: String) -> Result { - // Instance method on the prototype - } + pub fn lookup(&self, host: String) -> Result { … } #[jsg_method] - pub fn create(name: String) -> Result { - // Static method on the constructor (no &self) - } + pub fn create() -> Self { … } } ``` -On struct definitions, generates: -- `jsg::Type` implementation -- `jsg::GarbageCollected` implementation with automatic field tracing (see below) -- Wrapper struct and `ResourceTemplate` implementations - -On impl blocks, scans for `#[jsg_method]` and `#[jsg_static_constant]` attributes and generates the `Resource` trait implementation. Methods with a receiver (`&self`/`&mut self`) are registered as instance methods; methods without a receiver are registered as static methods. +--- ## `#[jsg_static_constant]` -Exposes a Rust `const` item as a read-only static constant on both the JavaScript constructor and prototype. This is the Rust equivalent of `JSG_STATIC_CONSTANT` in C++ JSG. - -The constant name is used as-is for the JavaScript property name (no camelCase conversion), matching the convention that constants are `UPPER_SNAKE_CASE` in both Rust and JavaScript. Only numeric types are supported (`i8`..`i64`, `u8`..`u64`, `f32`, `f64`). +Exposes a Rust `const` as a read-only JavaScript property on both the constructor +and its prototype (equivalent to `JSG_STATIC_CONSTANT` in C++ JSG). The name is +used as-is (no camelCase). Only numeric types are supported. ```rust #[jsg_resource] impl WebSocket { #[jsg_static_constant] pub const CONNECTING: i32 = 0; - #[jsg_static_constant] pub const OPEN: i32 = 1; - - #[jsg_static_constant] - pub const CLOSING: i32 = 2; - - #[jsg_static_constant] - pub const CLOSED: i32 = 3; } -// In JavaScript: -// WebSocket.CONNECTING === 0 -// WebSocket.OPEN === 1 -// new WebSocket(...).CLOSING === 2 (also on instances via prototype) +// JS: WebSocket.CONNECTING === 0 / instance.OPEN === 1 ``` -Per Web IDL, constants are `{writable: false, enumerable: true, configurable: false}`. +--- ## `#[jsg_constructor]` -Marks a static method as the JavaScript constructor for a `#[jsg_resource]`. When JavaScript calls `new MyClass(args)`, V8 invokes this method, creates a `jsg::Rc`, and attaches it to the `this` object. +Marks a **static** method (no `self` receiver, returns `Self`) as the JavaScript +constructor. Only one per impl block is allowed. Without it, `new MyClass()` throws +`Illegal constructor`. An optional first parameter of `&mut Lock` is passed the +isolate lock and is not counted as a JavaScript argument. ```rust #[jsg_resource] -impl MyResource { +impl Greeting { #[jsg_constructor] - fn constructor(name: String) -> Self { - Self { name } + fn constructor(message: String) -> Self { + Self { message } } } -// JS: let r = new MyResource("hello"); +// JS: let g = new Greeting("hello"); ``` -The method must be static (no `self` receiver) and must return `Self`. Only one `#[jsg_constructor]` is allowed per impl block. The first parameter may be `&mut Lock` if the constructor needs isolate access — it is not exposed as a JS argument. - -If no `#[jsg_constructor]` is present, `new MyClass()` throws an `Illegal constructor` error. +--- ## `#[jsg_property([placement,] [name = "..."] [, readonly])]` @@ -254,83 +247,157 @@ impl ReadableStream { ## `#[jsg_oneof]` -Generates `jsg::Type` and `jsg::FromJS` implementations for union types. Use this to accept parameters that can be one of several JavaScript types. - -Each enum variant should be a single-field tuple variant where the field type implements `jsg::Type` and `jsg::FromJS` (e.g., `String`, `f64`, `bool`). +Generates `jsg::Type` and `jsg::FromJS` for a union enum — the Rust equivalent of +`kj::OneOf<…>`. Each variant must be a single-field tuple whose inner type implements +`jsg::Type` + `jsg::FromJS`. Variants are tried in declaration order using +exact-type matching; if none matches, a `TypeError` is thrown listing all expected +types. ```rust -use jsg_macros::jsg_oneof; - #[jsg_oneof] #[derive(Debug, Clone)] enum StringOrNumber { String(String), - Number(f64), + Number(jsg::Number), } +#[jsg_resource] impl MyResource { #[jsg_method] - pub fn process(&self, value: StringOrNumber) -> Result { + pub fn process(&self, value: StringOrNumber) -> String { match value { - StringOrNumber::String(s) => Ok(format!("string: {}", s)), - StringOrNumber::Number(n) => Ok(format!("number: {}", n)), + StringOrNumber::String(s) => format!("string: {s}"), + StringOrNumber::Number(n) => format!("number: {}", n.value()), } } } ``` -The macro generates type-checking code that matches JavaScript values to enum variants without coercion. If no variant matches, a `TypeError` is thrown listing all expected types. +--- + +## Garbage Collection + +`#[jsg_resource]` on a struct synthesises: -### Garbage Collection +- `impl jsg::Traced for MyType { fn trace(&self, visitor) { ... } }` +- `impl jsg::GarbageCollected for MyType { fn memory_name(&self) -> ... }` + +The generated `Traced::trace` body simply calls `Traced::trace` on **every named +field**: + +```rust +jsg::Traced::trace(&self.field_a, visitor); +jsg::Traced::trace(&self.field_b, visitor); +``` -Resources are automatically integrated with V8's garbage collector through the C++ `Wrappable` base class. The macro generates a `GarbageCollected` implementation that traces fields based on their type: +This means tracing behavior is now fully trait-driven. Types with no GC edges +use no-op `Traced` impls; containers/wrappers recurse to inner values. -| Field type | Behaviour | +### Supported field shapes + +| Field type | Trace behaviour | |---|---| -| `jsg::Rc` | Strong GC edge — keeps target alive | -| `jsg::Weak` | Not traced — does not keep target alive | -| `jsg::v8::Global` | Dual strong/traced — enables back-reference cycle collection | -| Anything else | Not traced — plain data, ignored by tracer | +| `jsg::Rc` | Strong GC edge — `visitor.visit_rc` | +| `jsg::v8::Global` | Dual strong/traced — `visitor.visit_global` (enables cycle collection) | +| `jsg::Weak` | **Not traced** — does not keep the target alive | +| `Option` / `jsg::Nullable` | Delegates to `T` when present | +| `Vec`, `HashMap`, `BTreeMap`, `HashSet`, `BTreeSet` | Delegates recursively to contained values | +| `Cell` / `std::cell::Cell` | Delegates via `as_ptr()` read (safe under single-threaded, non-reentrant GC tracing) | +| Plain data / primitives / `#[jsg_struct]` types | No-op `Traced` | +| Any other `T: Traced` | Uses `T`'s implementation | + +The `Cell<…>` variants are required whenever a traced field needs to be mutated +after construction, because `Traced::trace` receives `&self`. + +### Manual tracing with `custom_trace` + +For cases where default field-by-field `Traced` behavior is not enough, use +`#[jsg_resource(custom_trace)]` to suppress the generated `Traced` impl and +write your own. -`Option` and `jsg::Nullable` wrappers are supported for all traced field types and are traced only when `Some`. Any traced field type may also be wrapped in `Cell` (or `std::cell::Cell`) for interior mutability — required when the field is set after construction, since `GarbageCollected::trace` receives `&self`. +The macro still generates: -#### `jsg::v8::Global` and cycle collection +- `jsg::GarbageCollected` (with `memory_name`) +- `jsg::Type` +- `jsg::ToJS` +- `jsg::FromJS` -`jsg::v8::Global` fields use the same strong↔traced dual-mode as C++ `jsg::V8Ref`. While the parent resource has strong Rust `Rc` refs the handle stays strong; once all `Rc`s are dropped, `visit_global` downgrades the handle to a `v8::TracedReference` that cppgc can follow — allowing back-reference cycles (e.g. a resource holding a callback that captures its own JS wrapper) to be collected by the next full GC. +Example: + +```rust +struct EventHandlers { + on_message: Option>, + on_error: Option>, +} + +impl jsg::Traced for EventHandlers { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + self.on_message.trace(visitor); + self.on_error.trace(visitor); + } +} + +#[jsg_resource] +pub struct MySocket { + handlers: EventHandlers, + name: String, +} +``` ```rust use std::cell::Cell; +use std::collections::HashMap; #[jsg_resource] -pub struct MyResource { - // Strong GC edge — keeps child alive - child: jsg::Rc, +pub struct EventRouter { + // Strong edges — all children kept alive through GC. + handlers: HashMap>, - // Conditionally traced - maybe_child: Option>, + // Conditionally traced. + fallback: Option>, - // Weak — does not keep target alive - observer: jsg::Weak, + // Interior-mutable callback set after construction; dual-mode Global enables + // cycle collection if the callback closes over this resource's own JS wrapper. + on_error: Cell>>, - // JS value — traced with dual-mode switching; Cell needed because - // the callback is set after construction (trace takes &self) - callback: Cell>>, + // Weak — does not keep target alive. + parent: jsg::Weak, - // Plain data — not traced + // Plain data — no-op Traced. name: String, } ``` -For complex cases or custom tracing logic, you can manually implement `GarbageCollected` without using the `jsg_resource` macro: +### `jsg::v8::Global` and cycle collection + +`jsg::v8::Global` uses the same strong↔traced dual-mode as C++ `jsg::V8Ref`. +While the parent resource holds at least one strong Rust `Rc`, the V8 handle stays +strong. Once all `Rc`s are dropped and only the JS wrapper keeps the resource alive, +`visit_global` downgrades the handle to a `v8::TracedReference` that cppgc can +follow — allowing back-reference cycles (e.g. a resource that stores a callback +which closes over its own JS wrapper) to be detected and collected on the next full GC. + +### Custom tracing with `custom_trace` + +Use `#[jsg_resource(custom_trace)]` to suppress the generated `Traced` impl and +write your own. The macro still generates `jsg::GarbageCollected` (`memory_name`), +`jsg::Type`, `jsg::ToJS`, and `jsg::FromJS`. ```rust -pub struct CustomResource { - data: String, +#[jsg_resource(custom_trace)] +pub struct DynamicResource { + slots: Vec>>, } -impl jsg::GarbageCollected for CustomResource { +impl jsg::Traced for DynamicResource { fn trace(&self, visitor: &mut jsg::GcVisitor) { - // Custom tracing logic + for slot in &self.slots { + if let Some(ref h) = slot { + visitor.visit_rc(h); + } + } } } ``` + +`custom_trace` can be combined with `name`: `#[jsg_resource(name = "MyName", custom_trace)]`. diff --git a/src/rust/jsg-macros/lib.rs b/src/rust/jsg-macros/lib.rs index dd27676b154..b558236294f 100644 --- a/src/rust/jsg-macros/lib.rs +++ b/src/rust/jsg-macros/lib.rs @@ -2,32 +2,54 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 +//! Procedural macros for the JSG Rust bindings. +//! +//! # Macros +//! +//! | Macro | Apply to | Purpose | +//! |--------------------------|------------------|----------------------------------------------------------------| +//! | `#[jsg_resource]` | struct / impl | Expose a Rust type to JavaScript as a GC resource | +//! | `#[jsg_method]` | fn inside impl | Register a method (instance or static) on a resource | +//! | `#[jsg_constructor]` | fn inside impl | Register `new MyResource(…)` JavaScript constructor | +//! | `#[jsg_static_constant]` | const inside impl | Expose a numeric constant on both constructor and prototype | +//! | `#[jsg_property]` | fn inside impl | Register a resource accessor property (getter/setter) | +//! | `#[jsg_inspect_property]` | fn inside impl | Register a debug-inspect-only symbol property | +//! | `#[jsg_struct]` | struct | Expose a Rust struct as a plain JavaScript object | +//! | `#[jsg_oneof]` | enum | Accept one of several JavaScript types (`kj::OneOf`) | +//! +//! See [`jsg/README.md`](../jsg/README.md) for full usage documentation. + +mod resource; +mod trace; +mod utils; + use proc_macro::TokenStream; -use quote::ToTokens; use quote::quote; +use resource::generate_resource_impl; +use resource::generate_resource_struct; use syn::Data; use syn::DeriveInput; use syn::Fields; use syn::FnArg; use syn::ItemFn; use syn::ItemImpl; -use syn::Type; use syn::parse_macro_input; - -// Compile-time mirror of `jsg::PropertyKind` used to group annotated methods -// and emit the correct token streams. Cannot reuse the runtime type directly -// because proc-macro crates cannot link against CXX-bridge runtime crates. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum PropertyKind { - Prototype, - Instance, - Inspect, -} - -/// Generates `jsg::Struct` and `jsg::Type` implementations for data structures. -/// -/// Only public fields are included in the generated JavaScript object. -/// Use `name` parameter for custom JavaScript class name. +use utils::error; +use utils::extract_name_attribute; +use utils::extract_named_fields; +use utils::is_lock_ref; +use utils::is_result_type; + +// ============================================================================= +// #[jsg_struct] +// ============================================================================= + +/// Generates `jsg::Struct`, `jsg::Type`, `jsg::ToJS`, and `jsg::FromJS` +/// implementations for a plain data struct. +/// +/// Only `pub` fields are projected into the JavaScript object. +/// Use `#[jsg_struct(name = "MyName")]` to override `Type::class_name()` +/// metadata (used in diagnostics/type reporting), not to define a JS class. #[proc_macro_attribute] pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as DeriveInput); @@ -115,14 +137,28 @@ pub fn jsg_struct(attr: TokenStream, item: TokenStream) -> TokenStream { } impl jsg::Struct for #name {} + + #[automatically_derived] + impl jsg::Traced for #name {} } .into() } -/// Generates FFI callback for JSG methods. +// ============================================================================= +// #[jsg_method] +// ============================================================================= + +/// Generates a V8 `FunctionCallback` for a JSG resource method. +/// +/// Parameters are extracted from JavaScript arguments via `jsg::FromJS`. +/// Return values are converted via `jsg::ToJS`. +/// `Result` return types automatically throw exceptions on `Err`. /// -/// Parameters and return values are handled via `jsg::FromJS`. -/// See `jsg/wrappable.rs` for supported types. +/// The first typed parameter may be `&mut Lock` (or `&mut jsg::Lock`) to receive +/// the isolate lock directly; it is not counted as a JavaScript argument. +/// +/// Use `#[jsg_method(name = "jsName")]` to override the default `camelCase` +/// conversion of the Rust function name. #[proc_macro_attribute] pub fn jsg_method(_attr: TokenStream, item: TokenStream) -> TokenStream { let input_fn = parse_macro_input!(item as ItemFn); @@ -159,9 +195,7 @@ pub fn jsg_method(_attr: TokenStream, item: TokenStream) -> TokenStream { .map(|(i, ty)| { // First param is &mut Lock — pass the callback's lock directly. if i == 0 && has_lock_param { - let unwrap = quote! {}; - let arg_expr = quote! { &mut lock }; - return (unwrap, arg_expr); + return (quote! {}, quote! { &mut lock }); } let js_index = i - js_arg_offset; @@ -251,1168 +285,135 @@ pub fn jsg_method(_attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Generates boilerplate for JSG resources. +// ============================================================================= +// #[jsg_resource] +// ============================================================================= + +/// Generates JSG boilerplate for a resource type or its impl block. /// -/// On structs: generates `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, and `jsg::GarbageCollected`. -/// On impl blocks: generates `Resource` trait with method registrations. +/// **On a struct** — emits `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, +/// `jsg::Traced`, and `jsg::GarbageCollected`. /// -/// The generated `GarbageCollected` implementation automatically traces fields that -/// need GC integration: -/// - `Rc` fields - traced as a strong GC edge -/// - `Option>` / `Nullable>` - conditionally traced when `Some` -/// - `Weak` fields - not traced (weak references don't keep the target alive) +/// The generated `Traced::trace` body simply delegates to `Traced::trace()` on +/// every named field. +/// +/// **On an impl block** — emits `jsg::Resource::members()` registering every +/// `#[jsg_method]`, `#[jsg_property]`, `#[jsg_inspect_property]`, +/// `#[jsg_constructor]`, and `#[jsg_static_constant]` item. +/// +/// Use `#[jsg_resource(name = "JSName")]` on the struct to override the default +/// JavaScript class name. #[proc_macro_attribute] pub fn jsg_resource(attr: TokenStream, item: TokenStream) -> TokenStream { if let Ok(impl_block) = syn::parse::(item.clone()) { return generate_resource_impl(&impl_block); } - let input = parse_macro_input!(item as DeriveInput); - let name: &syn::Ident = &input.ident; - - let class_name = if attr.is_empty() { - name.to_string() - } else { - extract_name_attribute(attr).unwrap_or_else(|| name.to_string()) - }; - - let fields = match extract_named_fields(&input, "jsg_resource") { - Ok(fields) => fields, - Err(err) => return err, - }; - - // Generate trace statements for traceable fields - let trace_statements = generate_trace_statements(&fields); - let name_str = name.to_string(); - let gc_impl = quote! { - #[automatically_derived] - impl jsg::GarbageCollected for #name { - fn trace(&self, visitor: &mut jsg::GcVisitor) { - // Suppress unused warning when there are no traceable fields. - let _ = visitor; - #(#trace_statements)* - } - - fn memory_name(&self) -> &'static ::std::ffi::CStr { - // from_bytes_with_nul on a concat!(name, "\0") literal is a - // compile-time constant expression — the compiler folds the - // unwrap and emits a direct pointer into the read-only data - // segment. The C++ side constructs a kj::StringPtr directly - // from data()+size() with no allocation. - ::std::ffi::CStr::from_bytes_with_nul(concat!(#name_str, "\0").as_bytes()) - .unwrap() - } - } - }; - - quote! { - #input - - #[automatically_derived] - impl jsg::Type for #name { - fn class_name() -> &'static str { #class_name } - - fn is_exact(value: &jsg::v8::Local) -> bool { - value.is_object() - } - } - - #[automatically_derived] - impl jsg::ToJS for #name { - fn to_js<'a, 'b>(self, lock: &'a mut jsg::Lock) -> jsg::v8::Local<'b, jsg::v8::Value> - where - 'b: 'a, - { - let r = jsg::Rc::new(self); - r.to_js(lock) - } - } - - #[automatically_derived] - impl jsg::FromJS for #name { - type ResultType = jsg::Rc; - - fn from_js( - lock: &mut jsg::Lock, - value: jsg::v8::Local, - ) -> Result { - as jsg::FromJS>::from_js(lock, value) - } - } - - #gc_impl - } - .into() + generate_resource_struct(attr, &input) } -#[derive(Debug, Clone, Copy, PartialEq)] -enum TraceableType { - /// `jsg::Rc` — strong GC edge; visited via `GcVisitor::visit_rc`. - Ref, - /// `jsg::Weak` — weak reference, not traced (doesn't keep the target alive). - Weak, - /// `jsg::v8::Global` — JS value strong/traced dual-mode handle; - /// visited via `GcVisitor::visit_global`. - Global, - /// Not a traceable type. - None, -} - -enum OptionalKind { - Option, - Nullable, -} +// ============================================================================= +// #[jsg_static_constant] (marker only — processed by #[jsg_resource]) +// ============================================================================= -/// Checks if a type path matches a known JSG traceable type. +/// Marks a `const` item inside a `#[jsg_resource]` impl block as a static +/// constant exposed to JavaScript on both the constructor and its prototype. /// -/// Matches `jsg::Rc`, `jsg::Weak`, and `jsg::v8::Global`. -/// All must be fully qualified — this avoids confusion with same-named types -/// from other crates. -fn get_traceable_type(ty: &Type) -> TraceableType { - if let Type::Path(type_path) = ty { - let segments = &type_path.path.segments; - - // `jsg::Rc` or `jsg::Weak` — exactly 2 segments. - if segments.len() == 2 && segments[0].ident == "jsg" { - match segments[1].ident.to_string().as_str() { - "Rc" => return TraceableType::Ref, - "Weak" => return TraceableType::Weak, - _ => {} - } - } - - // `jsg::v8::Global` — exactly 3 segments. - if segments.len() == 3 - && segments[0].ident == "jsg" - && segments[1].ident == "v8" - && segments[2].ident == "Global" - { - return TraceableType::Global; - } - } - TraceableType::None -} - -/// Extracts the inner type from `Option` or `Nullable` if present. -fn extract_optional_inner(ty: &Type) -> Option<(OptionalKind, &Type)> { - if let Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.last() - && let syn::PathArguments::AngleBracketed(args) = &segment.arguments - && let Some(syn::GenericArgument::Type(inner)) = args.args.first() - { - let kind = match segment.ident.to_string().as_str() { - "Option" => OptionalKind::Option, - "Nullable" => OptionalKind::Nullable, - _ => return None, - }; - return Some((kind, inner)); - } - None -} - -/// Extracts the inner type `T` from `Cell` or `std::cell::Cell` if present. -/// -/// Accepts both unqualified `Cell` and fully-qualified `std::cell::Cell`. -fn extract_cell_inner(ty: &Type) -> Option<&Type> { - if let Type::Path(type_path) = ty { - let segments = &type_path.path.segments; - - // `Cell` — single segment. - let cell_seg = if segments.len() == 1 && segments[0].ident == "Cell" { - &segments[0] - // `std::cell::Cell` — three segments. - } else if segments.len() == 3 - && segments[0].ident == "std" - && segments[1].ident == "cell" - && segments[2].ident == "Cell" - { - &segments[2] - } else { - return None; - }; - - if let syn::PathArguments::AngleBracketed(args) = &cell_seg.arguments - && let Some(syn::GenericArgument::Type(inner)) = args.args.first() - { - return Some(inner); - } - } - None -} - -/// Generates a trace statement for a field whose type is known to be a `Cell`. -/// -/// Because `GarbageCollected::trace` receives `&self`, `Cell` fields cannot be -/// accessed through normal Rust references (they require `&mut self` or `T: Copy`). -/// We use `Cell::as_ptr` to obtain a raw pointer and dereference it for mutable -/// access. This is safe because: -/// -/// - V8 GC tracing is always single-threaded within an isolate. -/// - `trace` is never re-entrant on the same object during a single GC cycle. -fn generate_cell_trace_statement( - field_name: &syn::Ident, - cell_inner_ty: &Type, -) -> Option { - match get_traceable_type(cell_inner_ty) { - // Cell> — strong Rc reference inside a Cell, read-only visit. - TraceableType::Ref => { - return Some(quote! { - // SAFETY: trace() is single-threaded and never re-entrant. - // We only read through the pointer. - unsafe { visitor.visit_rc(&*self.#field_name.as_ptr()); } - }); - } - // Cell> — visit_global takes &Global (safe). - TraceableType::Global => { - return Some(quote! { - // SAFETY: Cell::as_ptr() dereference is sound because GC - // tracing is single-threaded and never re-entrant on the - // same object. visit_global itself is safe. - unsafe { visitor.visit_global(&*self.#field_name.as_ptr()); } - }); - } - TraceableType::Weak | TraceableType::None => {} - } - - // Cell>> or Cell>>. - if let Some((kind, inner_ty)) = extract_optional_inner(cell_inner_ty) { - let pattern = match kind { - OptionalKind::Option => quote! { Some(inner) }, - OptionalKind::Nullable => quote! { jsg::Nullable::Some(inner) }, - }; - match get_traceable_type(inner_ty) { - TraceableType::Ref => { - return Some(quote! { - // SAFETY: trace() is single-threaded and never re-entrant. - if let #pattern = unsafe { &*self.#field_name.as_ptr() } { - visitor.visit_rc(inner); - } - }); - } - TraceableType::Global => { - return Some(quote! { - // SAFETY: Cell::as_ptr() dereference is sound because GC - // tracing is single-threaded and never re-entrant on the - // same object. visit_global itself is safe. - if let #pattern = unsafe { &*self.#field_name.as_ptr() } { - visitor.visit_global(inner); - } - }); - } - TraceableType::Weak | TraceableType::None => {} - } - } - - None -} - -/// Generates trace statements for all traceable fields in a struct. -fn generate_trace_statements( - fields: &syn::punctuated::Punctuated, -) -> Vec { - fields - .iter() - .filter_map(|field| { - let field_name = field.ident.as_ref()?; - let ty = &field.ty; - - // Check if this field is wrapped in a `Cell`. - if let Some(cell_inner_ty) = extract_cell_inner(ty) { - return generate_cell_trace_statement(field_name, cell_inner_ty); - } - - // Check if it's Option or Nullable - if let Some((kind, inner_ty)) = extract_optional_inner(ty) { - let pattern = match kind { - OptionalKind::Option => quote! { Some(ref inner) }, - OptionalKind::Nullable => quote! { jsg::Nullable::Some(ref inner) }, - }; - match get_traceable_type(inner_ty) { - // Rc is a strong reference — visit it so the GC knows the edge. - TraceableType::Ref => { - return Some(quote! { - if let #pattern = self.#field_name { - visitor.visit_rc(inner); - } - }); - } - // Global — visit_global takes &Global (safe). - TraceableType::Global => { - return Some(quote! { - if let #pattern = self.#field_name { - visitor.visit_global(inner); - } - }); - } - // Weak doesn't keep the target alive and has no GC edges to trace. - TraceableType::Weak | TraceableType::None => {} - } - } - - match get_traceable_type(ty) { - TraceableType::Ref => Some(quote! { - visitor.visit_rc(&self.#field_name); - }), - // visit_global takes &Global and handles interior mutation safely. - TraceableType::Global => Some(quote! { - visitor.visit_global(&self.#field_name); - }), - // Weak doesn't keep the target alive and has no GC edges to trace. - TraceableType::Weak | TraceableType::None => None, - } - }) - .collect() -} - -/// Scans `impl_block` for `#[jsg_method]`-annotated functions and returns a -/// `Member::Method` / `Member::StaticMethod` token-stream for each. -fn collect_method_registrations(impl_block: &ItemImpl) -> Vec { - impl_block - .items - .iter() - .filter_map(|item| { - let syn::ImplItem::Fn(method) = item else { - return None; - }; - let attr = method.attrs.iter().find(|a| is_attr(a, "jsg_method"))?; - - let rust_method_name = &method.sig.ident; - let js_name = attr - .meta - .require_list() - .ok() - .map(|list| list.tokens.clone().into()) - .and_then(extract_name_attribute) - .unwrap_or_else(|| snake_to_camel(&rust_method_name.to_string())); - let callback_name = - syn::Ident::new(&format!("{rust_method_name}_callback"), rust_method_name.span()); - - let has_self = method - .sig - .inputs - .iter() - .any(|arg| matches!(arg, FnArg::Receiver(_))); - - Some(if has_self { - quote! { jsg::Member::Method { name: #js_name.to_owned(), callback: Self::#callback_name } } - } else { - quote! { jsg::Member::StaticMethod { name: #js_name.to_owned(), callback: Self::#callback_name } } - }) - }) - .collect() -} - -/// Scans `impl_block` for `#[jsg_static_constant]`-annotated consts and returns a -/// `Member::StaticConstant` token-stream for each. -fn collect_constant_registrations(impl_block: &ItemImpl) -> Vec { - impl_block - .items - .iter() - .filter_map(|item| { - let syn::ImplItem::Const(constant) = item else { - return None; - }; - let attr = constant - .attrs - .iter() - .find(|a| is_attr(a, "jsg_static_constant"))?; - - let rust_name = &constant.ident; - let js_name = attr - .meta - .require_list() - .ok() - .map(|list| list.tokens.clone().into()) - .and_then(extract_name_attribute) - .unwrap_or_else(|| rust_name.to_string()); - - Some(quote! { - jsg::Member::StaticConstant { - name: #js_name.to_owned(), - value: jsg::ConstantValue::from(Self::#rust_name), - } - }) - }) - .collect() -} - -fn generate_resource_impl(impl_block: &ItemImpl) -> TokenStream { - let self_ty = &impl_block.self_ty; - - if !matches!(&**self_ty, syn::Type::Path(_)) { - return error( - self_ty, - "#[jsg_resource] impl blocks must use a simple path type (e.g., `impl MyResource`)", - ); - } - - let method_registrations = collect_method_registrations(impl_block); - let property_registrations = collect_property_registrations(impl_block); - let constant_registrations = collect_constant_registrations(impl_block); - let constructor_vec: Vec<_> = generate_constructor_registration(impl_block, self_ty) - .into_iter() - .collect(); - - quote! { - #impl_block - - #[automatically_derived] - impl jsg::Resource for #self_ty { - fn members() -> Vec - where - Self: Sized, - { - vec![ - #(#constructor_vec,)* - #(#method_registrations,)* - #(#property_registrations,)* - #(#constant_registrations,)* - ] - } - } - } - .into() -} - -/// Scans an impl block for a `#[jsg_constructor]` attribute and generates the -/// constructor callback registration. Returns `None` if no constructor is defined. -/// Validates that a `#[jsg_constructor]` method has the right shape and returns -/// a compile-error token stream if it doesn't. -fn validate_constructor(method: &syn::ImplItemFn) -> Option { - let has_self = method - .sig - .inputs - .iter() - .any(|arg| matches!(arg, FnArg::Receiver(_))); - if has_self { - return Some(quote! { - compile_error!("#[jsg_constructor] must be a static method (no self receiver)"); - }); - } - - let returns_self = matches!(&method.sig.output, - syn::ReturnType::Type(_, ty) if matches!(&**ty, - syn::Type::Path(p) if p.path.is_ident("Self") - ) - ); - if !returns_self { - return Some(quote! { - compile_error!("#[jsg_constructor] must return Self"); - }); - } - - None -} - -/// Extracts constructor argument unwrap statements and argument expressions. -fn extract_constructor_params( - method: &syn::ImplItemFn, -) -> ( - bool, - Vec, - Vec, -) { - let params: Vec<_> = method - .sig - .inputs - .iter() - .filter_map(|arg| { - if let FnArg::Typed(pat_type) = arg { - Some((*pat_type.ty).clone()) - } else { - None - } - }) - .collect(); - - let has_lock_param = params.first().is_some_and(is_lock_ref); - let js_arg_offset = usize::from(has_lock_param); - - let (unwraps, arg_exprs) = params - .iter() - .enumerate() - .skip(js_arg_offset) - .map(|(i, ty)| { - let js_index = i - js_arg_offset; - let var = syn::Ident::new(&format!("arg{js_index}"), method.sig.ident.span()); - let unwrap = quote! { - let #var = match <#ty as jsg::FromJS>::from_js(&mut lock, args.get(#js_index)) { - Ok(v) => v, - Err(e) => { - lock.throw_exception(&e); - return; - } - }; - }; - (unwrap, quote! { #var }) - }) - .unzip(); - - (has_lock_param, unwraps, arg_exprs) -} - -fn generate_constructor_registration( - impl_block: &ItemImpl, - self_ty: &syn::Type, -) -> Option { - let constructors: Vec<_> = impl_block - .items - .iter() - .filter_map(|item| match item { - syn::ImplItem::Fn(m) if m.attrs.iter().any(|a| is_attr(a, "jsg_constructor")) => { - Some(m) - } - _ => None, - }) - .collect(); - - if constructors.len() > 1 { - return Some(quote! { - compile_error!("only one #[jsg_constructor] is allowed per impl block"); - }); - } - - constructors - .into_iter() - .map(|method| { - if let Some(err) = validate_constructor(method) { - return err; - } - - let rust_method_name = &method.sig.ident; - let callback_name = syn::Ident::new( - &format!("{rust_method_name}_constructor_callback"), - rust_method_name.span(), - ); - - let (has_lock_param, unwraps, arg_exprs) = extract_constructor_params(method); - let lock_arg = if has_lock_param { - quote! { &mut lock, } - } else { - quote! {} - }; - - quote! { - jsg::Member::Constructor { - callback: { - unsafe extern "C" fn #callback_name( - info: *mut jsg::v8::ffi::FunctionCallbackInfo, - ) { - let mut lock = unsafe { jsg::Lock::from_args(info) }; - jsg::catch_panic(&mut lock, || { - // SAFETY: info is a valid V8 FunctionCallbackInfo from the constructor call. - let mut args = unsafe { jsg::v8::FunctionCallbackInfo::from_ffi(info) }; - let mut lock = unsafe { jsg::Lock::from_args(info) }; - - #(#unwraps)* - - let resource = #self_ty::#rust_method_name(#lock_arg #(#arg_exprs),*); - let rc = jsg::Rc::new(resource); - rc.attach_to_this(&mut args); - }); - } - #callback_name - }, - } - } - }) - .next() -} - -// --------------------------------------------------------------------------- -// Property macro helpers -// --------------------------------------------------------------------------- - -/// Emits one `Member::Property { .. }` token stream for a single property group, -/// or an `Err` compile-error stream if the group has no getter. -fn emit_property_group( - js_name: &str, - kind: PropertyKind, - getter: Option, - setter: Option, -) -> Result { - let Some(getter_name) = getter else { - return Err(quote! { - compile_error!(concat!("no getter found for property \"", #js_name, "\"")) - }); - }; - - let getter_cb = syn::Ident::new(&format!("{getter_name}_callback"), getter_name.span()); - let kind_tokens = match kind { - PropertyKind::Prototype => quote! { jsg::PropertyKind::Prototype }, - PropertyKind::Instance => quote! { jsg::PropertyKind::Instance }, - PropertyKind::Inspect => quote! { jsg::PropertyKind::Inspect }, - }; - let setter_tokens = if let Some(setter_name) = setter { - let setter_cb = syn::Ident::new(&format!("{setter_name}_callback"), setter_name.span()); - quote! { Some(Self::#setter_cb) } - } else { - quote! { None } - }; - - Ok(quote! { - jsg::Member::Property { - name: #js_name.to_owned(), - kind: #kind_tokens, - getter_callback: Self::#getter_cb, - setter_callback: #setter_tokens, - } - }) -} - -/// Parses the argument list of `#[jsg_property([placement,] [name = "..."] [, readonly])]`. -/// -/// `placement` is optional; when omitted it defaults to `Prototype` (the recommended -/// placement in almost all cases). -/// Returns `(PropertyKind, Option, is_readonly)`. -fn parse_jsg_property_args( - tokens: TokenStream, -) -> Result<(PropertyKind, Option, bool), quote::__private::TokenStream> { - use syn::parse::Parser as _; - let metas = syn::punctuated::Punctuated::::parse_terminated - .parse(tokens) - .map_err(|e| e.to_compile_error())?; - - let mut kind: Option = None; - let mut name: Option = None; - let mut readonly = false; - - for meta in &metas { - match meta { - syn::Meta::Path(p) if p.is_ident("instance") => { - if kind.is_some() { - return Err(syn::Error::new_spanned( - p, - "conflicting placement: specify either `instance` or `prototype`, not both", - ) - .to_compile_error()); - } - kind = Some(PropertyKind::Instance); - } - syn::Meta::Path(p) if p.is_ident("prototype") => { - if kind.is_some() { - return Err(syn::Error::new_spanned( - p, - "conflicting placement: specify either `instance` or `prototype`, not both", - ) - .to_compile_error()); - } - kind = Some(PropertyKind::Prototype); - } - syn::Meta::Path(p) if p.is_ident("readonly") => { - readonly = true; - } - syn::Meta::NameValue(nv) if nv.path.is_ident("name") => { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - }) = &nv.value - { - name = Some(s.value()); - } else { - return Err(syn::Error::new_spanned( - &nv.value, - "expected a string literal for `name`", - ) - .to_compile_error()); - } - } - _ => { - return Err(syn::Error::new_spanned( - meta, - "unknown argument; expected `instance`, `prototype`, `readonly`, or `name = \"...\"`", - ) - .to_compile_error()); - } - } - } - - // Default to `Prototype` when no explicit placement is given — prototype properties - // are preferred in almost all cases and don't inhibit minor-GC or V8 optimisations. - let kind = kind.unwrap_or(PropertyKind::Prototype); - - Ok((kind, name, readonly)) -} - -struct PropMethod { - rust_name: syn::Ident, - is_setter: bool, - is_readonly: bool, -} - -/// Ordered list of property groups, preserving source-code declaration order. -/// Each entry is `((js_name, kind), methods)`. -type PropGroups = Vec<((String, PropertyKind), Vec)>; - -/// Derive the JS property name from a Rust method name: strip `get_`/`set_` prefix then -/// convert `snake_case` → `camelCase`. -fn derive_js_name(rust_name: &str) -> String { - let stripped = rust_name - .strip_prefix("get_") - .or_else(|| rust_name.strip_prefix("set_")) - .unwrap_or(rust_name); - snake_to_camel(stripped) -} - -/// Find the group for `key` in `groups`, or append a new empty one and return it. -/// Preserves insertion order so that property registration matches source-code order. -fn prop_groups_find_or_insert( - groups: &mut PropGroups, - key: (String, PropertyKind), -) -> &mut Vec { - if let Some(pos) = groups.iter().position(|(k, _)| k == &key) { - return &mut groups[pos].1; - } - groups.push((key, Vec::new())); - &mut groups.last_mut().expect("just pushed").1 -} - -/// Phase 1: scan `impl_block` for `#[jsg_property]` / `#[jsg_inspect_property]` annotations -/// and group the annotated methods by `(js_name, PropertyKind)`. -/// Insertion order mirrors source-code declaration order. -fn scan_property_annotations( - impl_block: &ItemImpl, -) -> Result { - let mut groups: PropGroups = Vec::new(); - - for item in &impl_block.items { - let syn::ImplItem::Fn(method) = item else { - continue; - }; - let rust_method_name = method.sig.ident.clone(); - let rust_name_str = rust_method_name.to_string(); - let is_setter = rust_name_str.starts_with("set_"); - - if let Some(attr) = method.attrs.iter().find(|a| is_attr(a, "jsg_property")) { - // Validate that property methods use `get_` or `set_` prefix so that the - // getter/setter pairing is unambiguous. Methods without either prefix are - // rejected at compile time rather than being silently treated as getters. - if !rust_name_str.starts_with("get_") && !rust_name_str.starts_with("set_") { - return Err(syn::Error::new( - rust_method_name.span(), - "#[jsg_property] methods must be named with a `get_` prefix (getter) \ - or `set_` prefix (setter)", - ) - .to_compile_error()); - } - - // Accept both `#[jsg_property]` (no parens → defaults to prototype) and - // `#[jsg_property(...)]`. Attributes without a parenthesized list are - // represented as `Meta::Path`, for which `require_list()` returns an error. - let tokens: TokenStream = attr - .meta - .require_list() - .map(|list| list.tokens.clone().into()) - .unwrap_or_default(); - let (kind, js_name_opt, is_readonly) = parse_jsg_property_args(tokens)?; - let js_name = js_name_opt.unwrap_or_else(|| derive_js_name(&rust_name_str)); - prop_groups_find_or_insert(&mut groups, (js_name, kind)).push(PropMethod { - rust_name: rust_method_name, - is_setter, - is_readonly, - }); - continue; - } - - if let Some(attr) = method - .attrs - .iter() - .find(|a| is_attr(a, "jsg_inspect_property")) - { - let attr_tokens: Option = attr - .meta - .require_list() - .ok() - .map(|list| list.tokens.clone().into()); - let js_name = attr_tokens - .and_then(extract_name_attribute) - .unwrap_or_else(|| derive_js_name(&rust_name_str)); - prop_groups_find_or_insert(&mut groups, (js_name, PropertyKind::Inspect)).push( - PropMethod { - rust_name: rust_method_name, - is_setter, - is_readonly: false, - }, - ); - } - } - Ok(groups) -} - -/// Phase 2 (per group): validate constraints and emit a single `Member::Property` token stream. -fn validate_and_emit_property( - js_name: &str, - kind: PropertyKind, - methods: Vec, -) -> Result { - let has_readonly_getter = methods.iter().any(|m| m.is_readonly && !m.is_setter); - for m in &methods { - if m.is_readonly && m.is_setter { - return Err(syn::Error::new( - m.rust_name.span(), - "`readonly` attribute cannot be used on a setter method", - ) - .to_compile_error()); - } - } - let setters: Vec<_> = methods.iter().filter(|m| m.is_setter).collect(); - if has_readonly_getter && !setters.is_empty() { - return Err(syn::Error::new( - setters[0].rust_name.span(), - "read-only property cannot have a setter; \ - remove the setter or drop the `readonly` attribute", - ) - .to_compile_error()); - } - if kind == PropertyKind::Inspect { - for m in &methods { - if m.is_setter { - return Err(syn::Error::new( - m.rust_name.span(), - "#[jsg_inspect_property] methods must be getters; \ - inspect properties are always read-only", - ) - .to_compile_error()); - } - } - } - - let mut getter: Option = None; - let mut setter: Option = None; - for m in methods { - if m.is_setter { - if setter.replace(m.rust_name).is_some() { - return Err( - quote! { compile_error!(concat!("duplicate setter for property \"", #js_name, "\"")) }, - ); - } - } else if getter.replace(m.rust_name).is_some() { - return Err( - quote! { compile_error!(concat!("duplicate getter for property \"", #js_name, "\"")) }, - ); - } - } - emit_property_group(js_name, kind, getter, setter) -} - -/// Scans an impl block for `#[jsg_property]` and `#[jsg_inspect_property]` annotations -/// and returns a `Member::Property` token stream for each property group (getter + optional setter). -fn collect_property_registrations(impl_block: &ItemImpl) -> Vec { - let groups = match scan_property_annotations(impl_block) { - Ok(g) => g, - Err(e) => return vec![e], - }; - let mut registrations = Vec::new(); - for ((js_name, kind), methods) in groups { - match validate_and_emit_property(&js_name, kind, methods) { - Ok(ts) => registrations.push(ts), - Err(ts) => { - registrations.push(ts); - return registrations; - } - } - } - registrations -} - -/// Extracts named fields from a struct, returning an empty list for unit structs. -/// Returns `Err` with a compile error for tuple structs or non-struct data. -fn extract_named_fields( - input: &DeriveInput, - macro_name: &str, -) -> Result, TokenStream> { - match &input.data { - Data::Struct(data) => match &data.fields { - Fields::Named(fields) => Ok(fields.named.clone()), - Fields::Unit => Ok(syn::punctuated::Punctuated::new()), - Fields::Unnamed(_) => Err(error( - input, - &format!("#[{macro_name}] does not support tuple structs"), - )), - }, - _ => Err(error( - input, - &format!("#[{macro_name}] can only be applied to structs or impl blocks"), - )), - } -} - -/// Checks if an attribute matches a given name, handling both unqualified (`#[jsg_method]`) -/// and qualified (`#[jsg_macros::jsg_method]`) paths. -fn is_attr(attr: &syn::Attribute, name: &str) -> bool { - attr.path().is_ident(name) || attr.path().segments.last().is_some_and(|s| s.ident == name) -} - -fn error(tokens: &impl ToTokens, msg: &str) -> TokenStream { - syn::Error::new_spanned(tokens, msg) - .to_compile_error() - .into() -} - -fn extract_name_attribute(tokens: TokenStream) -> Option { - let nv: syn::MetaNameValue = syn::parse(tokens).ok()?; - if !nv.path.is_ident("name") { - return None; - } - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - }) = &nv.value - { - Some(s.value()) - } else { - None - } -} - -fn snake_to_camel(s: &str) -> String { - let mut result = String::new(); - let mut cap_next = false; - for (i, c) in s.chars().enumerate() { - match c { - '_' => cap_next = true, - _ if i == 0 => result.push(c), - _ if cap_next => { - result.push(c.to_ascii_uppercase()); - cap_next = false; - } - _ => result.push(c), - } - } - result -} - -/// Checks if a type is `Result`. -fn is_result_type(ty: &syn::Type) -> bool { - if let syn::Type::Path(type_path) = ty - && let Some(segment) = type_path.path.segments.last() - { - return segment.ident == "Result"; - } - false -} - -/// Marks a `const` item inside a `#[jsg_resource]` impl block as a static constant -/// exposed to JavaScript on both the constructor and prototype. -/// -/// The constant name is used as-is for the JavaScript property name (no camelCase -/// conversion), matching the convention that constants are `UPPER_SNAKE_CASE` in -/// both Rust and JavaScript. -/// -/// Only numeric types are supported (`i8`..`i64`, `u8`..`u64`, `f32`, `f64`). -/// -/// # Example +/// The constant name is used as-is (no camelCase conversion), matching the +/// convention that constants are `UPPER_SNAKE_CASE` in both Rust and JavaScript. +/// Only numeric types (`i8`..`i64`, `u8`..`u64`, `f32`, `f64`) are supported. /// /// ```ignore /// #[jsg_resource] -/// impl MyResource { +/// impl WebSocket { /// #[jsg_static_constant] -/// pub const MAX_SIZE: u32 = 1024; -/// -/// #[jsg_static_constant] -/// pub const STATUS_OK: i32 = 0; +/// pub const CONNECTING: i32 = 0; /// } -/// // In JavaScript: MyResource.MAX_SIZE === 1024 +/// // JS: WebSocket.CONNECTING === 0 / instance.CONNECTING === 0 /// ``` #[proc_macro_attribute] pub fn jsg_static_constant(_attr: TokenStream, item: TokenStream) -> TokenStream { - // Marker attribute — the actual registration is handled by #[jsg_resource] on the impl block. + // Marker only — registration is handled by #[jsg_resource] on the impl block. item } +// ============================================================================= +// #[jsg_constructor] (marker only — processed by #[jsg_resource]) +// ============================================================================= + /// Marks a static method as the JavaScript constructor for a `#[jsg_resource]`. /// -/// The method must be a static function (no `self` receiver) that returns `Self`. -/// When JavaScript calls `new MyResource(args)`, V8 invokes this method, -/// wraps the returned resource, and attaches it to the `this` object. +/// The method must have no `self` receiver and must return `Self`. +/// An optional first parameter of `&mut Lock` (or `&mut jsg::Lock`) receives +/// the isolate lock and is not exposed as a JavaScript argument. +/// +/// Only one `#[jsg_constructor]` is allowed per impl block. Without it, +/// `new MyResource()` throws `Illegal constructor`, matching C++ JSG behaviour. /// /// ```ignore /// #[jsg_resource] -/// impl MyResource { +/// impl Greeting { /// #[jsg_constructor] -/// fn constructor(name: String) -> Self { -/// Self { name } +/// fn constructor(message: String) -> Self { +/// Self { message } /// } /// } -/// // JS: let obj = new MyResource("hello"); +/// // JS: let g = new Greeting("hello"); /// ``` -/// -/// Only one `#[jsg_constructor]` is allowed per impl block. #[proc_macro_attribute] pub fn jsg_constructor(_attr: TokenStream, item: TokenStream) -> TokenStream { - // Marker attribute — the actual registration is handled by #[jsg_resource] on the impl block. + // Marker only — registration is handled by #[jsg_resource] on the impl block. item } -/// Registers a method as a JavaScript property getter or setter on a `#[jsg_resource]` type. -/// -/// # Arguments -/// -/// - **`prototype`** or **`instance`** (optional, defaults to `prototype`) — where the property lives: -/// - `prototype`: installed via `prototype->SetAccessorProperty`. Not directly -/// enumerable (`Object.keys()` is empty), but visible via the prototype chain -/// (`"prop" in obj` is `true`) and overridable by subclasses. -/// Equivalent to C++ `JSG_PROTOTYPE_PROPERTY` / `JSG_READONLY_PROTOTYPE_PROPERTY`. -/// - `instance`: installed via `instance->SetAccessorProperty`, making it an -/// **own property** of every object instance. `Object.keys()` includes it, -/// `hasOwnProperty()` returns `true`, and it cannot be overridden by subclasses. -/// Equivalent to C++ `JSG_INSTANCE_PROPERTY` / `JSG_READONLY_INSTANCE_PROPERTY`. -/// > **Prefer `prototype` in almost all cases.** Own-property accessors prevent -/// > minor-GC collection of the object and inhibit some V8 optimisations. -/// - `name = "..."` — overrides the JS property name (optional). -/// - `readonly` — marks the property as read-only. It is a compile error to pair -/// `readonly` with a `set_*` method of the same derived name. -/// -/// # Naming (when `name` is omitted) -/// -/// Methods **must** be named with a `get_` prefix (getter) or `set_` prefix (setter). -/// The prefix is stripped and the remainder is converted `snake_case` → `camelCase`, -/// so `get_foo_bar` / `set_foo_bar` both map to `"fooBar"`. -/// -/// # Read-only vs read-write -/// -/// - Methods whose Rust name starts with `set_` are registered as the **setter**. -/// - Methods whose Rust name starts with `get_` are registered as the **getter**. -/// - Omitting a setter (or using `readonly`) makes the property read-only. In strict -/// mode, an assignment to a read-only property throws a `TypeError`. -/// -/// # `spec_compliant_property_attributes` compat flag -/// -/// When enabled, getter `.length = 0` / setter `.length = 1`, and getter `.name = "get "` / -/// setter `.name = "set "` per Web IDL §3.7.6. -/// -/// # Example +/// Registers a method as a JavaScript property getter or setter on a +/// `#[jsg_resource]` type. /// -/// ```ignore -/// #[jsg_resource] -/// impl Counter { -/// // Prototype property — getter + setter (read/write). -/// #[jsg_property(prototype)] -/// pub fn get_value(&self) -> jsg::Number { ... } -/// -/// #[jsg_property(prototype)] -/// pub fn set_value(&self, v: jsg::Number) { ... } -/// -/// // Prototype property — read-only (no setter). -/// #[jsg_property(prototype, readonly)] -/// pub fn get_label(&self) -> String { ... } -/// -/// // Prototype property — explicit JS name override. -/// #[jsg_property(prototype, name = "max")] -/// pub fn get_maximum(&self) -> jsg::Number { ... } -/// -/// // Instance (own) property — read/write. -/// #[jsg_property(instance)] -/// pub fn get_id(&self) -> String { ... } +/// Supported arguments: +/// - `prototype` or `instance` (optional, defaults to `prototype`) +/// - `name = "..."` for an explicit JS property name +/// - `readonly` to require no matching setter /// -/// #[jsg_property(instance)] -/// pub fn set_id(&self, v: String) { ... } -/// -/// // Instance property — read-only, explicit name. -/// #[jsg_property(instance, name = "shortId", readonly)] -/// pub fn get_prefix(&self) -> String { ... } -/// } -/// ``` +/// Methods must start with `get_` (getter) or `set_` (setter). If `name` is +/// omitted, the prefix is stripped and the remainder is converted +/// `snake_case` -> `camelCase`. #[proc_macro_attribute] pub fn jsg_property(_attr: TokenStream, item: TokenStream) -> TokenStream { - // Reuse jsg_method's callback generation — the generated `{name}_callback` - // is what collect_property_registrations references. The method is NOT - // added to `Member::Method`; registration happens via Member::Property. - // - // The attr tokens (placement, name, readonly) are intentionally NOT - // forwarded to jsg_method — it only needs the raw function body. + // Reuse jsg_method callback generation; registration as Member::Property is + // handled by #[jsg_resource] on the enclosing impl block. jsg_method(TokenStream::new(), item) } -/// Registers a method as a **debug-inspect** property on a `#[jsg_resource]` type. -/// -/// Equivalent to C++ `JSG_INSPECT_PROPERTY`. The getter is registered under a -/// unique `v8::Symbol` on the prototype, making it **invisible** to all normal -/// property access (string key lookup, `Object.keys()`, `getOwnPropertyNames()`). -/// It is surfaced only by `node:util`'s `inspect()` and `console.log()`. -/// -/// Inspect properties are **always read-only**. Annotating a `set_*` method with -/// `#[jsg_inspect_property]` is a compile error. +/// Registers a method as a debug-inspect-only property on a `#[jsg_resource]` +/// type. This maps to `jsg::PropertyKind::Inspect`. /// -/// # Arguments +/// Optional argument: `name = "..."` to set the symbol description. /// -/// - `name = "..."` — sets the symbol **description** shown by `inspect()` (optional). -/// When omitted the Rust method name is converted: a leading `get_` prefix is -/// stripped (consistent with `#[jsg_property]`) and the remainder is -/// `snake_case` → `camelCase`-converted, so `get_debug_state` → `"debugState"`. -/// -/// # Example -/// -/// ```ignore -/// #[jsg_resource] -/// impl ReadableStream { -/// #[jsg_inspect_property] // symbol description: "state" -/// pub fn state(&self) -> String { self.state.to_string() } -/// -/// #[jsg_inspect_property(name = "streamState")] // explicit symbol description -/// pub fn get_debug_state(&self) -> String { ... } -/// } -/// // JS: typeof stream.state // "undefined" (hidden from string keys) -/// // Object.keys(stream) // [] -/// // // util.inspect shows it via its symbol -/// ``` +/// Inspect properties are always read-only; setters are rejected at compile time. #[proc_macro_attribute] -pub fn jsg_inspect_property(attr: TokenStream, item: TokenStream) -> TokenStream { - // Reuse jsg_method's callback generation — the generated `{name}_callback` - // is what collect_property_registrations references. The method is NOT - // added to `Member::Method`; registration happens via Member::Property. - jsg_method(attr, item) +pub fn jsg_inspect_property(_attr: TokenStream, item: TokenStream) -> TokenStream { + // Reuse jsg_method callback generation; registration as Member::Property is + // handled by #[jsg_resource] on the enclosing impl block. + jsg_method(TokenStream::new(), item) } -/// Returns true if the type is `&mut Lock` or `&mut jsg::Lock`. -/// -/// When a method's first typed parameter matches this pattern, the macro passes the -/// callback's `lock` directly instead of extracting it from JavaScript arguments. -fn is_lock_ref(ty: &syn::Type) -> bool { - let syn::Type::Reference(ref_type) = ty else { - return false; - }; - if ref_type.mutability.is_none() { - return false; - } - let syn::Type::Path(type_path) = ref_type.elem.as_ref() else { - return false; - }; - let segments: Vec<_> = type_path.path.segments.iter().collect(); - match segments.len() { - // `&mut Lock` — bare import (assumes `use jsg::Lock;`) - 1 => segments[0].ident == "Lock", - // `&mut jsg::Lock` — fully qualified path - 2 => segments[0].ident == "jsg" && segments[1].ident == "Lock", - _ => false, - } -} +// ============================================================================= +// #[jsg_oneof] +// ============================================================================= -/// Generates `jsg::Type` and `jsg::FromJS` implementations for union types. +/// Generates `jsg::Type` and `jsg::FromJS` for a union enum, equivalent to +/// `kj::OneOf<…>` in C++ JSG. /// -/// This macro automatically implements the traits needed for enums with -/// single-field tuple variants to be used directly as `jsg_method` parameters. -/// Each variant should contain a type that implements `jsg::Type` and `jsg::FromJS`. -/// -/// # Example +/// Each variant must be a single-field tuple variant whose inner type implements +/// `jsg::Type` and `jsg::FromJS`. The macro tries each variant in declaration +/// order using exact-type matching and returns the first that succeeds. /// /// ```ignore -/// use jsg_macros::jsg_oneof; -/// /// #[jsg_oneof] /// #[derive(Debug, Clone)] /// enum StringOrNumber { /// String(String), -/// Number(f64), -/// } -/// -/// // Use directly as a parameter type: -/// #[jsg_method] -/// fn process(&self, value: StringOrNumber) -> Result { -/// match value { -/// StringOrNumber::String(s) => Ok(format!("string: {}", s)), -/// StringOrNumber::Number(n) => Ok(format!("number: {}", n)), -/// } +/// Number(jsg::Number), /// } /// ``` #[proc_macro_attribute] @@ -1457,16 +458,12 @@ pub fn jsg_oneof(_attr: TokenStream, item: TokenStream) -> TokenStream { let type_names: Vec<_> = variants .iter() - .map(|(_, inner_type)| { - quote! { <#inner_type as jsg::Type>::class_name() } - }) + .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::class_name() }) .collect(); let is_exact_checks: Vec<_> = variants .iter() - .map(|(_, inner_type)| { - quote! { <#inner_type as jsg::Type>::is_exact(value) } - }) + .map(|(_, inner_type)| quote! { <#inner_type as jsg::Type>::is_exact(value) }) .collect(); let error_msg = quote! { diff --git a/src/rust/jsg-macros/resource.rs b/src/rust/jsg-macros/resource.rs new file mode 100644 index 00000000000..6ffe701501e --- /dev/null +++ b/src/rust/jsg-macros/resource.rs @@ -0,0 +1,747 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! Code generation for `#[jsg_resource]` on structs and impl blocks. +//! +//! - On a **struct**: emits `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, +//! `jsg::Traced`, and `jsg::GarbageCollected` implementations. +//! - On an **impl block**: emits the `jsg::Resource` trait with method, static +//! method, static constant, and constructor registrations. + +use proc_macro::TokenStream; +use quote::quote; +use syn::FnArg; +use syn::ItemImpl; + +use crate::trace::generate_trace_statements; +use crate::utils::error; +use crate::utils::extract_name_attribute; +use crate::utils::extract_named_fields; +use crate::utils::has_custom_trace_flag; +use crate::utils::is_attr; +use crate::utils::is_lock_ref; +use crate::utils::snake_to_camel; + +// Compile-time mirror of `jsg::PropertyKind` used to group annotated methods +// and emit the correct token streams. Cannot reuse the runtime type directly +// because proc-macro crates cannot link against CXX-bridge runtime crates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PropertyKind { + Prototype, + Instance, + Inspect, +} + +/// Entry point called from `lib.rs` for `#[jsg_resource]` on a struct. +pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> TokenStream { + // Check for `#[jsg_resource(custom_trace)]` before consuming `attr`. + let custom_trace = has_custom_trace_flag(&attr); + + // Clone the name before mutating `input` so we can borrow freely later. + let name = input.ident.clone(); + + let class_name = if attr.is_empty() { + name.to_string() + } else { + extract_name_attribute(attr).unwrap_or_else(|| name.to_string()) + }; + + let fields = match extract_named_fields(input, "jsg_resource") { + Ok(fields) => fields, + Err(err) => return err, + }; + + let trace_statements = generate_trace_statements(&fields); + let name_str = name.to_string(); + + let traced_impl = if custom_trace { + // `custom_trace` suppresses the generated `Traced` impl — the user will write their own. + quote! {} + } else { + quote! { + #[automatically_derived] + impl jsg::Traced for #name { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + // Suppress unused warning when there are no traceable fields. + let _ = visitor; + #(#trace_statements)* + } + } + } + }; + + let gc_impl = quote! { + #[automatically_derived] + impl jsg::GarbageCollected for #name { + fn memory_name(&self) -> &'static ::std::ffi::CStr { + // from_bytes_with_nul on a concat!(name, "\0") literal is a + // compile-time constant expression — the compiler folds the + // unwrap and emits a direct pointer into the read-only data + // segment. The C++ side constructs a kj::StringPtr directly + // from data()+size() with no allocation. + ::std::ffi::CStr::from_bytes_with_nul(concat!(#name_str, "\0").as_bytes()) + .unwrap() + } + } + }; + + quote! { + #input + + #[automatically_derived] + impl jsg::Type for #name { + fn class_name() -> &'static str { #class_name } + + fn is_exact(value: &jsg::v8::Local) -> bool { + value.is_object() + } + } + + #[automatically_derived] + impl jsg::ToJS for #name { + fn to_js<'a, 'b>(self, lock: &'a mut jsg::Lock) -> jsg::v8::Local<'b, jsg::v8::Value> + where + 'b: 'a, + { + let r = jsg::Rc::new(self); + r.to_js(lock) + } + } + + #[automatically_derived] + impl jsg::FromJS for #name { + type ResultType = jsg::Rc; + + fn from_js( + lock: &mut jsg::Lock, + value: jsg::v8::Local, + ) -> Result { + as jsg::FromJS>::from_js(lock, value) + } + } + + #traced_impl + #gc_impl + } + .into() +} + +/// Scans `impl_block` for `#[jsg_method]`-annotated functions and returns a +/// `Member::Method` / `Member::StaticMethod` token stream for each. +fn collect_method_registrations(impl_block: &ItemImpl) -> Vec { + impl_block + .items + .iter() + .filter_map(|item| { + let syn::ImplItem::Fn(method) = item else { + return None; + }; + let attr = method.attrs.iter().find(|a| is_attr(a, "jsg_method"))?; + + let rust_method_name = &method.sig.ident; + let js_name = attr + .meta + .require_list() + .ok() + .map(|list| list.tokens.clone().into()) + .and_then(extract_name_attribute) + .unwrap_or_else(|| snake_to_camel(&rust_method_name.to_string())); + let callback_name = + syn::Ident::new(&format!("{rust_method_name}_callback"), rust_method_name.span()); + + let has_self = method + .sig + .inputs + .iter() + .any(|arg| matches!(arg, FnArg::Receiver(_))); + + Some(if has_self { + quote! { jsg::Member::Method { name: #js_name.to_owned(), callback: Self::#callback_name } } + } else { + quote! { jsg::Member::StaticMethod { name: #js_name.to_owned(), callback: Self::#callback_name } } + }) + }) + .collect() +} + +/// Scans `impl_block` for `#[jsg_static_constant]`-annotated consts and returns +/// a `Member::StaticConstant` token stream for each. +fn collect_constant_registrations(impl_block: &ItemImpl) -> Vec { + impl_block + .items + .iter() + .filter_map(|item| { + let syn::ImplItem::Const(constant) = item else { + return None; + }; + let attr = constant + .attrs + .iter() + .find(|a| is_attr(a, "jsg_static_constant"))?; + + let rust_name = &constant.ident; + let js_name = attr + .meta + .require_list() + .ok() + .map(|list| list.tokens.clone().into()) + .and_then(extract_name_attribute) + .unwrap_or_else(|| rust_name.to_string()); + + Some(quote! { + jsg::Member::StaticConstant { + name: #js_name.to_owned(), + value: jsg::ConstantValue::from(Self::#rust_name), + } + }) + }) + .collect() +} + +/// Entry point called from `lib.rs` for `#[jsg_resource]` on an impl block. +pub fn generate_resource_impl(impl_block: &ItemImpl) -> TokenStream { + let self_ty = &impl_block.self_ty; + + if !matches!(&**self_ty, syn::Type::Path(_)) { + return error( + self_ty, + "#[jsg_resource] impl blocks must use a simple path type (e.g., `impl MyResource`)", + ); + } + + let method_registrations = collect_method_registrations(impl_block); + let property_registrations = collect_property_registrations(impl_block); + let constant_registrations = collect_constant_registrations(impl_block); + + let constructor_registration = generate_constructor_registration(impl_block, self_ty); + let constructor_vec: Vec<_> = constructor_registration.into_iter().collect(); + + quote! { + #impl_block + + #[automatically_derived] + impl jsg::Resource for #self_ty { + fn members() -> Vec + where + Self: Sized, + { + vec![ + #(#constructor_vec,)* + #(#method_registrations,)* + #(#property_registrations,)* + #(#constant_registrations,)* + ] + } + } + } + .into() +} + +// --------------------------------------------------------------------------- +// Constructor helpers +// --------------------------------------------------------------------------- + +/// Validates that a `#[jsg_constructor]` method has the right shape. +/// +/// Returns a `compile_error!` token stream if the method has a `self` receiver +/// or does not return `Self`; returns `None` if the method is valid. +fn validate_constructor(method: &syn::ImplItemFn) -> Option { + let has_self = method + .sig + .inputs + .iter() + .any(|arg| matches!(arg, FnArg::Receiver(_))); + if has_self { + return Some(quote! { + compile_error!("#[jsg_constructor] must be a static method (no self receiver)"); + }); + } + + let returns_self = matches!(&method.sig.output, + syn::ReturnType::Type(_, ty) if matches!(&**ty, + syn::Type::Path(p) if p.path.is_ident("Self") + ) + ); + if !returns_self { + return Some(quote! { + compile_error!("#[jsg_constructor] must return Self"); + }); + } + + None +} + +/// Extracts constructor argument unwrap statements and argument expressions. +fn extract_constructor_params( + method: &syn::ImplItemFn, +) -> ( + bool, + Vec, + Vec, +) { + let params: Vec<_> = method + .sig + .inputs + .iter() + .filter_map(|arg| { + if let FnArg::Typed(pat_type) = arg { + Some((*pat_type.ty).clone()) + } else { + None + } + }) + .collect(); + + let has_lock_param = params.first().is_some_and(is_lock_ref); + let js_arg_offset = usize::from(has_lock_param); + + let (unwraps, arg_exprs) = params + .iter() + .enumerate() + .skip(js_arg_offset) + .map(|(i, ty)| { + let js_index = i - js_arg_offset; + let var = syn::Ident::new(&format!("arg{js_index}"), method.sig.ident.span()); + let unwrap = quote! { + let #var = match <#ty as jsg::FromJS>::from_js(&mut lock, args.get(#js_index)) { + Ok(v) => v, + Err(e) => { + lock.throw_exception(&e); + return; + } + }; + }; + (unwrap, quote! { #var }) + }) + .unzip(); + + (has_lock_param, unwraps, arg_exprs) +} + +/// Scans an impl block for a `#[jsg_constructor]` attribute and generates the +/// constructor callback registration. Returns `None` if no constructor is defined. +/// Validates that a `#[jsg_constructor]` method has the right shape and returns +/// a compile-error token stream if it doesn't. +fn generate_constructor_registration( + impl_block: &ItemImpl, + self_ty: &syn::Type, +) -> Option { + let constructors: Vec<_> = impl_block + .items + .iter() + .filter_map(|item| match item { + syn::ImplItem::Fn(m) if m.attrs.iter().any(|a| is_attr(a, "jsg_constructor")) => { + Some(m) + } + _ => None, + }) + .collect(); + + if constructors.len() > 1 { + return Some(quote! { + compile_error!("only one #[jsg_constructor] is allowed per impl block"); + }); + } + + constructors + .into_iter() + .map(|method| { + if let Some(err) = validate_constructor(method) { + return err; + } + + let rust_method_name = &method.sig.ident; + let callback_name = syn::Ident::new( + &format!("{rust_method_name}_constructor_callback"), + rust_method_name.span(), + ); + + let (has_lock_param, unwraps, arg_exprs) = extract_constructor_params(method); + let lock_arg = if has_lock_param { + quote! { &mut lock, } + } else { + quote! {} + }; + + quote! { + jsg::Member::Constructor { + callback: { + unsafe extern "C" fn #callback_name( + info: *mut jsg::v8::ffi::FunctionCallbackInfo, + ) { + let mut lock = unsafe { jsg::Lock::from_args(info) }; + jsg::catch_panic(&mut lock, || { + // SAFETY: info is a valid V8 FunctionCallbackInfo from the constructor call. + let mut args = unsafe { jsg::v8::FunctionCallbackInfo::from_ffi(info) }; + let mut lock = unsafe { jsg::Lock::from_args(info) }; + + #(#unwraps)* + + let resource = #self_ty::#rust_method_name(#lock_arg #(#arg_exprs),*); + let rc = jsg::Rc::new(resource); + rc.attach_to_this(&mut args); + }); + } + #callback_name + }, + } + } + }) + .next() +} + +// --------------------------------------------------------------------------- +// Property helpers +// --------------------------------------------------------------------------- + +/// Emits one `Member::Property { .. }` token stream for a single property group, +/// or an `Err` compile-error stream if the group has no getter. +fn emit_property_group( + js_name: &str, + kind: PropertyKind, + getter: Option, + setter: Option, +) -> Result { + let Some(getter_name) = getter else { + return Err(quote! { + compile_error!(concat!("no getter found for property \"", #js_name, "\"")) + }); + }; + + let getter_cb = syn::Ident::new(&format!("{getter_name}_callback"), getter_name.span()); + let kind_tokens = match kind { + PropertyKind::Prototype => quote! { jsg::PropertyKind::Prototype }, + PropertyKind::Instance => quote! { jsg::PropertyKind::Instance }, + PropertyKind::Inspect => quote! { jsg::PropertyKind::Inspect }, + }; + let setter_tokens = if let Some(setter_name) = setter { + let setter_cb = syn::Ident::new(&format!("{setter_name}_callback"), setter_name.span()); + quote! { Some(Self::#setter_cb) } + } else { + quote! { None } + }; + + Ok(quote! { + jsg::Member::Property { + name: #js_name.to_owned(), + kind: #kind_tokens, + getter_callback: Self::#getter_cb, + setter_callback: #setter_tokens, + } + }) +} + +/// Parses the argument list of `#[jsg_property([placement,] [name = "..."] [, readonly])]`. +/// +/// `placement` is optional; when omitted it defaults to `Prototype`. +/// Returns `(PropertyKind, Option, is_readonly)`. +fn parse_jsg_property_args( + tokens: TokenStream, +) -> Result<(PropertyKind, Option, bool), quote::__private::TokenStream> { + use syn::parse::Parser as _; + let metas = syn::punctuated::Punctuated::::parse_terminated + .parse(tokens) + .map_err(|e| e.to_compile_error())?; + + let mut kind: Option = None; + let mut name: Option = None; + let mut readonly = false; + + for meta in &metas { + match meta { + syn::Meta::Path(p) if p.is_ident("instance") => { + if kind.is_some() { + return Err(syn::Error::new_spanned( + p, + "conflicting placement: specify either `instance` or `prototype`, not both", + ) + .to_compile_error()); + } + kind = Some(PropertyKind::Instance); + } + syn::Meta::Path(p) if p.is_ident("prototype") => { + if kind.is_some() { + return Err(syn::Error::new_spanned( + p, + "conflicting placement: specify either `instance` or `prototype`, not both", + ) + .to_compile_error()); + } + kind = Some(PropertyKind::Prototype); + } + syn::Meta::Path(p) if p.is_ident("readonly") => { + readonly = true; + } + syn::Meta::NameValue(nv) if nv.path.is_ident("name") => { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = &nv.value + { + name = Some(s.value()); + } else { + return Err(syn::Error::new_spanned( + &nv.value, + "expected a string literal for `name`", + ) + .to_compile_error()); + } + } + _ => { + return Err(syn::Error::new_spanned( + meta, + "unknown argument; expected `instance`, `prototype`, `readonly`, or `name = \"...\"`", + ) + .to_compile_error()); + } + } + } + + // Default to `Prototype` when no explicit placement is given. + let kind = kind.unwrap_or(PropertyKind::Prototype); + + Ok((kind, name, readonly)) +} + +struct PropMethod { + rust_name: syn::Ident, + is_setter: bool, + is_readonly: bool, +} + +/// Ordered list of property groups, preserving source-code declaration order. +/// Each entry is `((js_name, kind), methods)`. +type PropGroups = Vec<((String, PropertyKind), Vec)>; + +/// Derive the JS property name from a Rust method name: strip `get_`/`set_` prefix then +/// convert `snake_case` -> `camelCase`. +fn derive_js_name(rust_name: &str) -> String { + let stripped = rust_name + .strip_prefix("get_") + .or_else(|| rust_name.strip_prefix("set_")) + .unwrap_or(rust_name); + snake_to_camel(stripped) +} + +/// Find the group for `key` in `groups`, or append a new empty one and return it. +/// Preserves insertion order so that property registration matches source-code order. +fn prop_groups_find_or_insert( + groups: &mut PropGroups, + key: (String, PropertyKind), +) -> &mut Vec { + if let Some(pos) = groups.iter().position(|(k, _)| k == &key) { + return &mut groups[pos].1; + } + groups.push((key, Vec::new())); + &mut groups.last_mut().expect("just pushed").1 +} + +/// Phase 1: scan `impl_block` for `#[jsg_property]` / `#[jsg_inspect_property]` annotations +/// and group the annotated methods by `(js_name, PropertyKind)`. +fn scan_property_annotations( + impl_block: &ItemImpl, +) -> Result { + let mut groups: PropGroups = Vec::new(); + + for item in &impl_block.items { + let syn::ImplItem::Fn(method) = item else { + continue; + }; + let rust_method_name = method.sig.ident.clone(); + let rust_name_str = rust_method_name.to_string(); + let is_setter = rust_name_str.starts_with("set_"); + + if let Some(attr) = method.attrs.iter().find(|a| is_attr(a, "jsg_property")) { + if !rust_name_str.starts_with("get_") && !rust_name_str.starts_with("set_") { + return Err(syn::Error::new( + rust_method_name.span(), + "#[jsg_property] methods must be named with a `get_` prefix (getter) \ + or `set_` prefix (setter)", + ) + .to_compile_error()); + } + + let tokens: TokenStream = attr + .meta + .require_list() + .map(|list| list.tokens.clone().into()) + .unwrap_or_default(); + let (kind, js_name_opt, is_readonly) = parse_jsg_property_args(tokens)?; + let js_name = js_name_opt.unwrap_or_else(|| derive_js_name(&rust_name_str)); + prop_groups_find_or_insert(&mut groups, (js_name, kind)).push(PropMethod { + rust_name: rust_method_name, + is_setter, + is_readonly, + }); + continue; + } + + if let Some(attr) = method + .attrs + .iter() + .find(|a| is_attr(a, "jsg_inspect_property")) + { + let attr_tokens: Option = attr + .meta + .require_list() + .ok() + .map(|list| list.tokens.clone().into()); + let js_name = attr_tokens + .and_then(extract_name_attribute) + .unwrap_or_else(|| derive_js_name(&rust_name_str)); + prop_groups_find_or_insert(&mut groups, (js_name, PropertyKind::Inspect)).push( + PropMethod { + rust_name: rust_method_name, + is_setter, + is_readonly: false, + }, + ); + } + } + Ok(groups) +} + +/// Phase 2 (per group): validate constraints and emit one `Member::Property`. +fn validate_and_emit_property( + js_name: &str, + kind: PropertyKind, + methods: Vec, +) -> Result { + let has_readonly_getter = methods.iter().any(|m| m.is_readonly && !m.is_setter); + for m in &methods { + if m.is_readonly && m.is_setter { + return Err(syn::Error::new( + m.rust_name.span(), + "`readonly` attribute cannot be used on a setter method", + ) + .to_compile_error()); + } + } + let setters: Vec<_> = methods.iter().filter(|m| m.is_setter).collect(); + if has_readonly_getter && !setters.is_empty() { + return Err(syn::Error::new( + setters[0].rust_name.span(), + "read-only property cannot have a setter; remove the setter or drop the `readonly` attribute", + ) + .to_compile_error()); + } + if kind == PropertyKind::Inspect { + for m in &methods { + if m.is_setter { + return Err(syn::Error::new( + m.rust_name.span(), + "#[jsg_inspect_property] methods must be getters; inspect properties are always read-only", + ) + .to_compile_error()); + } + } + } + + let mut getter: Option = None; + let mut setter: Option = None; + for m in methods { + if m.is_setter { + if setter.replace(m.rust_name).is_some() { + return Err( + quote! { compile_error!(concat!("duplicate setter for property \"", #js_name, "\"")) }, + ); + } + } else if getter.replace(m.rust_name).is_some() { + return Err( + quote! { compile_error!(concat!("duplicate getter for property \"", #js_name, "\"")) }, + ); + } + } + emit_property_group(js_name, kind, getter, setter) +} + +/// Scans an impl block for `#[jsg_property]` and `#[jsg_inspect_property]` annotations +/// and returns a `Member::Property` token stream for each property group. +fn collect_property_registrations(impl_block: &ItemImpl) -> Vec { + let groups = match scan_property_annotations(impl_block) { + Ok(g) => g, + Err(e) => return vec![e], + }; + let mut registrations = Vec::new(); + for ((js_name, kind), methods) in groups { + match validate_and_emit_property(&js_name, kind, methods) { + Ok(ts) => registrations.push(ts), + Err(ts) => { + registrations.push(ts); + return registrations; + } + } + } + registrations +} + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::*; + + #[test] + fn validate_constructor_valid() { + // A valid constructor: static (no self), returns Self. + let method: syn::ImplItemFn = parse_quote! { + fn constructor(name: String) -> Self { todo!() } + }; + assert!(validate_constructor(&method).is_none()); + } + + #[test] + fn validate_constructor_rejects_self_receiver() { + // Instance method — must not have &self. + let method: syn::ImplItemFn = parse_quote! { + fn constructor(&self) -> Self { todo!() } + }; + assert!(validate_constructor(&method).is_some()); + } + + #[test] + fn validate_constructor_rejects_non_self_return() { + // Returns String, not Self. + let method: syn::ImplItemFn = parse_quote! { + fn constructor() -> String { todo!() } + }; + assert!(validate_constructor(&method).is_some()); + } + + #[test] + fn extract_constructor_params_no_lock() { + // Plain constructor — no Lock param, two JS args. + let method: syn::ImplItemFn = parse_quote! { + fn constructor(name: String, value: u32) -> Self { todo!() } + }; + let (has_lock, unwraps, arg_exprs) = extract_constructor_params(&method); + assert!(!has_lock); + assert_eq!(unwraps.len(), 2); + assert_eq!(arg_exprs.len(), 2); + } + + #[test] + fn extract_constructor_params_with_lock() { + // First param is `&mut jsg::Lock` — skipped from JS args. + let method: syn::ImplItemFn = parse_quote! { + fn constructor(lock: &mut jsg::Lock, name: String) -> Self { todo!() } + }; + let (has_lock, unwraps, arg_exprs) = extract_constructor_params(&method); + assert!(has_lock); + // Only one JS arg (name); lock is not counted. + assert_eq!(unwraps.len(), 1); + assert_eq!(arg_exprs.len(), 1); + } + + #[test] + fn extract_constructor_params_no_args() { + let method: syn::ImplItemFn = parse_quote! { + fn constructor() -> Self { todo!() } + }; + let (has_lock, unwraps, arg_exprs) = extract_constructor_params(&method); + assert!(!has_lock); + assert!(unwraps.is_empty()); + assert!(arg_exprs.is_empty()); + } +} diff --git a/src/rust/jsg-macros/trace.rs b/src/rust/jsg-macros/trace.rs new file mode 100644 index 00000000000..d619a772393 --- /dev/null +++ b/src/rust/jsg-macros/trace.rs @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! Trace code generation for `#[jsg_resource]` structs. +//! +//! We intentionally do not inspect field types here. Every field is assumed to +//! implement `jsg::Traced`, and the generated body simply delegates to +//! `jsg::Traced::trace(&self.field, visitor)` for each named field. + +/// Generates one trace delegation statement per named field. +/// All fields are traced for safety by default, based on enforcing they implement +/// the `Traced` trait, which all `GarbageCollected` types implement, and all non-GC +/// types implement as a no-op. +pub fn generate_trace_statements( + fields: &syn::punctuated::Punctuated, +) -> Vec { + use quote::quote; + + fields + .iter() + .filter_map(|field| { + let field_name = field.ident.as_ref()?; + Some(quote! { + jsg::Traced::trace(&self.#field_name, visitor); + }) + }) + .collect() +} diff --git a/src/rust/jsg-macros/utils.rs b/src/rust/jsg-macros/utils.rs new file mode 100644 index 00000000000..a1697a13d29 --- /dev/null +++ b/src/rust/jsg-macros/utils.rs @@ -0,0 +1,197 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! Shared utility helpers used across the jsg-macros crate. + +use proc_macro::TokenStream; +use quote::ToTokens; +use syn::Data; +use syn::DeriveInput; +use syn::Fields; + +/// Extracts named fields from a struct, returning an empty list for unit structs. +/// Returns `Err` with a compile error for tuple structs or non-struct data. +pub fn extract_named_fields( + input: &DeriveInput, + macro_name: &str, +) -> Result, TokenStream> { + match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => Ok(fields.named.clone()), + Fields::Unit => Ok(syn::punctuated::Punctuated::new()), + Fields::Unnamed(_) => Err(error( + input, + &format!("#[{macro_name}] does not support tuple structs"), + )), + }, + _ => Err(error( + input, + &format!("#[{macro_name}] can only be applied to structs or impl blocks"), + )), + } +} + +/// Checks if an attribute matches a given name, handling both unqualified (`#[jsg_method]`) +/// and qualified (`#[jsg_macros::jsg_method]`) paths. +pub fn is_attr(attr: &syn::Attribute, name: &str) -> bool { + attr.path().is_ident(name) || attr.path().segments.last().is_some_and(|s| s.ident == name) +} + +/// Returns `true` if the `custom_trace` bare word is present in the attribute token stream. +/// +/// Handles both bare `custom_trace` and combined forms like `name = "Foo", custom_trace`. +/// When set, `#[jsg_resource]` on a struct suppresses the auto-generated `Traced` +/// impl, letting the user write their own. +pub fn has_custom_trace_flag(attr: &TokenStream) -> bool { + use syn::Meta; + use syn::punctuated::Punctuated; + + let Ok(parsed) = syn::parse::Parser::parse( + Punctuated::::parse_terminated, + attr.clone(), + ) else { + return false; + }; + + parsed + .iter() + .any(|meta| matches!(meta, Meta::Path(path) if path.is_ident("custom_trace"))) +} + +/// Emits a `compile_error!` token stream anchored to `tokens` with message `msg`. +pub fn error(tokens: &impl ToTokens, msg: &str) -> TokenStream { + syn::Error::new_spanned(tokens, msg) + .to_compile_error() + .into() +} + +/// Extracts the `name = "..."` value from an attribute token stream. +/// +/// Handles combined forms like `name = "Foo", custom_trace` by parsing the +/// token stream as comma-separated `syn::Meta` items and finding the first +/// `name = "..."` name-value pair. +pub fn extract_name_attribute(tokens: TokenStream) -> Option { + use syn::Meta; + use syn::punctuated::Punctuated; + + let parsed: Punctuated = + syn::parse::Parser::parse(Punctuated::parse_terminated, tokens).ok()?; + + for meta in &parsed { + if let Meta::NameValue(nv) = meta + && nv.path.is_ident("name") + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = &nv.value + { + return Some(s.value()); + } + } + + None +} + +/// Converts a `snake_case` identifier to `camelCase`. +pub fn snake_to_camel(s: &str) -> String { + let mut result = String::new(); + let mut cap_next = false; + for (i, c) in s.chars().enumerate() { + match c { + '_' => cap_next = true, + _ if i == 0 => result.push(c), + _ if cap_next => { + result.push(c.to_ascii_uppercase()); + cap_next = false; + } + _ => result.push(c), + } + } + result +} + +/// Checks if a type is `Result`. +pub fn is_result_type(ty: &syn::Type) -> bool { + if let syn::Type::Path(type_path) = ty + && let Some(segment) = type_path.path.segments.last() + { + return segment.ident == "Result"; + } + false +} + +/// Returns true if the type is `&mut Lock` or `&mut jsg::Lock`. +/// +/// When a method's first typed parameter matches this pattern, the macro passes the +/// callback's `lock` directly instead of extracting it from JavaScript arguments. +pub fn is_lock_ref(ty: &syn::Type) -> bool { + let syn::Type::Reference(ref_type) = ty else { + return false; + }; + if ref_type.mutability.is_none() { + return false; + } + let syn::Type::Path(type_path) = ref_type.elem.as_ref() else { + return false; + }; + let segments: Vec<_> = type_path.path.segments.iter().collect(); + match segments.len() { + // `&mut Lock` — bare import (assumes `use jsg::Lock;`) + 1 => segments[0].ident == "Lock", + // `&mut jsg::Lock` — fully qualified path + 2 => segments[0].ident == "jsg" && segments[1].ident == "Lock", + _ => false, + } +} + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::*; + + #[test] + fn snake_to_camel_cases() { + // First char is never uppercased; each `_` capitalises the next letter. + assert_eq!(snake_to_camel(""), ""); + assert_eq!(snake_to_camel("hello"), "hello"); + assert_eq!(snake_to_camel("get_name"), "getName"); + assert_eq!(snake_to_camel("parse_caa_record"), "parseCaaRecord"); + assert_eq!(snake_to_camel("alreadyCamel"), "alreadyCamel"); + // A leading `_` sets cap_next; the next char is capitalised. + assert_eq!(snake_to_camel("_private"), "Private"); + // Consecutive underscores — the second just re-sets cap_next. + assert_eq!(snake_to_camel("a__b"), "aB"); + } + + #[test] + fn is_result_type_cases() { + assert!(is_result_type(&parse_quote!(Result))); + // Qualified path: last segment is still `Result`. + assert!(is_result_type(&parse_quote!(std::result::Result<(), ()>))); + assert!(!is_result_type(&parse_quote!(Option))); + assert!(!is_result_type(&parse_quote!(String))); + } + + #[test] + fn is_lock_ref_cases() { + assert!(is_lock_ref(&parse_quote!(&mut Lock))); + assert!(is_lock_ref(&parse_quote!(&mut jsg::Lock))); + // Immutable ref, wrong type, or not a ref at all must all return false. + assert!(!is_lock_ref(&parse_quote!(&Lock))); + assert!(!is_lock_ref(&parse_quote!(&mut String))); + assert!(!is_lock_ref(&parse_quote!(Lock))); + } + + #[test] + fn is_attr_cases() { + let simple: syn::ItemFn = parse_quote! { #[jsg_method] fn foo() {} }; + let qualified: syn::ItemFn = parse_quote! { #[jsg_macros::jsg_method] fn foo() {} }; + + assert!(is_attr(&simple.attrs[0], "jsg_method")); + assert!(!is_attr(&simple.attrs[0], "jsg_resource")); + // Qualified path (`jsg_macros::jsg_method`) must also match by last segment. + assert!(is_attr(&qualified.attrs[0], "jsg_method")); + } +} diff --git a/src/rust/jsg-test/tests/collections_gc.rs b/src/rust/jsg-test/tests/collections_gc.rs new file mode 100644 index 00000000000..15c110eedcd --- /dev/null +++ b/src/rust/jsg-test/tests/collections_gc.rs @@ -0,0 +1,920 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! GC tracing tests for collection fields in `#[jsg_resource]` structs. +//! +//! Verifies that `Vec>`, `HashMap>`, `BTreeMap>`, +//! `HashSet>`, `BTreeSet>`, and their `Cell<…>` variants all +//! produce correct GC trace edges so that children are kept alive as long as the parent +//! is reachable, and collected once the parent is collected. +//! +//! Each test follows the same pattern: +//! 1. Wrap the parent for JavaScript (gives it a JS wrapper held by a context global). +//! 2. Drop all Rust `Rc` handles — the parent is now only alive via the JS wrapper. +//! 3. Run a minor + major GC while the global is reachable → children must survive. +//! 4. Move to a fresh context (global is gone) → GC must collect parent and all children. + +use std::cell::Cell; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use jsg::ToJS; +use jsg_macros::jsg_method; +use jsg_macros::jsg_resource; + +// ============================================================================= +// Shared leaf resource +// ============================================================================= + +static LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct Leaf { + pub value: u32, +} + +impl Drop for Leaf { + fn drop(&mut self) { + LEAF_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl Leaf { + #[jsg_method] + fn get_value(&self) -> jsg::Number { + jsg::Number::from(self.value) + } +} + +// ============================================================================= +// Vec> +// ============================================================================= + +static VEC_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct VecParent { + pub children: Vec>, +} + +impl Drop for VecParent { + fn drop(&mut self) { + VEC_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl VecParent {} + +/// `Vec>` children are kept alive while parent JS wrapper is reachable. +#[test] +fn vec_rc_children_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + VEC_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let c1 = jsg::Rc::new(Leaf { value: 1 }); + let c2 = jsg::Rc::new(Leaf { value: 2 }); + let c3 = jsg::Rc::new(Leaf { value: 3 }); + + let parent = jsg::Rc::new(VecParent { + children: vec![c1.clone(), c2.clone(), c3.clone()], + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + // Drop all Rust refs. + std::mem::drop(c1); + std::mem::drop(c2); + std::mem::drop(c3); + std::mem::drop(parent); + + // GC while global is reachable — parent and all children must survive. + crate::Harness::request_gc(lock); + assert_eq!(VEC_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + // Fresh context — global is gone, everything should be collected. + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(VEC_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 3); + Ok(()) + }); +} + +/// An empty `Vec>` does not crash during GC. +#[test] +fn vec_rc_empty_does_not_crash_during_gc() { + VEC_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(VecParent { children: vec![] }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(VEC_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(VEC_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// HashMap> +// ============================================================================= + +static HASHMAP_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct HashMapParent { + pub children: HashMap>, +} + +impl Drop for HashMapParent { + fn drop(&mut self) { + HASHMAP_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl HashMapParent {} + +/// `HashMap>` values are kept alive while parent JS wrapper is reachable. +#[test] +fn hashmap_rc_values_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + HASHMAP_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let a = jsg::Rc::new(Leaf { value: 10 }); + let b = jsg::Rc::new(Leaf { value: 20 }); + + let mut map = HashMap::new(); + map.insert("a".to_owned(), a.clone()); + map.insert("b".to_owned(), b.clone()); + + let parent = jsg::Rc::new(HashMapParent { children: map }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(a); + std::mem::drop(b); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(HASHMAP_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(HASHMAP_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 2); + Ok(()) + }); +} + +/// An empty `HashMap>` does not crash during GC. +#[test] +fn hashmap_rc_empty_does_not_crash_during_gc() { + HASHMAP_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(HashMapParent { + children: HashMap::new(), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(HASHMAP_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(HASHMAP_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// BTreeMap> +// ============================================================================= + +static BTREEMAP_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct BTreeMapParent { + pub children: BTreeMap>, +} + +impl Drop for BTreeMapParent { + fn drop(&mut self) { + BTREEMAP_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl BTreeMapParent {} + +/// `BTreeMap>` values are kept alive while parent JS wrapper is reachable. +#[test] +fn btreemap_rc_values_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + BTREEMAP_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let x = jsg::Rc::new(Leaf { value: 100 }); + let y = jsg::Rc::new(Leaf { value: 200 }); + + let mut map = BTreeMap::new(); + map.insert("x".to_owned(), x.clone()); + map.insert("y".to_owned(), y.clone()); + + let parent = jsg::Rc::new(BTreeMapParent { children: map }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(x); + std::mem::drop(y); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(BTREEMAP_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(BTREEMAP_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 2); + Ok(()) + }); +} + +/// An empty `BTreeMap>` does not crash during GC. +#[test] +fn btreemap_rc_empty_does_not_crash_during_gc() { + BTREEMAP_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(BTreeMapParent { + children: BTreeMap::new(), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(BTREEMAP_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(BTREEMAP_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// HashSet> +// ============================================================================= + +static HASHSET_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +// jsg::Rc implements Hash + Eq by pointer identity (matching std::rc::Rc). +#[jsg_resource] +struct HashSetParent { + pub children: HashSet>, +} + +impl Drop for HashSetParent { + fn drop(&mut self) { + HASHSET_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl HashSetParent {} + +/// `HashSet>` elements are kept alive while parent JS wrapper is reachable. +// jsg::Rc uses pointer-identity Hash+Eq, so interior mutability (Cell fields) doesn't +// affect correctness. Suppress the mutable_key_type lint that fires on HashSet::new(). +#[expect( + clippy::mutable_key_type, + reason = "jsg::Rc hashes by pointer address, not interior state" +)] +#[test] +fn hashset_rc_elements_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + HASHSET_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let p = jsg::Rc::new(Leaf { value: 1 }); + let q = jsg::Rc::new(Leaf { value: 2 }); + + let mut set = HashSet::new(); + set.insert(p.clone()); + set.insert(q.clone()); + + let parent = jsg::Rc::new(HashSetParent { children: set }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(p); + std::mem::drop(q); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(HASHSET_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(HASHSET_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 2); + Ok(()) + }); +} + +/// An empty `HashSet>` does not crash during GC. +#[test] +fn hashset_rc_empty_does_not_crash_during_gc() { + HASHSET_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(HashSetParent { + children: HashSet::new(), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(HASHSET_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(HASHSET_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// BTreeSet> +// ============================================================================= + +static BTREESET_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct BTreeSetParent { + pub children: BTreeSet>, +} + +impl Drop for BTreeSetParent { + fn drop(&mut self) { + BTREESET_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl BTreeSetParent {} + +/// `BTreeSet>` elements are kept alive while parent JS wrapper is reachable. +// Same pointer-identity rationale as the HashSet test above. +#[expect( + clippy::mutable_key_type, + reason = "jsg::Rc orders by pointer address, not interior state" +)] +#[test] +fn btreeset_rc_elements_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + BTREESET_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let r = jsg::Rc::new(Leaf { value: 7 }); + let s = jsg::Rc::new(Leaf { value: 8 }); + + let mut set = BTreeSet::new(); + set.insert(r.clone()); + set.insert(s.clone()); + + let parent = jsg::Rc::new(BTreeSetParent { children: set }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(r); + std::mem::drop(s); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(BTREESET_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(BTREESET_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 2); + Ok(()) + }); +} + +/// An empty `BTreeSet>` does not crash during GC. +#[test] +fn btreeset_rc_empty_does_not_crash_during_gc() { + BTREESET_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(BTreeSetParent { + children: BTreeSet::new(), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(BTREESET_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(BTREESET_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// Cell>> +// ============================================================================= + +static CELL_VEC_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct CellVecParent { + pub children: Cell>>, +} + +impl Drop for CellVecParent { + fn drop(&mut self) { + CELL_VEC_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl CellVecParent {} + +/// `Cell>>` children are kept alive while parent JS wrapper is reachable. +#[test] +fn cell_vec_rc_children_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + CELL_VEC_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let c1 = jsg::Rc::new(Leaf { value: 11 }); + let c2 = jsg::Rc::new(Leaf { value: 22 }); + + let parent = jsg::Rc::new(CellVecParent { + children: Cell::new(vec![c1.clone(), c2.clone()]), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(c1); + std::mem::drop(c2); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(CELL_VEC_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(CELL_VEC_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 2); + Ok(()) + }); +} + +/// An empty `Cell>>` does not crash during GC. +#[test] +fn cell_vec_rc_empty_does_not_crash_during_gc() { + CELL_VEC_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(CellVecParent { + children: Cell::new(vec![]), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(CELL_VEC_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(CELL_VEC_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// Cell>> +// ============================================================================= + +static CELL_MAP_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct CellHashMapParent { + pub children: Cell>>, +} + +impl Drop for CellHashMapParent { + fn drop(&mut self) { + CELL_MAP_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl CellHashMapParent {} + +/// `Cell>>` values are kept alive while parent JS wrapper is reachable. +#[test] +fn cell_hashmap_rc_values_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + CELL_MAP_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let m = jsg::Rc::new(Leaf { value: 99 }); + + let mut map = HashMap::new(); + map.insert("m".to_owned(), m.clone()); + + let parent = jsg::Rc::new(CellHashMapParent { + children: Cell::new(map), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(m); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(CELL_MAP_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(CELL_MAP_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// Mixed field resource — Vec + HashMap + bare Rc all in one struct +// ============================================================================= + +static MIXED_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct MixedParent { + pub singles: Vec>, + pub named: HashMap>, + pub direct: jsg::Rc, +} + +impl Drop for MixedParent { + fn drop(&mut self) { + MIXED_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl MixedParent {} + +/// A resource with `Vec`, `HashMap`, and bare `Rc` fields — all children traced. +#[test] +fn mixed_collection_fields_all_traced() { + LEAF_DROPS.store(0, Ordering::SeqCst); + MIXED_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let vec_child = jsg::Rc::new(Leaf { value: 1 }); + let map_child = jsg::Rc::new(Leaf { value: 2 }); + let direct_child = jsg::Rc::new(Leaf { value: 3 }); + + let mut named = HashMap::new(); + named.insert("key".to_owned(), map_child.clone()); + + let parent = jsg::Rc::new(MixedParent { + singles: vec![vec_child.clone()], + named, + direct: direct_child.clone(), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(vec_child); + std::mem::drop(map_child); + std::mem::drop(direct_child); + std::mem::drop(parent); + + // All 3 children must survive (1 from vec, 1 from map, 1 direct). + crate::Harness::request_gc(lock); + assert_eq!(MIXED_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(MIXED_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 3); + Ok(()) + }); +} + +// ============================================================================= +// Vec> — child kept alive by vec even after outer Rust ref is dropped +// ============================================================================= + +/// Verifies that a child held only by a `Vec` inside a JS-wrapped parent is NOT +/// collected while the parent is reachable, even after its own Rust `Rc` is gone. +#[test] +fn vec_rc_child_kept_alive_by_parent_after_rust_ref_dropped() { + LEAF_DROPS.store(0, Ordering::SeqCst); + VEC_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, _ctx| { + let child = jsg::Rc::new(Leaf { value: 42 }); + + let parent = jsg::Rc::new(VecParent { + children: vec![child.clone()], + }); + + // Wrap parent so it gets a JS object. + let _ = parent.clone().to_js(lock); + + // Drop the child Rust ref — vec inside parent is the only holder. + std::mem::drop(child); + + // Force a GC: parent wrapper is unreachable (no global), but the local + // handle keeps it alive in this scope. + crate::Harness::request_gc(lock); + // Child must NOT have been collected yet — the parent's vec still holds it. + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + + std::mem::drop(parent); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(VEC_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// HashMap with integer key — u32 key, jsg::Rc value +// ============================================================================= + +static INT_KEY_MAP_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct IntKeyMapParent { + pub children: HashMap>, +} + +impl Drop for IntKeyMapParent { + fn drop(&mut self) { + INT_KEY_MAP_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl IntKeyMapParent {} + +/// `HashMap>` — integer key does not affect tracing. +#[test] +fn hashmap_integer_key_rc_values_traced() { + LEAF_DROPS.store(0, Ordering::SeqCst); + INT_KEY_MAP_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let v = jsg::Rc::new(Leaf { value: 55 }); + + let mut map = HashMap::new(); + map.insert(0u32, v.clone()); + map.insert(1u32, v.clone()); // same child twice + + let parent = jsg::Rc::new(IntKeyMapParent { children: map }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(v); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(INT_KEY_MAP_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(INT_KEY_MAP_PARENT_DROPS.load(Ordering::SeqCst), 1); + // One unique Leaf despite two map entries (both were clones of the same Rc). + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// Nested struct tracing via `Traced` +// +// Mirrors the C++ pattern: +// struct PrivateData { void visitForGc(jsg::GcVisitor&) { ... } }; +// class Foo : public jsg::Object { PrivateData privateData_; ... }; +// ============================================================================= + +static TRACE_DELEGATION_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// A plain Rust struct (not a jsg resource) that holds traceable children. +/// It manually implements `Traced`. +struct PrivateData { + child: jsg::Rc, +} + +impl jsg::Traced for PrivateData { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + visitor.visit_rc(&self.child); + } +} + +/// Resource with a plain nested field — auto-generated tracing delegates to +/// `Traced::trace` for each field, including this one. +#[jsg_resource] +struct TraceDelegationParent { + pub data: PrivateData, +} + +impl Drop for TraceDelegationParent { + fn drop(&mut self) { + TRACE_DELEGATION_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl TraceDelegationParent {} + +/// Children held inside a nested `Traced` struct are kept alive while the +/// parent JS wrapper is reachable. +#[test] +fn trace_delegation_child_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + TRACE_DELEGATION_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let child = jsg::Rc::new(Leaf { value: 77 }); + + let parent = jsg::Rc::new(TraceDelegationParent { + data: PrivateData { + child: child.clone(), + }, + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(child); + std::mem::drop(parent); + + // GC while global reachable — child inside PrivateData must survive. + crate::Harness::request_gc(lock); + assert_eq!(TRACE_DELEGATION_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(TRACE_DELEGATION_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +// ============================================================================= +// custom_trace — user-written Traced impl +// ============================================================================= + +static CUSTOM_TRACE_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +/// Resource that opts out of macro-generated tracing via `custom_trace`. +/// The user provides their own `Traced` impl with custom logic. +#[jsg_resource(custom_trace)] +struct CustomTraceParent { + pub child: jsg::Rc, +} + +impl Drop for CustomTraceParent { + fn drop(&mut self) { + CUSTOM_TRACE_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +// User-supplied Traced impl — not generated by the macro because of custom_trace. +impl jsg::Traced for CustomTraceParent { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + visitor.visit_rc(&self.child); + } +} + +#[jsg_resource] +impl CustomTraceParent {} + +/// With `custom_trace`, the user-supplied `Traced` impl is used and +/// children are traced correctly. +#[test] +fn custom_trace_child_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + CUSTOM_TRACE_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let child = jsg::Rc::new(Leaf { value: 88 }); + + let parent = jsg::Rc::new(CustomTraceParent { + child: child.clone(), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(child); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(CUSTOM_TRACE_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(CUSTOM_TRACE_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} diff --git a/src/rust/jsg-test/tests/gc.rs b/src/rust/jsg-test/tests/gc.rs index 873fb82c3ce..a17b4e3fac9 100644 --- a/src/rust/jsg-test/tests/gc.rs +++ b/src/rust/jsg-test/tests/gc.rs @@ -935,6 +935,8 @@ struct NativeState { value: u64, } +impl jsg::Traced for NativeState {} + impl Drop for NativeState { fn drop(&mut self) { NATIVE_STATE_DROPS.fetch_add(1, Ordering::SeqCst); @@ -1089,7 +1091,7 @@ static CYCLIC_RESOURCE_DROPS: AtomicUsize = AtomicUsize::new(0); /// to the resource's own JS wrapper can be installed after wrapping. /// /// `Cell>` provides interior mutability through `&self`, matching -/// the access pattern of `GarbageCollected::trace(&self)`. +/// the access pattern of `Traced::trace(&self)`. #[jsg_resource] struct CyclicResource { /// The stored "callback". Uses `Cell` so it can be set after `to_js()` diff --git a/src/rust/jsg-test/tests/mod.rs b/src/rust/jsg-test/tests/mod.rs index 5330f268cdc..8b14e89d65e 100644 --- a/src/rust/jsg-test/tests/mod.rs +++ b/src/rust/jsg-test/tests/mod.rs @@ -4,6 +4,7 @@ mod arrays; mod buffer_types; +mod collections_gc; mod eval; mod function; mod gc; @@ -17,4 +18,5 @@ mod resource_conversion; mod resource_properties; mod string; mod symbol; +mod traceable_gc; mod unwrap; diff --git a/src/rust/jsg-test/tests/traceable_gc.rs b/src/rust/jsg-test/tests/traceable_gc.rs new file mode 100644 index 00000000000..5cc17513e06 --- /dev/null +++ b/src/rust/jsg-test/tests/traceable_gc.rs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +//! GC tracing tests for manual `jsg::Traced` implementations on helper types. +//! +//! `#[jsg_resource]` now traces every field via `Traced::trace`, so nested +//! non-resource helper structs/enums just need to implement `Traced`. + +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use jsg::ToJS; +use jsg_macros::jsg_resource; + +// ============================================================================= +// Shared leaf resource +// ============================================================================= + +static LEAF_DROPS: AtomicUsize = AtomicUsize::new(0); + +#[jsg_resource] +struct Leaf { + pub value: u32, +} + +impl Drop for Leaf { + fn drop(&mut self) { + LEAF_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl Leaf {} + +// ============================================================================= +// Enum helper implementing Traced +// ============================================================================= + +static ENUM_PARENT_DROPS: AtomicUsize = AtomicUsize::new(0); + +enum StreamState { + Closed, + Errored { reason: jsg::Rc }, + Readable(jsg::Rc), +} + +impl jsg::Traced for StreamState { + fn trace(&self, visitor: &mut jsg::GcVisitor) { + match self { + Self::Closed => {} + Self::Errored { reason } => jsg::Traced::trace(reason, visitor), + Self::Readable(inner) => jsg::Traced::trace(inner, visitor), + } + } +} + +#[jsg_resource] +struct StreamController { + pub state: StreamState, +} + +impl Drop for StreamController { + fn drop(&mut self) { + ENUM_PARENT_DROPS.fetch_add(1, Ordering::SeqCst); + } +} + +#[jsg_resource] +impl StreamController {} + +#[test] +fn enum_unit_variant_does_not_crash_during_gc() { + ENUM_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let parent = jsg::Rc::new(StreamController { + state: StreamState::Closed, + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(ENUM_PARENT_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(ENUM_PARENT_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +#[test] +fn enum_variant_child_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + ENUM_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let child = jsg::Rc::new(Leaf { value: 10 }); + + let parent = jsg::Rc::new(StreamController { + state: StreamState::Readable(child.clone()), + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(child); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(ENUM_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(ENUM_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} + +#[test] +fn enum_named_variant_child_kept_alive_through_gc() { + LEAF_DROPS.store(0, Ordering::SeqCst); + ENUM_PARENT_DROPS.store(0, Ordering::SeqCst); + + let harness = crate::Harness::new(); + harness.run_in_context(|lock, ctx| { + let child = jsg::Rc::new(Leaf { value: 11 }); + + let parent = jsg::Rc::new(StreamController { + state: StreamState::Errored { + reason: child.clone(), + }, + }); + let wrapped = parent.clone().to_js(lock); + ctx.set_global("parent", wrapped); + + std::mem::drop(child); + std::mem::drop(parent); + + crate::Harness::request_gc(lock); + assert_eq!(ENUM_PARENT_DROPS.load(Ordering::SeqCst), 0); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 0); + Ok(()) + }); + + harness.run_in_context(|lock, _ctx| { + crate::Harness::request_gc(lock); + assert_eq!(ENUM_PARENT_DROPS.load(Ordering::SeqCst), 1); + assert_eq!(LEAF_DROPS.load(Ordering::SeqCst), 1); + Ok(()) + }); +} diff --git a/src/rust/jsg/README.md b/src/rust/jsg/README.md index 86f2e1f3f3e..a6391a50784 100644 --- a/src/rust/jsg/README.md +++ b/src/rust/jsg/README.md @@ -73,7 +73,7 @@ let r: jsg::Rc = jsg::Rc::from_js(&mut lock, js_val)?; - **No JS wrapper**: Dropping the last `Rc` immediately destroys the resource (no GC needed). - **With JS wrapper**: Dropping all `Rc`s makes the wrapper eligible for V8 GC. When collected, the resource is destroyed. -- **Tracing**: The `#[jsg_resource]` macro auto-generates `GarbageCollected::trace` based on field types: +- **Tracing**: The `#[jsg_resource]` macro auto-generates `Traced::trace` and calls it on every field: | Field type | Traced? | Notes | |---|---|---| @@ -83,15 +83,26 @@ let r: jsg::Rc = jsg::Rc::from_js(&mut lock, js_val)?; | `Cell>` | Yes — strong edge | Use `Cell` when field needs interior mutability | | `Cell>>` | Yes — when `Some` | | | `Cell>>` | Yes — when `Some` | | +| `Vec>` | Yes — each element | Iterates and visits every `Rc` in the vec | +| `HashMap>` | Yes — each value | Iterates `.values()` and visits each `Rc` | +| `BTreeMap>` | Yes — each value | Iterates `.values()` and visits each `Rc` | +| `HashSet>` | Yes — each element | Iterates and visits every `Rc` | +| `BTreeSet>` | Yes — each element | Iterates and visits every `Rc` | +| `Cell>>` | Yes — each element | `Cell` variant of above | +| `Cell>>` | Yes — each value | `Cell` variant of above | +| Same patterns with `jsg::v8::Global` | Yes | All collection forms work with `Global` too | | `jsg::v8::Global` | Yes — dual strong/traced | Enables cycle collection; see below | | `Option>` | Yes — when `Some` | | | `jsg::Nullable>` | Yes — when `Some` | | | `Cell>` | Yes — dual strong/traced | Required when set after construction | | `Cell>>` | Yes — when `Some` | | | `jsg::Weak` | No | Doesn't keep target alive | -| Anything else | No | Plain data fields are ignored | +| Any `T: Traced` | Depends on `T` | `#[jsg_resource]` calls `Traced::trace` on every field | -- **`Cell` for interior mutability**: `GarbageCollected::trace` takes `&self`. Fields that need to be mutated after construction (e.g. a callback set in a method) must be wrapped in `Cell`. Both `Cell` and `std::cell::Cell` are recognised. +- **`Cell` for interior mutability**: `Traced::trace` takes `&self`. Fields that need to be mutated after construction (e.g. a callback set in a method) can use `Cell`. `Cell` implements `Traced` by reading through `as_ptr()` during single-threaded GC tracing. +- **`Traced` drives field tracing**: `#[jsg_resource]` now traces every named field via `Traced::trace(&self.field, visitor)`. Types with no GC edges use no-op `Traced` impls; wrappers/collections delegate recursively. +- **Nested wrappers are supported by composition**: `Option>>` works as long as each layer implements `Traced`. +- **`#[jsg_resource(custom_trace)]`**: suppresses the generated `Traced` impl so you can write your own. `GarbageCollected` (`memory_name`), `Type`, `ToJS`, and `FromJS` are still generated. - **`jsg::v8::Global` cycle collection**: Uses the same strong↔traced dual-mode as C++ `jsg::V8Ref`. While the parent resource has strong Rust refs the JS handle stays strong. Once all Rust `Rc`s are dropped, `visit_global` downgrades the handle to a `v8::TracedReference` that cppgc can follow — allowing cycles (e.g. a resource holding a callback that captures its own wrapper) to be detected and collected. - **Circular references** through `jsg::Rc` are **not** collected, matching C++ `jsg::Rc` behavior. diff --git a/src/rust/jsg/lib.rs b/src/rust/jsg/lib.rs index 79f2fe1d109..9ced64c5804 100644 --- a/src/rust/jsg/lib.rs +++ b/src/rust/jsg/lib.rs @@ -7,6 +7,7 @@ use std::num::ParseIntError; use std::ops::Deref; pub mod feature_flags; +pub mod macros; pub mod modules; pub mod resource; pub mod v8; @@ -34,6 +35,7 @@ pub use v8::Uint32Array; pub use v8::ffi::ExceptionType; pub use wrappable::FromJS; pub use wrappable::ToJS; +pub use wrappable::Traced; #[cxx::bridge(namespace = "workerd::rust::jsg")] mod ffi { @@ -716,14 +718,15 @@ pub enum Member { }, } -/// Trait for types that participate in V8 garbage collection tracing. +/// Trait for types that participate in V8 garbage collection as tracked resources. /// -/// Implementors can report nested GC-visible references via [`GcVisitor`] and -/// optionally provide a class name for heap snapshot tooling. -pub trait GarbageCollected { - /// Trace nested GC-visible references. Called during V8 GC marking. - fn trace(&self, _visitor: &mut GcVisitor); - +/// Extends [`Traced`] (which provides the `trace` method for visiting nested +/// GC-visible references) with a class name for heap snapshot tooling. +/// +/// `#[jsg_resource]` auto-derives both `Traced` and `GarbageCollected`. +/// Use `#[jsg_resource(custom_trace)]` to suppress the generated `Traced` +/// impl and provide your own. +pub trait GarbageCollected: Traced { /// Class name for heap snapshots / debugging. /// /// Returns a `&'static CStr` — always a compile-time literal. This lets the C++ side diff --git a/src/rust/jsg/macros.rs b/src/rust/jsg/macros.rs new file mode 100644 index 00000000000..83fa945e914 --- /dev/null +++ b/src/rust/jsg/macros.rs @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +/// Generates a no-op [`Traced`](crate::Traced) implementation for types with no GC-visible +/// references. +/// +/// Types that contain only plain data (no `jsg::Rc`, `jsg::v8::Global`, etc.) do not +/// need to participate in GC tracing. This macro produces an empty `Traced` impl +/// whose `trace` method is a no-op. +/// +/// # Example +/// +/// ```ignore +/// use jsg::jsg_traced; +/// +/// struct Id(u64); +/// jsg_traced!(Id); +/// ``` +#[macro_export] +macro_rules! jsg_traced { + ($($t:ty),* $(,)?) => { + $(impl $crate::Traced for $t {})* + }; +} + +/// Validates a condition at runtime, returning a [`jsg::Error`](crate::Error) if the condition +/// is `false`. +/// +/// This is the Rust equivalent of the C++ `JSG_REQUIRE` macro defined in +/// `src/workerd/jsg/exception.h`. While the C++ version throws a KJ exception, this macro +/// returns `Err(jsg::Error)` via the `?` operator, following Rust's error-handling conventions. +/// +/// # Syntax +/// +/// ```ignore +/// jsg_require!(condition, ExceptionVariant, "message"); +/// jsg_require!(condition, ExceptionVariant, "format {} string", arg1, arg2); +/// ``` +/// +/// # Parameters +/// +/// - `condition` — Any expression that evaluates to `bool`. When `true`, the macro is a no-op. +/// When `false`, an error is returned. +/// - `ExceptionVariant` — One of the [`ExceptionType`](crate::v8::ffi::ExceptionType) variants +/// that determines the JavaScript error class thrown to user code. Available variants: +/// `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `Error`, +/// `OperationError`, `DataError`, `DataCloneError`, `InvalidAccessError`, +/// `InvalidStateError`, `InvalidCharacterError`, `NotSupportedError`, +/// `TimeoutError`, `TypeMismatchError`, `AbortError`, `NotFoundError`. +/// - `"message"` / `"format string", args...` — A message string, optionally with `format!`-style +/// arguments. Unlike the C++ `JSG_REQUIRE` which concatenates via `kj::str()`, this macro uses +/// Rust's standard `format!()` for string interpolation. +/// +/// # Return Type +/// +/// The enclosing function must return `jsg::Result` (or any `Result` where +/// `E: From`). The macro expands to an early `return Err(...)` on failure. +/// +/// # Examples +/// +/// ```ignore +/// use jsg::{jsg_require, Result}; +/// +/// fn parse_port(value: u32) -> Result { +/// jsg_require!(value <= 65535, RangeError, "port {} out of range", value); +/// Ok(value as u16) +/// } +/// +/// fn require_non_empty(s: &str) -> Result<()> { +/// jsg_require!(!s.is_empty(), TypeError, "string must not be empty"); +/// Ok(()) +/// } +/// ``` +/// +/// # Comparison with C++ +/// +/// | C++ | Rust | +/// |-----|------| +/// | `JSG_REQUIRE(port <= 65535, RangeError, "port ", port, " out of range")` | `jsg_require!(port <= 65535, RangeError, "port {} out of range", port)` | +/// | Throws `kj::Exception` | Returns `Err(jsg::Error)` | +/// | Uses `kj::str()` concatenation | Uses `format!()` interpolation | +#[macro_export] +macro_rules! jsg_require { + ($cond:expr, $err_type:ident, $msg:literal $(, $arg:expr)* $(,)?) => { + if !($cond) { + return Err($crate::Error { + name: $crate::ExceptionType::$err_type, + message: format!($msg $(, $arg)*), + }); + } + }; +} + +/// Unconditionally returns a [`jsg::Error`](crate::Error) from the enclosing function. +/// +/// This is the Rust equivalent of the C++ `JSG_FAIL_REQUIRE` macro defined in +/// `src/workerd/jsg/exception.h`. While the C++ version throws a KJ exception +/// unconditionally, this macro returns `Err(jsg::Error)` via an early `return`, +/// following Rust's error-handling conventions. +/// +/// # Syntax +/// +/// ```ignore +/// jsg_fail_require!(ExceptionVariant, "message"); +/// jsg_fail_require!(ExceptionVariant, "format {} string", arg1, arg2); +/// ``` +/// +/// # Parameters +/// +/// - `ExceptionVariant` — One of the [`ExceptionType`](crate::v8::ffi::ExceptionType) variants +/// that determines the JavaScript error class thrown to user code. Available variants: +/// `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `Error`, +/// `OperationError`, `DataError`, `DataCloneError`, `InvalidAccessError`, +/// `InvalidStateError`, `InvalidCharacterError`, `NotSupportedError`, +/// `TimeoutError`, `TypeMismatchError`, `AbortError`, `NotFoundError`. +/// - `"message"` / `"format string", args...` — A message string, optionally with `format!`-style +/// arguments. Unlike the C++ `JSG_FAIL_REQUIRE` which concatenates via `kj::str()`, this macro +/// uses Rust's standard `format!()` for string interpolation. +/// +/// # Return Type +/// +/// The enclosing function must return `jsg::Result` (or any `Result` where +/// `E: From`). The macro always triggers an early `return Err(...)`. +/// +/// # Examples +/// +/// ```ignore +/// use jsg::{jsg_fail_require, Result}; +/// +/// fn unsupported_algorithm(name: &str) -> Result<()> { +/// jsg_fail_require!(NotSupportedError, "algorithm '{}' is not supported", name); +/// } +/// +/// fn validate_input(mode: &str) -> Result { +/// match mode { +/// "fast" => Ok("fast-path".to_owned()), +/// "slow" => Ok("slow-path".to_owned()), +/// _ => jsg_fail_require!(TypeError, "invalid mode: '{}'", mode), +/// } +/// } +/// ``` +/// +/// # Comparison with C++ +/// +/// | C++ | Rust | +/// |-----|------| +/// | `JSG_FAIL_REQUIRE(TypeError, "invalid mode: ", mode)` | `jsg_fail_require!(TypeError, "invalid mode: '{}'", mode)` | +/// | Throws `kj::Exception` | Returns `Err(jsg::Error)` | +/// | Uses `kj::str()` concatenation | Uses `format!()` interpolation | +#[macro_export] +macro_rules! jsg_fail_require { + ($err_type:ident, $msg:literal $(, $arg:expr)* $(,)?) => { + return Err($crate::Error { + name: $crate::ExceptionType::$err_type, + message: format!($msg $(, $arg)*), + }) + }; +} diff --git a/src/rust/jsg/resource.rs b/src/rust/jsg/resource.rs index d9bfa09f60e..20652065972 100644 --- a/src/rust/jsg/resource.rs +++ b/src/rust/jsg/resource.rs @@ -18,6 +18,7 @@ use crate::GarbageCollected; use crate::Lock; use crate::Member; use crate::ToJS; +use crate::Traced; use crate::Type; use crate::v8; use crate::v8::ffi::Wrappable; @@ -88,7 +89,7 @@ impl Rc { /// Delegates to C++ `Wrappable::visitRef()` which handles strong/traced switching /// and transitive tracing. /// - /// Takes `&self` because `GarbageCollected::trace(&self)` receives a shared + /// Takes `&self` because `Traced::trace(&self)` receives a shared /// reference to `R` inside the `Rc` allocation. The `parent` and `strong` /// fields already use `Cell` for interior mutability. /// `WrappableRc::visit_rc(&self)` produces `Pin<&mut Wrappable>` from @@ -201,6 +202,38 @@ impl FromJS for Rc { } } +/// Equality by pointer identity — two `Rc`s are equal iff they point to the +/// same allocation, matching the behaviour of [`std::rc::Rc`]. +impl PartialEq for Rc { + fn eq(&self, other: &Self) -> bool { + std::rc::Rc::ptr_eq(&self.handle, &other.handle) + } +} + +impl Eq for Rc {} + +/// Ordering by pointer address — consistent with `PartialEq` and allows +/// `Rc` to be used as a `BTreeSet`/`BTreeMap` key. +impl PartialOrd for Rc { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Rc { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + std::rc::Rc::as_ptr(&self.handle).cmp(&std::rc::Rc::as_ptr(&other.handle)) + } +} + +/// Hashing by pointer address — consistent with `PartialEq` and allows +/// `Rc` to be used as a `HashSet`/`HashMap` key. +impl std::hash::Hash for Rc { + fn hash(&self, state: &mut H) { + std::rc::Rc::as_ptr(&self.handle).hash(state); + } +} + impl fmt::Debug for Rc { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Rc") @@ -274,10 +307,17 @@ impl Clone for Weak { } } -impl GarbageCollected for Weak { - /// No-op: weak references don't keep the target alive and have no GC edges to trace. - fn trace(&self, _visitor: &mut v8::GcVisitor) {} +/// `Rc` is a strong GC edge — visited via `GcVisitor::visit_rc`. +impl Traced for Rc { + fn trace(&self, visitor: &mut v8::GcVisitor) { + visitor.visit_rc(self); + } +} +/// Weak references don't keep the target alive and have no GC edges to trace. +impl Traced for Weak {} + +impl GarbageCollected for Weak { fn memory_name(&self) -> &'static std::ffi::CStr { // jsgGetMemoryName is only called on live Wrappables, never on Weak. // Delegate to the concrete R via a live upgrade. In the (unreachable) diff --git a/src/rust/jsg/v8.rs b/src/rust/jsg/v8.rs index 1c2ef997a35..cf28c101571 100644 --- a/src/rust/jsg/v8.rs +++ b/src/rust/jsg/v8.rs @@ -2992,6 +2992,13 @@ impl Drop for Global { } } +/// `Global` is a strong GC handle — visited via `GcVisitor::visit_global`. +impl crate::Traced for Global { + fn trace(&self, visitor: &mut GcVisitor) { + visitor.visit_global(self); + } +} + // Note: Global intentionally does NOT implement the std Clone trait. // Cloning a V8 persistent handle requires an isolate pointer to create an // independent handle via v8::Global::New(isolate, original). The Clone trait @@ -3556,7 +3563,7 @@ impl WrappableRc { /// 3. **No same-object re-entrancy** — the C++ methods called through this /// pin (`addStrongRef`, `removeStrongRef`, `visitRef`) may re-enter Rust /// for *different* Wrappables during GC tracing (e.g. `visitRef` traces - /// children, which calls `GarbageCollected::trace()` on them), but never + /// children, which calls `Traced::trace()` on them), but never /// create a second `Pin<&mut Wrappable>` for the *same* object. #[expect( clippy::mut_from_ref, @@ -3570,7 +3577,7 @@ impl WrappableRc { /// Visits this Wrappable during GC tracing. /// /// Takes `&self` because this is called from `Ref::visit(&self)` which is - /// called from `GarbageCollected::trace(&self)`. The mutation target is + /// called from `Traced::trace(&self)`. The mutation target is /// the C++ `Wrappable` on the KJ heap, not the `WrappableRc` wrapper. pub(crate) fn visit_rc(&self, parent: *mut usize, strong: *mut bool, visitor: &mut GcVisitor) { // SAFETY: wrappable, parent, strong, and visitor pointers are all valid (guaranteed by callers). diff --git a/src/rust/jsg/wrappable.rs b/src/rust/jsg/wrappable.rs index fb17064e1da..4fed92267c0 100644 --- a/src/rust/jsg/wrappable.rs +++ b/src/rust/jsg/wrappable.rs @@ -73,6 +73,165 @@ use crate::Type; use crate::v8; use crate::v8::ToLocalValue; +// ============================================================================= +// Traced trait — GC tracing for field types +// ============================================================================= + +/// Trait for types that can be visited during V8 garbage collection tracing. +/// +/// Every type that can appear as a field in a `#[jsg_resource]` struct must +/// implement `Traced`. The generated `Traced::trace` body calls +/// `Traced::trace` on every field — types with no GC-visible references +/// simply use the default no-op implementation. +/// +/// # Built-in implementations +/// +/// - **Primitives** (`bool`, `String`, integers, floats, `()`) — no-op. +/// - **`Option`**, **`Nullable`** — delegates to the inner value if present. +/// - **Collections** (`Vec`, `HashMap`, `BTreeMap`, `HashSet`, +/// `BTreeSet`) — iterates elements/values and traces each. +/// - **`Cell`** — reads through `as_ptr()` (sound under single-threaded GC). +/// - **`jsg::Rc`** — visits the reference via `GcVisitor::visit_rc`. +/// - **`jsg::Weak`** — no-op (weak refs don't keep targets alive). +/// - **`jsg::v8::Global`** — visits via `GcVisitor::visit_global`. +pub trait Traced { + /// Visit GC-visible references held by this value. + /// + /// The default implementation is a no-op, suitable for types with no + /// GC-visible references (primitives, plain data structs, etc.). + fn trace(&self, _visitor: &mut crate::v8::GcVisitor) {} +} + +/// Generates no-op `Traced` implementations for types with no GC-visible references. +macro_rules! impl_traced_noop { + ($($t:ty),* $(,)?) => { + $(impl Traced for $t {})* + }; +} + +impl_traced_noop!( + (), + bool, + String, + crate::Error, + u8, + u16, + u32, + u64, + usize, + i8, + i16, + i32, + i64, + isize, + f32, + f64, + crate::v8::ArrayBuffer, + crate::v8::ArrayBufferView, + crate::v8::BackingStore, + crate::v8::BigInt64Array, + crate::v8::BigUint64Array, + crate::v8::Float32Array, + crate::v8::Float64Array, + crate::v8::Int8Array, + crate::v8::Int16Array, + crate::v8::Int32Array, + crate::v8::Uint8Array, + crate::v8::Uint16Array, + crate::v8::Uint32Array, + &str, + Number, +); + +impl Traced for Option { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + if let Some(inner) = self { + inner.trace(visitor); + } + } +} + +impl Traced for Nullable { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + if let Self::Some(inner) = self { + inner.trace(visitor); + } + } +} + +impl Traced for NonCoercible { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + self.as_ref().trace(visitor); + } +} + +impl Traced for Vec { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + for item in self { + item.trace(visitor); + } + } +} + +impl Traced for std::collections::HashMap { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + for value in self.values() { + value.trace(visitor); + } + } +} + +impl Traced for std::collections::BTreeMap { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + for value in self.values() { + value.trace(visitor); + } + } +} + +impl Traced for std::collections::HashSet { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + for item in self { + item.trace(visitor); + } + } +} + +impl Traced for std::collections::BTreeSet { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + for item in self { + item.trace(visitor); + } + } +} + +impl Traced for std::cell::Cell { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + // SAFETY: V8 GC tracing is single-threaded within an isolate and never + // re-entrant on the same object during a single GC cycle. We only read + // through the pointer. + unsafe { + (*self.as_ptr()).trace(visitor); + } + } +} + +impl Traced for std::cell::RefCell { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + // Use `try_borrow()` to avoid panicking across the FFI boundary if a + // mutable borrow is active during GC. + if let Ok(inner) = self.try_borrow() { + inner.trace(visitor); + } + } +} + +impl Traced for std::rc::Rc { + fn trace(&self, visitor: &mut crate::v8::GcVisitor) { + (**self).trace(visitor); + } +} + // ============================================================================= // ToJS trait (Rust → JavaScript) // ============================================================================= diff --git a/src/workerd/jsg/AGENTS.md b/src/workerd/jsg/AGENTS.md index 95f87c331ee..49e3bd6791d 100644 --- a/src/workerd/jsg/AGENTS.md +++ b/src/workerd/jsg/AGENTS.md @@ -70,7 +70,7 @@ class MyType: public jsg::Object { - **NEVER** unwrap `Ref` — use `V8Ref` instead - `JSG_CATCH` is NOT a true catch — cannot rethrow with `throw` - `NonCoercible` runs counter to Web IDL best practices; avoid in new APIs -- Rust JSG bindings: see `src/rust/jsg/` and `src/rust/jsg-macros/` +- Rust JSG bindings: see `src/rust/jsg/` and `src/rust/jsg-macros/`; full macro reference in `src/rust/AGENTS.md`; GC tracing (`Traced` + `GarbageCollected`) documented in `src/rust/jsg-macros/README.md` ## INVARIANTS