myko_macros/lib.rs
1use proc_macro::TokenStream;
2use proc_macro2::Span;
3use quote::quote;
4use syn::{
5 Token,
6 parse::{Parse, ParseStream},
7 parse_macro_input,
8 punctuated::Punctuated,
9 spanned::Spanned,
10};
11
12mod command;
13mod item;
14mod message_events;
15mod partial_matches;
16mod query;
17mod relationship;
18mod report;
19mod saga;
20mod setter;
21mod view;
22
23/// Returns whether we are compiling inside the myko crate itself.
24pub(crate) fn is_myko_crate() -> bool {
25 std::env::var("CARGO_PKG_NAME")
26 .map(|name| name == "myko")
27 .unwrap_or(false)
28}
29
30/// Returns the path to use for `myko` depending on the current crate.
31/// When compiling myko itself, returns `crate`; otherwise returns `myko`.
32pub(crate) fn myko_path() -> syn::Path {
33 if is_myko_crate() {
34 syn::Path::from(syn::Ident::new("crate", Span::call_site()))
35 } else {
36 syn::Path::from(syn::Ident::new("myko", Span::call_site()))
37 }
38}
39
40/// Context for generating serde/partially derive paths in macros.
41/// When inside myko, uses direct crate paths. When outside, uses re-exports.
42pub(crate) struct DeriveCtx {
43 /// Path to myko (either `crate` or `myko`)
44 pub krate: syn::Path,
45 /// Path for serde derives (either `serde` or `myko::serde`)
46 pub serde_path: proc_macro2::TokenStream,
47 /// String value for #[serde(crate = "...")] — None when inside myko
48 pub serde_crate_attr: Option<String>,
49 /// Path for partially derives (either `partially` or `myko::partially`)
50 pub partially_path: proc_macro2::TokenStream,
51 /// String value for #[partially(crate = "...")] — None when inside myko
52 pub partially_crate_attr: Option<String>,
53}
54
55impl DeriveCtx {
56 pub fn new() -> Self {
57 let krate = myko_path();
58 if is_myko_crate() {
59 Self {
60 krate,
61 serde_path: quote!(serde),
62 serde_crate_attr: None,
63 partially_path: quote!(partially),
64 partially_crate_attr: None,
65 }
66 } else {
67 let serde_crate_str = "myko::serde".to_string();
68 let partially_crate_str = "myko::partially".to_string();
69 Self {
70 krate,
71 serde_path: quote!(myko::serde),
72 serde_crate_attr: Some(serde_crate_str),
73 partially_path: quote!(myko::partially),
74 partially_crate_attr: Some(partially_crate_str),
75 }
76 }
77 }
78
79 /// Generate #[serde(crate = "...", ...rest)] or just #[serde(...rest)]
80 pub fn serde_attr(&self, rest: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
81 match &self.serde_crate_attr {
82 Some(crate_str) => {
83 if rest.is_empty() {
84 quote!(#[serde(crate = #crate_str)])
85 } else {
86 quote!(#[serde(crate = #crate_str, #rest)])
87 }
88 }
89 None => {
90 if rest.is_empty() {
91 quote!()
92 } else {
93 quote!(#[serde(#rest)])
94 }
95 }
96 }
97 }
98}
99
100pub(crate) fn take_manual_cache_key_attr(input_struct: &mut syn::ItemStruct) -> bool {
101 let mut found = take_marker_attr(input_struct, "myko_manual_cache_key");
102 input_struct.attrs.retain(|attr| {
103 let is_doc_marker = attr.path().is_ident("doc")
104 && attr
105 .meta
106 .require_name_value()
107 .ok()
108 .and_then(|nv| match &nv.value {
109 syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
110 syn::Lit::Str(s) => Some(s.value() == "__myko_manual_cache_key"),
111 _ => None,
112 },
113 _ => None,
114 })
115 .unwrap_or(false);
116 found |= is_doc_marker;
117 !is_doc_marker
118 });
119 found
120}
121
122pub(crate) fn take_non_hash_cache_key_attr(input_struct: &mut syn::ItemStruct) -> bool {
123 let mut found = take_marker_attr(input_struct, "myko_non_hash_cache_key");
124 input_struct.attrs.retain(|attr| {
125 let is_doc_marker = attr.path().is_ident("doc")
126 && attr
127 .meta
128 .require_name_value()
129 .ok()
130 .and_then(|nv| match &nv.value {
131 syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
132 syn::Lit::Str(s) => Some(s.value() == "__myko_non_hash_cache_key"),
133 _ => None,
134 },
135 _ => None,
136 })
137 .unwrap_or(false);
138 found |= is_doc_marker;
139 !is_doc_marker
140 });
141 found
142}
143
144fn take_marker_attr(input_struct: &mut syn::ItemStruct, attr_name: &str) -> bool {
145 let mut found = false;
146 input_struct.attrs.retain(|attr| {
147 let matches = attr.path().is_ident(attr_name);
148 found |= matches;
149 !matches
150 });
151 found
152}
153
154/// Noop replacement for `ts_rs::TS` derive — emits no trait impls and
155/// declares the `ts` helper attribute so user-written `#[ts(...)]` in
156/// entity source doesn't error out when `ts_rs::TS` is absent.
157///
158/// `myko::TS` routes to this derive when the consuming crate has
159/// `ts-export` off. When on, `myko::TS` resolves to `ts_rs::TS` instead
160/// and full TS impls are generated.
161#[proc_macro_derive(TsNoop, attributes(ts))]
162pub fn ts_noop_derive(_input: TokenStream) -> TokenStream {
163 TokenStream::new()
164}
165
166/// No-op retained for call-site compatibility.
167///
168/// `#[myko_item]`/`#[myko_subtype]` now always emit `#[derive(myko::TS)]`
169/// (which resolves to the no-op `TsNoop` derive unless myko's own
170/// `ts-export` feature is on). Because that derive always claims the `ts`
171/// helper-attribute namespace, user-written `#[ts(...)]` attrs are valid
172/// as-is and no longer need wrapping in a consumer-side `cfg_attr`.
173pub(crate) fn gate_ts_attrs(_attrs: &mut [syn::Attribute]) {}
174
175#[proc_macro_attribute]
176pub fn myko_manual_cache_key(_attr: TokenStream, input: TokenStream) -> TokenStream {
177 let item = parse_macro_input!(input as syn::ItemStruct);
178 quote! {
179 #[doc = "__myko_manual_cache_key"]
180 #item
181 }
182 .into()
183}
184
185#[proc_macro_attribute]
186pub fn myko_non_hash_cache_key(_attr: TokenStream, input: TokenStream) -> TokenStream {
187 let item = parse_macro_input!(input as syn::ItemStruct);
188 quote! {
189 #[doc = "__myko_non_hash_cache_key"]
190 #item
191 }
192 .into()
193}
194
195#[proc_macro_derive(PartialMatches)]
196pub fn derive_partial_matches(input: TokenStream) -> TokenStream {
197 let input = parse_macro_input!(input as syn::DeriveInput);
198 partial_matches::derive_partial_matches_impl(input).into()
199}
200
201/// Marks a struct as a Myko entity, generating queries, reports, commands, and supporting types.
202///
203/// # Struct Modifications
204///
205/// Adds two required fields automatically:
206/// - `pub id: Arc<str>` - Unique identifier for the entity
207///
208/// # Derives
209///
210/// On the entity:
211/// - `Partial`, `PartialEq`, `Clone`, `Serialize`, `Deserialize`, `Debug`, `TS`
212/// - `Default` (only if `#[ensure_for]` attributes are present)
213///
214/// On the generated `Partial{Entity}`:
215/// - `Clone`, `Serialize`, `Deserialize`, `Debug`, `Default`, `PartialMatches`, `TS`
216///
217/// # Generated Queries
218///
219/// | Query | Description |
220/// |-------|-------------|
221/// | `GetAll{Entity}s` | Returns all entities of this type |
222/// | `Get{Entity}sByIds { ids: Vec<Arc<str>> }` | Returns entities matching the given IDs |
223/// | `Get{Entity}sByQuery(Partial{Entity})` | Returns entities matching partial field values |
224///
225/// # Generated Reports
226///
227/// | Report | Output Type | Description |
228/// |--------|-------------|-------------|
229/// | `Get{Entity}ById { id: Arc<str> }` | `Option<{Entity}>` | Returns a single entity by ID |
230/// | `CountAll{Entity}s` | `{Entity}Count` | Returns total count of all entities |
231/// | `Count{Entity}s(Partial{Entity})` | `{Entity}Count` | Returns count matching partial filter |
232///
233/// # Generated Commands
234///
235/// | Command | Result Type | Description |
236/// |---------|-------------|-------------|
237/// | `Delete{Entity} { id: Arc<str> }` | `Delete{Entity}Result` | Deletes a single entity |
238/// | `Delete{Entity}s { ids: Vec<Arc<str>> }` | `Delete{Entity}sResult` | Deletes multiple entities |
239///
240/// # Generated Types
241///
242/// | Type | Description |
243/// |------|-------------|
244/// | `{Entity}Id` | Entity-specific ID wrapper over `Arc<str>` (TypeScript: `string`) |
245/// | `Partial{Entity}` | Partial version with all fields optional, for filtering |
246/// | `{Entity}Count` | Count result with `count: usize` field |
247/// | `Delete{Entity}Result` | Single delete result with `deleted: bool` field |
248/// | `Delete{Entity}sResult` | Bulk delete result with `deleted_count: usize` field |
249///
250/// # Field Attributes
251///
252/// ## `#[myko_rename]`
253/// Generates a `Rename{Entity} { id, name }` command that updates the annotated field.
254/// The field is typically named `name` but can be any `String` field.
255///
256/// ```ignore
257/// #[myko_item]
258/// pub struct Target {
259/// #[myko_rename]
260/// pub name: String,
261/// }
262/// // Generates: RenameTarget { id: Arc<str>, name: Arc<str> }
263/// ```
264///
265/// ## `#[myko_setter]` / `#[myko_setter("CustomName")]`
266/// Generates a setter command for the field. Without an argument, generates
267/// `Set{Entity}{Field}`. With a string argument, uses that as the command name.
268///
269/// ```ignore
270/// #[myko_item]
271/// pub struct Scene {
272/// #[myko_setter]
273/// pub is_active: bool,
274/// #[myko_setter("ToggleSceneVisibility")]
275/// pub visible: bool,
276/// }
277/// // Generates: SetSceneIsActive { id, is_active }
278/// // Generates: ToggleSceneVisibility { id, visible }
279/// ```
280///
281/// ## `#[belongs_to(ParentEntity)]`
282/// Declares a parent-child relationship. When the parent is deleted, the child
283/// is cascade-deleted. The field should contain the parent's ID.
284///
285/// ```ignore
286/// #[myko_item]
287/// pub struct Binding {
288/// #[belongs_to(Scene)]
289/// pub scene_id: String,
290/// }
291/// // When Scene is deleted, all Bindings with that scene_id are deleted
292/// ```
293///
294/// ## `#[owns_many(ChildEntity)]`
295/// Declares ownership of child entities via an ID list. When the parent is deleted,
296/// children are deleted. When a child is deleted, its ID is removed from the list.
297///
298/// ```ignore
299/// #[myko_item]
300/// pub struct Scene {
301/// #[owns_many(BindingNode)]
302/// pub node_ids: Vec<String>,
303/// }
304/// ```
305///
306/// ## `#[ensure_for(DependencyEntity)]`
307/// Auto-creates one entity instance per dependency. Multiple `ensure_for` attributes
308/// on different fields create a Cartesian product.
309///
310/// ```ignore
311/// #[myko_item]
312/// pub struct BundleStatus {
313/// #[ensure_for(Session)]
314/// pub session_id: String,
315/// #[ensure_for(Bundle)]
316/// pub bundle_id: String,
317/// }
318/// // Creates one BundleStatus per Session×Bundle combination
319/// ```
320///
321/// ## `#[myko_client_id]`
322/// Server auto-populates this field with the WebSocket client ID that sent the event.
323///
324/// ```ignore
325/// #[myko_item]
326/// pub struct Instance {
327/// #[myko_client_id]
328/// pub client_id: Option<String>,
329/// }
330/// ```
331///
332/// ## `#[searchable]`
333/// Marks a field for full-text search indexing.
334///
335/// ```ignore
336/// #[myko_item]
337/// pub struct Target {
338/// #[searchable]
339/// pub name: String,
340/// #[searchable]
341/// pub description: String,
342/// pub internal_id: String, // not searchable
343/// }
344/// ```
345///
346/// ## `#[default_value(expr)]`
347/// Sets a default value for the field when auto-creating via `ensure_for`.
348///
349/// # Requirements
350///
351/// All manually-added fields must implement `Clone`, `Serialize`, and `Deserialize`.
352#[proc_macro_attribute]
353pub fn myko_item(attr: TokenStream, input: TokenStream) -> TokenStream {
354 let args = parse_macro_input!(attr as item::ItemArgs);
355 let input = parse_macro_input!(input as syn::ItemStruct);
356 item::myko_item_impl(args, input).into()
357}
358
359#[proc_macro_attribute]
360pub fn myko_query(attr: TokenStream, input: TokenStream) -> TokenStream {
361 let query_item_type = parse_macro_input!(attr as syn::Path);
362 let input = parse_macro_input!(input as syn::ItemStruct);
363 query::myko_query_impl(query_item_type, input).into()
364}
365
366/// Defines a reactive view query.
367///
368/// Preferred stacked syntax:
369/// ```ignore
370/// #[myko_view]
371/// #[view(output = TargetTreeView, root = Target, root_out = target)]
372/// #[tree(parent_param = parent_target_id, parent_field = parent_targets, include_offline_param = include_offline)]
373/// #[source(Target, key = id)]
374/// #[source(TargetStatus, key = target_id)]
375/// #[source(Action, key = id)]
376/// #[source(Emitter, key = id)]
377/// #[join_one(Target.id == TargetStatus.target_id, out = is_online, online = Status::Online)]
378/// #[join_many(Target.id == Action.target_id, out = actions)]
379/// #[join_many(Target.id == Emitter.target_id, out = emitters)]
380/// pub struct GetTargetTreeByParentFiltered {
381/// pub parent_target_id: Option<Arc<str>>,
382/// pub include_offline: bool,
383/// }
384/// ```
385///
386/// Query-style declaration syntax:
387/// `#[myko_view(ViewItemType)]`
388/// and then implement `myko::prelude::ViewHandler` for the params type with:
389/// `fn build_cell(ctx: ViewBuildCellCtx<Self>) -> FilteredViewCellMap`.
390#[proc_macro_attribute]
391pub fn myko_view(attr: TokenStream, input: TokenStream) -> TokenStream {
392 let input = parse_macro_input!(input as syn::ItemStruct);
393 if attr.is_empty() {
394 return syn::Error::new(
395 input.ident.span(),
396 "#[myko_view] requires an item type: #[myko_view(ViewItemType)]",
397 )
398 .to_compile_error()
399 .into();
400 }
401 let args = parse_macro_input!(attr as view::ViewArgs);
402 view::myko_view_impl(args, input).into()
403}
404
405/// Marks a struct as a typed view item (id/hash should already be present).
406///
407/// Adds serde/TS derives, TS export registration, and implements:
408/// - `WithId` (from `id`)
409/// - `AnyItem`
410/// - `Eventable`
411#[proc_macro_attribute]
412pub fn myko_view_item(_attr: TokenStream, input: TokenStream) -> TokenStream {
413 let input = parse_macro_input!(input as syn::ItemStruct);
414 view::myko_view_item_impl(input).into()
415}
416
417/// Generates a reactive report that can depend on queries and other reports.
418///
419/// # Usage
420///
421/// ```ignore
422/// #[myko_report(Vec<Target>)]
423/// pub struct GetParentTargets {
424/// pub target_id: String,
425/// pub depth: u32,
426/// }
427///
428/// // You must implement the compute method:
429/// impl GetParentTargets {
430/// pub fn compute(
431/// report: std::sync::Arc<Self>,
432/// ctx: myko::prelude::ReportContext,
433/// ) -> std::pin::Pin<Box<dyn futures::Stream<Item = Vec<Target>> + Send>> {
434/// // Use ctx.query() and ctx.report() for reactive dependencies
435/// Box::pin(async_stream::stream! {
436/// // ... your reactive logic
437/// })
438/// }
439/// }
440/// ```
441#[proc_macro_attribute]
442pub fn myko_report(attr: TokenStream, input: TokenStream) -> TokenStream {
443 let report_output_type = parse_macro_input!(attr as syn::Path);
444 let input = parse_macro_input!(input as syn::ItemStruct);
445 report::myko_report_impl(report_output_type, input).into()
446}
447
448/// Generates a command with handler struct and registration.
449///
450/// # Usage
451///
452/// ```ignore
453/// // With return type:
454/// #[myko_command(CreateMachineResult)]
455/// pub struct CreateMachine {
456/// pub name: String,
457/// }
458///
459/// // Without return type (returns ()):
460/// #[myko_command]
461/// pub struct DeleteMachine {
462/// pub machine_id: String,
463/// }
464///
465/// // User must implement the handler execute method:
466/// impl CreateMachineHandler {
467/// async fn execute(
468/// cmd: CreateMachine,
469/// ctx: CommandContext,
470/// ) -> Result<CreateMachineResult, CommandError> {
471/// // Handler logic
472/// }
473/// }
474/// ```
475#[proc_macro_attribute]
476pub fn myko_command(attr: TokenStream, input: TokenStream) -> TokenStream {
477 let options = if attr.is_empty() {
478 command::CommandOptions {
479 result_type: None,
480 custom_serialize: false,
481 }
482 } else {
483 parse_macro_input!(attr as CommandArgs).into()
484 };
485 let input = parse_macro_input!(input as syn::ItemStruct);
486 command::myko_command_impl(options, input).into()
487}
488
489struct CommandArgs {
490 result_type: Option<syn::Path>,
491 custom_serialize: bool,
492}
493
494impl From<CommandArgs> for command::CommandOptions {
495 fn from(value: CommandArgs) -> Self {
496 Self {
497 result_type: value.result_type,
498 custom_serialize: value.custom_serialize,
499 }
500 }
501}
502
503impl Parse for CommandArgs {
504 fn parse(input: ParseStream) -> syn::Result<Self> {
505 let args = Punctuated::<syn::Path, Token![,]>::parse_terminated(input)?;
506 let mut result_type = None;
507 let mut custom_serialize = false;
508
509 for path in args {
510 if path.is_ident("custom_serialize") {
511 if custom_serialize {
512 return Err(syn::Error::new(
513 path.span(),
514 "duplicate custom_serialize flag",
515 ));
516 }
517 custom_serialize = true;
518 continue;
519 }
520
521 if result_type.is_some() {
522 return Err(syn::Error::new(
523 path.span(),
524 "expected at most one result type",
525 ));
526 }
527
528 result_type = Some(path);
529 }
530
531 Ok(Self {
532 result_type,
533 custom_serialize,
534 })
535 }
536}
537
538/// Derive macro that extracts serde rename values from enum variants
539/// and generates MessageEventRegistration inventory submissions.
540///
541/// # Usage
542/// ```ignore
543/// #[derive(MessageEvents)]
544/// #[serde(tag = "event", content = "data")]
545/// pub enum MykoMessage<Commands> {
546/// #[serde(rename = "ws:m:query")]
547/// Query(WrappedQuery),
548/// // ...
549/// }
550/// ```
551#[proc_macro_derive(MessageEvents)]
552pub fn derive_message_events(input: TokenStream) -> TokenStream {
553 let input = parse_macro_input!(input as syn::DeriveInput);
554 message_events::derive_message_events_impl(input).into()
555}
556
557/// Generates a saga with registration for runtime discovery.
558///
559/// # Usage
560///
561/// ```ignore
562/// #[myko_saga]
563/// pub struct CleanupSaga;
564///
565/// impl myko::saga::SagaHandler for CleanupSaga {
566/// type EventItem = myko::entities::client::Client;
567/// type Command = HandleClientDisconnected;
568/// const EVENT_TYPE: myko::event::MEventType = myko::event::MEventType::DEL;
569///
570/// fn handle(
571/// item: Self::EventItem,
572/// event: myko::event::MEvent,
573/// ctx: std::sync::Arc<myko::saga::SagaContext>,
574/// ) -> Option<Self::Command> {
575/// // Saga logic here
576/// None
577/// }
578/// }
579/// ```
580#[proc_macro_attribute]
581pub fn myko_saga(attr: TokenStream, input: TokenStream) -> TokenStream {
582 let input = parse_macro_input!(input as syn::ItemStruct);
583 saga::myko_saga_impl(attr.into(), input).into()
584}
585
586/// Adds standard derives and registers for TypeScript export.
587///
588/// Use this for report output types to reduce boilerplate.
589///
590/// # Usage
591///
592/// ```ignore
593/// #[myko_report_output]
594/// pub struct ServerStatsOutput {
595/// pub server: Option<Server>,
596/// pub client_count: usize,
597/// }
598///
599/// // Expands to:
600/// #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, myko::TS)]
601/// #[serde(rename_all = "camelCase")]
602/// pub struct ServerStatsOutput { ... }
603/// myko::register_ts_export!(ServerStatsOutput);
604/// ```
605#[proc_macro_attribute]
606pub fn myko_report_output(_attr: TokenStream, input: TokenStream) -> TokenStream {
607 let mut input = parse_macro_input!(input as syn::ItemStruct);
608 let name = &input.ident;
609 let ctx = DeriveCtx::new();
610 let krate = &ctx.krate;
611 let serde_path = &ctx.serde_path;
612 let serde_rename_attr = ctx.serde_attr(quote!(rename_all = "camelCase"));
613
614 gate_ts_attrs(&mut input.attrs);
615 for field in input.fields.iter_mut() {
616 gate_ts_attrs(&mut field.attrs);
617 }
618
619 // ToValue is implemented via blanket impl for all Serialize types
620 let expanded = quote! {
621 #[derive(Debug, Clone, PartialEq, #serde_path::Serialize, #serde_path::Deserialize, #krate::TS)]
622 #serde_rename_attr
623 #input
624
625 #krate::register_ts_export!(#name);
626 };
627
628 expanded.into()
629}
630
631/// Declare a data subtype used by myko entities (field types, payloads,
632/// enum variants carried on commands/queries/reports/views). Bundles the
633/// standard derives + serde camelCase rename + conditional TS export +
634/// `register_ts_export!` so subtype definitions don't repeat 3–4 lines of
635/// boilerplate each.
636///
637/// Default derives: `Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize`.
638/// Always added: `#[cfg_attr(feature = "ts-export", derive(myko::TS))]`,
639/// `#[cfg_attr(feature = "ts-export", ts(export))]`, and
640/// `#[serde(rename_all = "camelCase")]`. Emits a `register_ts_export!`
641/// call after the item so typegen picks it up when the feature is on.
642///
643/// Extra derives (e.g. `Default`, `Eq`, `Hash`, `Copy`) can be requested
644/// via `derive(...)` — they're appended to the default list.
645///
646/// # Usage
647///
648/// ```ignore
649/// #[myko_subtype]
650/// pub struct UserData {
651/// pub id: UserId,
652/// }
653///
654/// #[myko_subtype(derive(Default, Eq))]
655/// pub enum NetworkEventType {
656/// Added,
657/// Removed,
658/// }
659///
660/// #[myko_subtype(derive(Default, Eq, Hash))]
661/// pub struct DeviceShareKey {
662/// pub device_id: Arc<str>,
663/// pub user_id: Arc<str>,
664/// }
665/// ```
666#[proc_macro_attribute]
667pub fn myko_subtype(attr: TokenStream, input: TokenStream) -> TokenStream {
668 let extra_derives = parse_macro_input!(attr as SubtypeArgs).extra_derives;
669 let item: syn::Item = parse_macro_input!(input as syn::Item);
670 myko_subtype_expand(extra_derives, item).into()
671}
672
673struct SubtypeArgs {
674 extra_derives: Vec<syn::Path>,
675}
676
677impl syn::parse::Parse for SubtypeArgs {
678 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
679 if input.is_empty() {
680 return Ok(Self {
681 extra_derives: Vec::new(),
682 });
683 }
684 // Accept `derive(Foo, Bar, Baz)` as the single invocation.
685 let meta: syn::Meta = input.parse()?;
686 if let syn::Meta::List(list) = meta {
687 if list.path.is_ident("derive") {
688 let punct: syn::punctuated::Punctuated<syn::Path, syn::Token![,]> =
689 list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
690 return Ok(Self {
691 extra_derives: punct.into_iter().collect(),
692 });
693 }
694 return Err(syn::Error::new_spanned(
695 &list.path,
696 "expected `derive(...)` argument",
697 ));
698 }
699 Err(syn::Error::new_spanned(
700 meta,
701 "expected `derive(...)` argument",
702 ))
703 }
704}
705
706fn myko_subtype_expand(
707 extra_derives: Vec<syn::Path>,
708 mut item: syn::Item,
709) -> proc_macro2::TokenStream {
710 let ctx = DeriveCtx::new();
711 let krate = &ctx.krate;
712 let serde_path = &ctx.serde_path;
713
714 // Common setup: gate user-written `#[ts(...)]` attrs, extract name for
715 // the `register_ts_export!` call. Also normalize visibility expectations
716 // to either struct or enum — other shapes aren't meaningful as subtypes.
717 //
718 // `is_struct` controls whether we default to `#[serde(rename_all = "camelCase")]`.
719 // For structs, Rust field names are snake_case and wire is camelCase → we need
720 // the rename. For enums, Rust variants are PascalCase (matching the wire form
721 // used historically in this codebase) so auto-renaming to camelCase would
722 // silently change the serialized representation and break existing stored
723 // data. Enums that want a non-default casing must supply their own
724 // `#[serde(rename_all = ...)]`.
725 let (name, has_rename_all, is_struct) = match &mut item {
726 syn::Item::Struct(s) => {
727 gate_ts_attrs(&mut s.attrs);
728 for field in s.fields.iter_mut() {
729 gate_ts_attrs(&mut field.attrs);
730 }
731 (s.ident.clone(), attrs_have_serde_rename_all(&s.attrs), true)
732 }
733 syn::Item::Enum(e) => {
734 gate_ts_attrs(&mut e.attrs);
735 for variant in e.variants.iter_mut() {
736 gate_ts_attrs(&mut variant.attrs);
737 for field in variant.fields.iter_mut() {
738 gate_ts_attrs(&mut field.attrs);
739 }
740 }
741 (
742 e.ident.clone(),
743 attrs_have_serde_rename_all(&e.attrs),
744 false,
745 )
746 }
747 other => {
748 return syn::Error::new_spanned(
749 other,
750 "#[myko_subtype] only supports `struct` and `enum` items",
751 )
752 .to_compile_error();
753 }
754 };
755
756 let extra_derive_tokens = if extra_derives.is_empty() {
757 quote!()
758 } else {
759 quote!(, #(#extra_derives),*)
760 };
761
762 // Only emit the default camelCase rename on structs when the user hasn't
763 // already supplied one.
764 let serde_rename_attr = if is_struct && !has_rename_all {
765 ctx.serde_attr(quote!(rename_all = "camelCase"))
766 } else {
767 quote!()
768 };
769
770 // `myko::TS` is the no-op `TsNoop` derive unless myko's own `ts-export`
771 // feature is on, so emit it (and the `ts(export)` attr it claims)
772 // unconditionally — no consumer-side feature gate. `register_ts_export!`
773 // is itself a no-op unless myko has ts-export on.
774 quote! {
775 #[derive(Debug, Clone, PartialEq, #serde_path::Serialize, #serde_path::Deserialize, #krate::TS #extra_derive_tokens)]
776 #[ts(export)]
777 #serde_rename_attr
778 #item
779
780 #krate::register_ts_export!(#name);
781 }
782}
783
784/// Returns true if any attribute in the slice is `#[serde(... rename_all = "...")]`.
785/// Used by `myko_subtype` to skip its default camelCase rename when the user
786/// already wrote a different one (e.g. snake_case for enum variants).
787fn attrs_have_serde_rename_all(attrs: &[syn::Attribute]) -> bool {
788 use quote::ToTokens;
789 attrs.iter().any(|a| {
790 a.path().is_ident("serde") && a.to_token_stream().to_string().contains("rename_all")
791 })
792}