File
Blob: src/rust/jsg-macros/resource.rs
| 1 | // Copyright (c) 2026 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | //! Code generation for `#[jsg_resource]` on structs and impl blocks. |
| 6 | //! |
| 7 | //! - On a **struct**: emits `jsg::Type`, `jsg::ToJS`, `jsg::FromJS`, |
| 8 | //! `jsg::Traced`, and `jsg::GarbageCollected` implementations. |
| 9 | //! - On an **impl block**: emits the `jsg::Resource` trait with method, static |
| 10 | //! method, static constant, and constructor registrations. |
| 11 | |
| 12 | use proc_macro::TokenStream; |
| 13 | use quote::quote; |
| 14 | use syn::FnArg; |
| 15 | use syn::ItemImpl; |
| 16 | |
| 17 | use crate::trace::generate_trace_statements; |
| 18 | use crate::utils::error; |
| 19 | use crate::utils::extract_name_attribute; |
| 20 | use crate::utils::extract_named_fields; |
| 21 | use crate::utils::has_custom_trace_flag; |
| 22 | use crate::utils::is_attr; |
| 23 | use crate::utils::is_lock_ref; |
| 24 | use crate::utils::snake_to_camel; |
| 25 | |
| 26 | // Compile-time mirror of `jsg::PropertyKind` used to group annotated methods |
| 27 | // and emit the correct token streams. Cannot reuse the runtime type directly |
| 28 | // because proc-macro crates cannot link against CXX-bridge runtime crates. |
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| 30 | enum PropertyKind { |
| 31 | Prototype, |
| 32 | Instance, |
| 33 | Inspect, |
| 34 | } |
| 35 | |
| 36 | /// Entry point called from `lib.rs` for `#[jsg_resource]` on a struct. |
| 37 | pub fn generate_resource_struct(attr: TokenStream, input: &syn::DeriveInput) -> TokenStream { |
| 38 | // Check for `#[jsg_resource(custom_trace)]` before consuming `attr`. |
| 39 | let custom_trace = has_custom_trace_flag(&attr); |
| 40 | |
| 41 | // Clone the name before mutating `input` so we can borrow freely later. |
| 42 | let name = input.ident.clone(); |
| 43 | |
| 44 | let class_name = if attr.is_empty() { |
| 45 | name.to_string() |
| 46 | } else { |
| 47 | extract_name_attribute(attr).unwrap_or_else(|| name.to_string()) |
| 48 | }; |
| 49 | |
| 50 | let fields = match extract_named_fields(input, "jsg_resource") { |
| 51 | Ok(fields) => fields, |
| 52 | Err(err) => return err, |
| 53 | }; |
| 54 | |
| 55 | let trace_statements = generate_trace_statements(&fields); |
| 56 | let name_str = name.to_string(); |
| 57 | |
| 58 | let traced_impl = if custom_trace { |
| 59 | // `custom_trace` suppresses the generated `Traced` impl — the user will write their own. |
| 60 | quote! {} |
| 61 | } else { |
| 62 | quote! { |
| 63 | #[automatically_derived] |
| 64 | impl jsg::Traced for #name { |
| 65 | fn trace(&self, visitor: &mut jsg::GcVisitor) { |
| 66 | // Suppress unused warning when there are no traceable fields. |
| 67 | let _ = visitor; |
| 68 | #(#trace_statements)* |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | }; |
| 73 | |
| 74 | let gc_impl = quote! { |
| 75 | #[automatically_derived] |
| 76 | impl jsg::GarbageCollected for #name { |
| 77 | fn memory_name(&self) -> &'static ::std::ffi::CStr { |
| 78 | // from_bytes_with_nul on a concat!(name, "\0") literal is a |
| 79 | // compile-time constant expression — the compiler folds the |
| 80 | // unwrap and emits a direct pointer into the read-only data |
| 81 | // segment. The C++ side constructs a kj::StringPtr directly |
| 82 | // from data()+size() with no allocation. |
| 83 | ::std::ffi::CStr::from_bytes_with_nul(concat!(#name_str, "\0").as_bytes()) |
| 84 | .unwrap() |
| 85 | } |
| 86 | } |
| 87 | }; |
| 88 | |
| 89 | quote! { |
| 90 | #input |
| 91 | |
| 92 | #[automatically_derived] |
| 93 | impl jsg::Type for #name { |
| 94 | fn class_name() -> &'static str { #class_name } |
| 95 | |
| 96 | fn is_exact(value: &jsg::v8::Local<jsg::v8::Value>) -> bool { |
| 97 | value.is_object() |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | #[automatically_derived] |
| 102 | impl jsg::ToJS for #name { |
| 103 | fn to_js<'a, 'b>(self, lock: &'a mut jsg::Lock) -> jsg::v8::Local<'b, jsg::v8::Value> |
| 104 | where |
| 105 | 'b: 'a, |
| 106 | { |
| 107 | let r = jsg::Rc::new(self); |
| 108 | r.to_js(lock) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | #[automatically_derived] |
| 113 | impl jsg::FromJS for #name { |
| 114 | type ResultType = jsg::Rc<Self>; |
| 115 | |
| 116 | fn from_js( |
| 117 | lock: &mut jsg::Lock, |
| 118 | value: jsg::v8::Local<jsg::v8::Value>, |
| 119 | ) -> Result<Self::ResultType, jsg::Error> { |
| 120 | <jsg::Rc<Self> as jsg::FromJS>::from_js(lock, value) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | #traced_impl |
| 125 | #gc_impl |
| 126 | } |
| 127 | .into() |
| 128 | } |
| 129 | |
| 130 | /// Scans `impl_block` for `#[jsg_method]`-annotated functions and returns a |
| 131 | /// `Member::Method` / `Member::StaticMethod` token stream for each. |
| 132 | fn collect_method_registrations(impl_block: &ItemImpl) -> Vec<quote::__private::TokenStream> { |
| 133 | impl_block |
| 134 | .items |
| 135 | .iter() |
| 136 | .filter_map(|item| { |
| 137 | let syn::ImplItem::Fn(method) = item else { |
| 138 | return None; |
| 139 | }; |
| 140 | let attr = method.attrs.iter().find(|a| is_attr(a, "jsg_method"))?; |
| 141 | |
| 142 | let rust_method_name = &method.sig.ident; |
| 143 | let js_name = attr |
| 144 | .meta |
| 145 | .require_list() |
| 146 | .ok() |
| 147 | .map(|list| list.tokens.clone().into()) |
| 148 | .and_then(extract_name_attribute) |
| 149 | .unwrap_or_else(|| snake_to_camel(&rust_method_name.to_string())); |
| 150 | let callback_name = |
| 151 | syn::Ident::new(&format!("{rust_method_name}_callback"), rust_method_name.span()); |
| 152 | |
| 153 | let has_self = method |
| 154 | .sig |
| 155 | .inputs |
| 156 | .iter() |
| 157 | .any(|arg| matches!(arg, FnArg::Receiver(_))); |
| 158 | |
| 159 | Some(if has_self { |
| 160 | quote! { jsg::Member::Method { name: #js_name.to_owned(), callback: Self::#callback_name } } |
| 161 | } else { |
| 162 | quote! { jsg::Member::StaticMethod { name: #js_name.to_owned(), callback: Self::#callback_name } } |
| 163 | }) |
| 164 | }) |
| 165 | .collect() |
| 166 | } |
| 167 | |
| 168 | /// Scans `impl_block` for `#[jsg_static_constant]`-annotated consts and returns |
| 169 | /// a `Member::StaticConstant` token stream for each. |
| 170 | fn collect_constant_registrations(impl_block: &ItemImpl) -> Vec<quote::__private::TokenStream> { |
| 171 | impl_block |
| 172 | .items |
| 173 | .iter() |
| 174 | .filter_map(|item| { |
| 175 | let syn::ImplItem::Const(constant) = item else { |
| 176 | return None; |
| 177 | }; |
| 178 | let attr = constant |
| 179 | .attrs |
| 180 | .iter() |
| 181 | .find(|a| is_attr(a, "jsg_static_constant"))?; |
| 182 | |
| 183 | let rust_name = &constant.ident; |
| 184 | let js_name = attr |
| 185 | .meta |
| 186 | .require_list() |
| 187 | .ok() |
| 188 | .map(|list| list.tokens.clone().into()) |
| 189 | .and_then(extract_name_attribute) |
| 190 | .unwrap_or_else(|| rust_name.to_string()); |
| 191 | |
| 192 | Some(quote! { |
| 193 | jsg::Member::StaticConstant { |
| 194 | name: #js_name.to_owned(), |
| 195 | value: jsg::ConstantValue::from(Self::#rust_name), |
| 196 | } |
| 197 | }) |
| 198 | }) |
| 199 | .collect() |
| 200 | } |
| 201 | |
| 202 | /// Entry point called from `lib.rs` for `#[jsg_resource]` on an impl block. |
| 203 | pub fn generate_resource_impl(impl_block: &ItemImpl) -> TokenStream { |
| 204 | let self_ty = &impl_block.self_ty; |
| 205 | |
| 206 | if !matches!(&**self_ty, syn::Type::Path(_)) { |
| 207 | return error( |
| 208 | self_ty, |
| 209 | "#[jsg_resource] impl blocks must use a simple path type (e.g., `impl MyResource`)", |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | let method_registrations = collect_method_registrations(impl_block); |
| 214 | let property_registrations = collect_property_registrations(impl_block); |
| 215 | let constant_registrations = collect_constant_registrations(impl_block); |
| 216 | |
| 217 | let constructor_registration = generate_constructor_registration(impl_block, self_ty); |
| 218 | let constructor_vec: Vec<_> = constructor_registration.into_iter().collect(); |
| 219 | |
| 220 | quote! { |
| 221 | #impl_block |
| 222 | |
| 223 | #[automatically_derived] |
| 224 | impl jsg::Resource for #self_ty { |
| 225 | fn members() -> Vec<jsg::Member> |
| 226 | where |
| 227 | Self: Sized, |
| 228 | { |
| 229 | vec![ |
| 230 | #(#constructor_vec,)* |
| 231 | #(#method_registrations,)* |
| 232 | #(#property_registrations,)* |
| 233 | #(#constant_registrations,)* |
| 234 | ] |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | .into() |
| 239 | } |
| 240 | |
| 241 | // --------------------------------------------------------------------------- |
| 242 | // Constructor helpers |
| 243 | // --------------------------------------------------------------------------- |
| 244 | |
| 245 | /// Validates that a `#[jsg_constructor]` method has the right shape. |
| 246 | /// |
| 247 | /// Returns a `compile_error!` token stream if the method has a `self` receiver |
| 248 | /// or does not return `Self`; returns `None` if the method is valid. |
| 249 | fn validate_constructor(method: &syn::ImplItemFn) -> Option<quote::__private::TokenStream> { |
| 250 | let has_self = method |
| 251 | .sig |
| 252 | .inputs |
| 253 | .iter() |
| 254 | .any(|arg| matches!(arg, FnArg::Receiver(_))); |
| 255 | if has_self { |
| 256 | return Some(quote! { |
| 257 | compile_error!("#[jsg_constructor] must be a static method (no self receiver)"); |
| 258 | }); |
| 259 | } |
| 260 | |
| 261 | let returns_self = matches!(&method.sig.output, |
| 262 | syn::ReturnType::Type(_, ty) if matches!(&**ty, |
| 263 | syn::Type::Path(p) if p.path.is_ident("Self") |
| 264 | ) |
| 265 | ); |
| 266 | if !returns_self { |
| 267 | return Some(quote! { |
| 268 | compile_error!("#[jsg_constructor] must return Self"); |
| 269 | }); |
| 270 | } |
| 271 | |
| 272 | None |
| 273 | } |
| 274 | |
| 275 | /// Extracts constructor argument unwrap statements and argument expressions. |
| 276 | fn extract_constructor_params( |
| 277 | method: &syn::ImplItemFn, |
| 278 | ) -> ( |
| 279 | bool, |
| 280 | Vec<quote::__private::TokenStream>, |
| 281 | Vec<quote::__private::TokenStream>, |
| 282 | ) { |
| 283 | let params: Vec<_> = method |
| 284 | .sig |
| 285 | .inputs |
| 286 | .iter() |
| 287 | .filter_map(|arg| { |
| 288 | if let FnArg::Typed(pat_type) = arg { |
| 289 | Some((*pat_type.ty).clone()) |
| 290 | } else { |
| 291 | None |
| 292 | } |
| 293 | }) |
| 294 | .collect(); |
| 295 | |
| 296 | let has_lock_param = params.first().is_some_and(is_lock_ref); |
| 297 | let js_arg_offset = usize::from(has_lock_param); |
| 298 | |
| 299 | let (unwraps, arg_exprs) = params |
| 300 | .iter() |
| 301 | .enumerate() |
| 302 | .skip(js_arg_offset) |
| 303 | .map(|(i, ty)| { |
| 304 | let js_index = i - js_arg_offset; |
| 305 | let var = syn::Ident::new(&format!("arg{js_index}"), method.sig.ident.span()); |
| 306 | let unwrap = quote! { |
| 307 | let #var = match <#ty as jsg::FromJS>::from_js(&mut lock, args.get(#js_index)) { |
| 308 | Ok(v) => v, |
| 309 | Err(e) => { |
| 310 | lock.throw_exception(&e); |
| 311 | return; |
| 312 | } |
| 313 | }; |
| 314 | }; |
| 315 | (unwrap, quote! { #var }) |
| 316 | }) |
| 317 | .unzip(); |
| 318 | |
| 319 | (has_lock_param, unwraps, arg_exprs) |
| 320 | } |
| 321 | |
| 322 | /// Scans an impl block for a `#[jsg_constructor]` attribute and generates the |
| 323 | /// constructor callback registration. Returns `None` if no constructor is defined. |
| 324 | /// Validates that a `#[jsg_constructor]` method has the right shape and returns |
| 325 | /// a compile-error token stream if it doesn't. |
| 326 | fn generate_constructor_registration( |
| 327 | impl_block: &ItemImpl, |
| 328 | self_ty: &syn::Type, |
| 329 | ) -> Option<quote::__private::TokenStream> { |
| 330 | let constructors: Vec<_> = impl_block |
| 331 | .items |
| 332 | .iter() |
| 333 | .filter_map(|item| match item { |
| 334 | syn::ImplItem::Fn(m) if m.attrs.iter().any(|a| is_attr(a, "jsg_constructor")) => { |
| 335 | Some(m) |
| 336 | } |
| 337 | _ => None, |
| 338 | }) |
| 339 | .collect(); |
| 340 | |
| 341 | if constructors.len() > 1 { |
| 342 | return Some(quote! { |
| 343 | compile_error!("only one #[jsg_constructor] is allowed per impl block"); |
| 344 | }); |
| 345 | } |
| 346 | |
| 347 | constructors |
| 348 | .into_iter() |
| 349 | .map(|method| { |
| 350 | if let Some(err) = validate_constructor(method) { |
| 351 | return err; |
| 352 | } |
| 353 | |
| 354 | let rust_method_name = &method.sig.ident; |
| 355 | let callback_name = syn::Ident::new( |
| 356 | &format!("{rust_method_name}_constructor_callback"), |
| 357 | rust_method_name.span(), |
| 358 | ); |
| 359 | |
| 360 | let (has_lock_param, unwraps, arg_exprs) = extract_constructor_params(method); |
| 361 | let lock_arg = if has_lock_param { |
| 362 | quote! { &mut lock, } |
| 363 | } else { |
| 364 | quote! {} |
| 365 | }; |
| 366 | |
| 367 | quote! { |
| 368 | jsg::Member::Constructor { |
| 369 | callback: { |
| 370 | unsafe extern "C" fn #callback_name( |
| 371 | info: *mut jsg::v8::ffi::FunctionCallbackInfo, |
| 372 | ) { |
| 373 | let mut lock = unsafe { jsg::Lock::from_args(info) }; |
| 374 | jsg::catch_panic(&mut lock, || { |
| 375 | // SAFETY: info is a valid V8 FunctionCallbackInfo from the constructor call. |
| 376 | let mut args = unsafe { jsg::v8::FunctionCallbackInfo::from_ffi(info) }; |
| 377 | let mut lock = unsafe { jsg::Lock::from_args(info) }; |
| 378 | |
| 379 | #(#unwraps)* |
| 380 | |
| 381 | let resource = #self_ty::#rust_method_name(#lock_arg #(#arg_exprs),*); |
| 382 | let rc = jsg::Rc::new(resource); |
| 383 | rc.attach_to_this(&mut args); |
| 384 | }); |
| 385 | } |
| 386 | #callback_name |
| 387 | }, |
| 388 | } |
| 389 | } |
| 390 | }) |
| 391 | .next() |
| 392 | } |
| 393 | |
| 394 | // --------------------------------------------------------------------------- |
| 395 | // Property helpers |
| 396 | // --------------------------------------------------------------------------- |
| 397 | |
| 398 | /// Emits one `Member::Property { .. }` token stream for a single property group, |
| 399 | /// or an `Err` compile-error stream if the group has no getter. |
| 400 | fn emit_property_group( |
| 401 | js_name: &str, |
| 402 | kind: PropertyKind, |
| 403 | getter: Option<syn::Ident>, |
| 404 | setter: Option<syn::Ident>, |
| 405 | ) -> Result<quote::__private::TokenStream, quote::__private::TokenStream> { |
| 406 | let Some(getter_name) = getter else { |
| 407 | return Err(quote! { |
| 408 | compile_error!(concat!("no getter found for property \"", #js_name, "\"")) |
| 409 | }); |
| 410 | }; |
| 411 | |
| 412 | let getter_cb = syn::Ident::new(&format!("{getter_name}_callback"), getter_name.span()); |
| 413 | let kind_tokens = match kind { |
| 414 | PropertyKind::Prototype => quote! { jsg::PropertyKind::Prototype }, |
| 415 | PropertyKind::Instance => quote! { jsg::PropertyKind::Instance }, |
| 416 | PropertyKind::Inspect => quote! { jsg::PropertyKind::Inspect }, |
| 417 | }; |
| 418 | let setter_tokens = if let Some(setter_name) = setter { |
| 419 | let setter_cb = syn::Ident::new(&format!("{setter_name}_callback"), setter_name.span()); |
| 420 | quote! { Some(Self::#setter_cb) } |
| 421 | } else { |
| 422 | quote! { None } |
| 423 | }; |
| 424 | |
| 425 | Ok(quote! { |
| 426 | jsg::Member::Property { |
| 427 | name: #js_name.to_owned(), |
| 428 | kind: #kind_tokens, |
| 429 | getter_callback: Self::#getter_cb, |
| 430 | setter_callback: #setter_tokens, |
| 431 | } |
| 432 | }) |
| 433 | } |
| 434 | |
| 435 | /// Parses the argument list of `#[jsg_property([placement,] [name = "..."] [, readonly])]`. |
| 436 | /// |
| 437 | /// `placement` is optional; when omitted it defaults to `Prototype`. |
| 438 | /// Returns `(PropertyKind, Option<js_name_override>, is_readonly)`. |
| 439 | fn parse_jsg_property_args( |
| 440 | tokens: TokenStream, |
| 441 | ) -> Result<(PropertyKind, Option<String>, bool), quote::__private::TokenStream> { |
| 442 | use syn::parse::Parser as _; |
| 443 | let metas = syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated |
| 444 | .parse(tokens) |
| 445 | .map_err(|e| e.to_compile_error())?; |
| 446 | |
| 447 | let mut kind: Option<PropertyKind> = None; |
| 448 | let mut name: Option<String> = None; |
| 449 | let mut readonly = false; |
| 450 | |
| 451 | for meta in &metas { |
| 452 | match meta { |
| 453 | syn::Meta::Path(p) if p.is_ident("instance") => { |
| 454 | if kind.is_some() { |
| 455 | return Err(syn::Error::new_spanned( |
| 456 | p, |
| 457 | "conflicting placement: specify either `instance` or `prototype`, not both", |
| 458 | ) |
| 459 | .to_compile_error()); |
| 460 | } |
| 461 | kind = Some(PropertyKind::Instance); |
| 462 | } |
| 463 | syn::Meta::Path(p) if p.is_ident("prototype") => { |
| 464 | if kind.is_some() { |
| 465 | return Err(syn::Error::new_spanned( |
| 466 | p, |
| 467 | "conflicting placement: specify either `instance` or `prototype`, not both", |
| 468 | ) |
| 469 | .to_compile_error()); |
| 470 | } |
| 471 | kind = Some(PropertyKind::Prototype); |
| 472 | } |
| 473 | syn::Meta::Path(p) if p.is_ident("readonly") => { |
| 474 | readonly = true; |
| 475 | } |
| 476 | syn::Meta::NameValue(nv) if nv.path.is_ident("name") => { |
| 477 | if let syn::Expr::Lit(syn::ExprLit { |
| 478 | lit: syn::Lit::Str(s), |
| 479 | .. |
| 480 | }) = &nv.value |
| 481 | { |
| 482 | name = Some(s.value()); |
| 483 | } else { |
| 484 | return Err(syn::Error::new_spanned( |
| 485 | &nv.value, |
| 486 | "expected a string literal for `name`", |
| 487 | ) |
| 488 | .to_compile_error()); |
| 489 | } |
| 490 | } |
| 491 | _ => { |
| 492 | return Err(syn::Error::new_spanned( |
| 493 | meta, |
| 494 | "unknown argument; expected `instance`, `prototype`, `readonly`, or `name = \"...\"`", |
| 495 | ) |
| 496 | .to_compile_error()); |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // Default to `Prototype` when no explicit placement is given. |
| 502 | let kind = kind.unwrap_or(PropertyKind::Prototype); |
| 503 | |
| 504 | Ok((kind, name, readonly)) |
| 505 | } |
| 506 | |
| 507 | struct PropMethod { |
| 508 | rust_name: syn::Ident, |
| 509 | is_setter: bool, |
| 510 | is_readonly: bool, |
| 511 | } |
| 512 | |
| 513 | /// Ordered list of property groups, preserving source-code declaration order. |
| 514 | /// Each entry is `((js_name, kind), methods)`. |
| 515 | type PropGroups = Vec<((String, PropertyKind), Vec<PropMethod>)>; |
| 516 | |
| 517 | /// Derive the JS property name from a Rust method name: strip `get_`/`set_` prefix then |
| 518 | /// convert `snake_case` -> `camelCase`. |
| 519 | fn derive_js_name(rust_name: &str) -> String { |
| 520 | let stripped = rust_name |
| 521 | .strip_prefix("get_") |
| 522 | .or_else(|| rust_name.strip_prefix("set_")) |
| 523 | .unwrap_or(rust_name); |
| 524 | snake_to_camel(stripped) |
| 525 | } |
| 526 | |
| 527 | /// Find the group for `key` in `groups`, or append a new empty one and return it. |
| 528 | /// Preserves insertion order so that property registration matches source-code order. |
| 529 | fn prop_groups_find_or_insert( |
| 530 | groups: &mut PropGroups, |
| 531 | key: (String, PropertyKind), |
| 532 | ) -> &mut Vec<PropMethod> { |
| 533 | if let Some(pos) = groups.iter().position(|(k, _)| k == &key) { |
| 534 | return &mut groups[pos].1; |
| 535 | } |
| 536 | groups.push((key, Vec::new())); |
| 537 | &mut groups.last_mut().expect("just pushed").1 |
| 538 | } |
| 539 | |
| 540 | /// Phase 1: scan `impl_block` for `#[jsg_property]` / `#[jsg_inspect_property]` annotations |
| 541 | /// and group the annotated methods by `(js_name, PropertyKind)`. |
| 542 | fn scan_property_annotations( |
| 543 | impl_block: &ItemImpl, |
| 544 | ) -> Result<PropGroups, quote::__private::TokenStream> { |
| 545 | let mut groups: PropGroups = Vec::new(); |
| 546 | |
| 547 | for item in &impl_block.items { |
| 548 | let syn::ImplItem::Fn(method) = item else { |
| 549 | continue; |
| 550 | }; |
| 551 | let rust_method_name = method.sig.ident.clone(); |
| 552 | let rust_name_str = rust_method_name.to_string(); |
| 553 | let is_setter = rust_name_str.starts_with("set_"); |
| 554 | |
| 555 | if let Some(attr) = method.attrs.iter().find(|a| is_attr(a, "jsg_property")) { |
| 556 | if !rust_name_str.starts_with("get_") && !rust_name_str.starts_with("set_") { |
| 557 | return Err(syn::Error::new( |
| 558 | rust_method_name.span(), |
| 559 | "#[jsg_property] methods must be named with a `get_` prefix (getter) \ |
| 560 | or `set_` prefix (setter)", |
| 561 | ) |
| 562 | .to_compile_error()); |
| 563 | } |
| 564 | |
| 565 | let tokens: TokenStream = attr |
| 566 | .meta |
| 567 | .require_list() |
| 568 | .map(|list| list.tokens.clone().into()) |
| 569 | .unwrap_or_default(); |
| 570 | let (kind, js_name_opt, is_readonly) = parse_jsg_property_args(tokens)?; |
| 571 | let js_name = js_name_opt.unwrap_or_else(|| derive_js_name(&rust_name_str)); |
| 572 | prop_groups_find_or_insert(&mut groups, (js_name, kind)).push(PropMethod { |
| 573 | rust_name: rust_method_name, |
| 574 | is_setter, |
| 575 | is_readonly, |
| 576 | }); |
| 577 | continue; |
| 578 | } |
| 579 | |
| 580 | if let Some(attr) = method |
| 581 | .attrs |
| 582 | .iter() |
| 583 | .find(|a| is_attr(a, "jsg_inspect_property")) |
| 584 | { |
| 585 | let attr_tokens: Option<TokenStream> = attr |
| 586 | .meta |
| 587 | .require_list() |
| 588 | .ok() |
| 589 | .map(|list| list.tokens.clone().into()); |
| 590 | let js_name = attr_tokens |
| 591 | .and_then(extract_name_attribute) |
| 592 | .unwrap_or_else(|| derive_js_name(&rust_name_str)); |
| 593 | prop_groups_find_or_insert(&mut groups, (js_name, PropertyKind::Inspect)).push( |
| 594 | PropMethod { |
| 595 | rust_name: rust_method_name, |
| 596 | is_setter, |
| 597 | is_readonly: false, |
| 598 | }, |
| 599 | ); |
| 600 | } |
| 601 | } |
| 602 | Ok(groups) |
| 603 | } |
| 604 | |
| 605 | /// Phase 2 (per group): validate constraints and emit one `Member::Property`. |
| 606 | fn validate_and_emit_property( |
| 607 | js_name: &str, |
| 608 | kind: PropertyKind, |
| 609 | methods: Vec<PropMethod>, |
| 610 | ) -> Result<quote::__private::TokenStream, quote::__private::TokenStream> { |
| 611 | let has_readonly_getter = methods.iter().any(|m| m.is_readonly && !m.is_setter); |
| 612 | for m in &methods { |
| 613 | if m.is_readonly && m.is_setter { |
| 614 | return Err(syn::Error::new( |
| 615 | m.rust_name.span(), |
| 616 | "`readonly` attribute cannot be used on a setter method", |
| 617 | ) |
| 618 | .to_compile_error()); |
| 619 | } |
| 620 | } |
| 621 | let setters: Vec<_> = methods.iter().filter(|m| m.is_setter).collect(); |
| 622 | if has_readonly_getter && !setters.is_empty() { |
| 623 | return Err(syn::Error::new( |
| 624 | setters[0].rust_name.span(), |
| 625 | "read-only property cannot have a setter; remove the setter or drop the `readonly` attribute", |
| 626 | ) |
| 627 | .to_compile_error()); |
| 628 | } |
| 629 | if kind == PropertyKind::Inspect { |
| 630 | for m in &methods { |
| 631 | if m.is_setter { |
| 632 | return Err(syn::Error::new( |
| 633 | m.rust_name.span(), |
| 634 | "#[jsg_inspect_property] methods must be getters; inspect properties are always read-only", |
| 635 | ) |
| 636 | .to_compile_error()); |
| 637 | } |
| 638 | } |
| 639 | } |
| 640 | |
| 641 | let mut getter: Option<syn::Ident> = None; |
| 642 | let mut setter: Option<syn::Ident> = None; |
| 643 | for m in methods { |
| 644 | if m.is_setter { |
| 645 | if setter.replace(m.rust_name).is_some() { |
| 646 | return Err( |
| 647 | quote! { compile_error!(concat!("duplicate setter for property \"", #js_name, "\"")) }, |
| 648 | ); |
| 649 | } |
| 650 | } else if getter.replace(m.rust_name).is_some() { |
| 651 | return Err( |
| 652 | quote! { compile_error!(concat!("duplicate getter for property \"", #js_name, "\"")) }, |
| 653 | ); |
| 654 | } |
| 655 | } |
| 656 | emit_property_group(js_name, kind, getter, setter) |
| 657 | } |
| 658 | |
| 659 | /// Scans an impl block for `#[jsg_property]` and `#[jsg_inspect_property]` annotations |
| 660 | /// and returns a `Member::Property` token stream for each property group. |
| 661 | fn collect_property_registrations(impl_block: &ItemImpl) -> Vec<quote::__private::TokenStream> { |
| 662 | let groups = match scan_property_annotations(impl_block) { |
| 663 | Ok(g) => g, |
| 664 | Err(e) => return vec![e], |
| 665 | }; |
| 666 | let mut registrations = Vec::new(); |
| 667 | for ((js_name, kind), methods) in groups { |
| 668 | match validate_and_emit_property(&js_name, kind, methods) { |
| 669 | Ok(ts) => registrations.push(ts), |
| 670 | Err(ts) => { |
| 671 | registrations.push(ts); |
| 672 | return registrations; |
| 673 | } |
| 674 | } |
| 675 | } |
| 676 | registrations |
| 677 | } |
| 678 | |
| 679 | #[cfg(test)] |
| 680 | mod tests { |
| 681 | use syn::parse_quote; |
| 682 | |
| 683 | use super::*; |
| 684 | |
| 685 | #[test] |
| 686 | fn validate_constructor_valid() { |
| 687 | // A valid constructor: static (no self), returns Self. |
| 688 | let method: syn::ImplItemFn = parse_quote! { |
| 689 | fn constructor(name: String) -> Self { todo!() } |
| 690 | }; |
| 691 | assert!(validate_constructor(&method).is_none()); |
| 692 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn validate_constructor_rejects_self_receiver() { |
| 696 | // Instance method — must not have &self. |
| 697 | let method: syn::ImplItemFn = parse_quote! { |
| 698 | fn constructor(&self) -> Self { todo!() } |
| 699 | }; |
| 700 | assert!(validate_constructor(&method).is_some()); |
| 701 | } |
| 702 | |
| 703 | #[test] |
| 704 | fn validate_constructor_rejects_non_self_return() { |
| 705 | // Returns String, not Self. |
| 706 | let method: syn::ImplItemFn = parse_quote! { |
| 707 | fn constructor() -> String { todo!() } |
| 708 | }; |
| 709 | assert!(validate_constructor(&method).is_some()); |
| 710 | } |
| 711 | |
| 712 | #[test] |
| 713 | fn extract_constructor_params_no_lock() { |
| 714 | // Plain constructor — no Lock param, two JS args. |
| 715 | let method: syn::ImplItemFn = parse_quote! { |
| 716 | fn constructor(name: String, value: u32) -> Self { todo!() } |
| 717 | }; |
| 718 | let (has_lock, unwraps, arg_exprs) = extract_constructor_params(&method); |
| 719 | assert!(!has_lock); |
| 720 | assert_eq!(unwraps.len(), 2); |
| 721 | assert_eq!(arg_exprs.len(), 2); |
| 722 | } |
| 723 | |
| 724 | #[test] |
| 725 | fn extract_constructor_params_with_lock() { |
| 726 | // First param is `&mut jsg::Lock` — skipped from JS args. |
| 727 | let method: syn::ImplItemFn = parse_quote! { |
| 728 | fn constructor(lock: &mut jsg::Lock, name: String) -> Self { todo!() } |
| 729 | }; |
| 730 | let (has_lock, unwraps, arg_exprs) = extract_constructor_params(&method); |
| 731 | assert!(has_lock); |
| 732 | // Only one JS arg (name); lock is not counted. |
| 733 | assert_eq!(unwraps.len(), 1); |
| 734 | assert_eq!(arg_exprs.len(), 1); |
| 735 | } |
| 736 | |
| 737 | #[test] |
| 738 | fn extract_constructor_params_no_args() { |
| 739 | let method: syn::ImplItemFn = parse_quote! { |
| 740 | fn constructor() -> Self { todo!() } |
| 741 | }; |
| 742 | let (has_lock, unwraps, arg_exprs) = extract_constructor_params(&method); |
| 743 | assert!(!has_lock); |
| 744 | assert!(unwraps.is_empty()); |
| 745 | assert!(arg_exprs.is_empty()); |
| 746 | } |
| 747 | } |