Skip to main content

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}