Skip to main content

myko_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::Span;
3use quote::{ToTokens, quote};
4use syn::{
5    Token,
6    ext::IdentExt,
7    parse::{Parse, ParseStream},
8    parse_macro_input,
9    punctuated::Punctuated,
10    spanned::Spanned,
11};
12
13mod command;
14mod graph;
15mod item;
16mod message_events;
17mod query;
18mod relationship;
19mod report;
20mod saga;
21mod setter;
22mod view;
23
24/// Declare an open, downstream-defined Myko entity category.
25#[proc_macro_attribute]
26pub fn myko_category(_attr: TokenStream, input: TokenStream) -> TokenStream {
27    graph::category(&parse_macro_input!(input as syn::ItemStruct)).into()
28}
29
30/// Add an item type to one or more entity categories.
31#[proc_macro_attribute]
32pub fn myko_in(attr: TokenStream, input: TokenStream) -> TokenStream {
33    let categories =
34        parse_macro_input!(attr with Punctuated::<syn::Path, Token![,]>::parse_terminated);
35    graph::category_membership(&categories, &parse_macro_input!(input as syn::ItemStruct)).into()
36}
37
38/// Register a [`GraphEdge`] implementation without changing its item schema.
39#[proc_macro_attribute]
40pub fn myko_edge(_attr: TokenStream, input: TokenStream) -> TokenStream {
41    graph::edge(parse_macro_input!(input as syn::ItemImpl)).into()
42}
43
44/// Returns whether we are compiling inside the myko crate itself.
45pub(crate) fn is_myko_crate() -> bool {
46    std::env::var("CARGO_PKG_NAME").is_ok_and(|name| name == "myko")
47}
48
49/// Returns the path to use for `myko` depending on the current crate.
50/// When compiling myko itself, returns `crate`; otherwise returns `myko`.
51pub(crate) fn myko_path() -> syn::Path {
52    if is_myko_crate() {
53        syn::Path::from(syn::Ident::new("crate", Span::call_site()))
54    } else {
55        syn::Path::from(syn::Ident::new("myko", Span::call_site()))
56    }
57}
58
59/// Context for generating serde derive paths in macros.
60/// When inside myko, uses direct crate paths. When outside, uses re-exports.
61pub(crate) struct DeriveCtx {
62    /// Path to myko (either `crate` or `myko`)
63    pub krate: syn::Path,
64    /// Path for serde derives (either `serde` or `myko::serde`)
65    pub serde_path: proc_macro2::TokenStream,
66    /// String value for #[serde(crate = "...")] — None when inside myko
67    pub serde_crate_attr: Option<String>,
68    /// String value for ts-rs's `crate` override.
69    pub ts_crate: String,
70}
71
72impl DeriveCtx {
73    pub fn new() -> Self {
74        let krate = myko_path();
75        if is_myko_crate() {
76            Self {
77                krate,
78                serde_path: quote!(serde),
79                serde_crate_attr: None,
80                ts_crate: "crate::ts_rs".to_string(),
81            }
82        } else {
83            let serde_crate_str = "myko::serde".to_string();
84            Self {
85                krate,
86                serde_path: quote!(myko::serde),
87                serde_crate_attr: Some(serde_crate_str),
88                ts_crate: "myko::ts_rs".to_string(),
89            }
90        }
91    }
92
93    /// Generate #[serde(crate = "...", ...rest)] or just #[serde(...rest)]
94    pub fn serde_attr(&self, rest: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
95        self.serde_crate_attr.as_ref().map_or_else(
96            || {
97                if rest.is_empty() {
98                    quote!()
99                } else {
100                    quote!(#[serde(#rest)])
101                }
102            },
103            |crate_str| {
104                if rest.is_empty() {
105                    quote!(#[serde(crate = #crate_str)])
106                } else {
107                    quote!(#[serde(crate = #crate_str, #rest)])
108                }
109            },
110        )
111    }
112}
113
114pub(crate) fn take_manual_cache_key_attr(input_struct: &mut syn::ItemStruct) -> bool {
115    let mut found = take_marker_attr(input_struct, "myko_manual_cache_key");
116    input_struct.attrs.retain(|attr| {
117        let is_doc_marker = attr.path().is_ident("doc")
118            && attr
119                .meta
120                .require_name_value()
121                .ok()
122                .and_then(|nv| match &nv.value {
123                    syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
124                        syn::Lit::Str(s) => Some(s.value() == "__myko_manual_cache_key"),
125                        _ => None,
126                    },
127                    _ => None,
128                })
129                .unwrap_or(false);
130        found |= is_doc_marker;
131        !is_doc_marker
132    });
133    found
134}
135
136pub(crate) fn take_non_hash_cache_key_attr(input_struct: &mut syn::ItemStruct) -> bool {
137    let mut found = take_marker_attr(input_struct, "myko_non_hash_cache_key");
138    input_struct.attrs.retain(|attr| {
139        let is_doc_marker = attr.path().is_ident("doc")
140            && attr
141                .meta
142                .require_name_value()
143                .ok()
144                .and_then(|nv| match &nv.value {
145                    syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
146                        syn::Lit::Str(s) => Some(s.value() == "__myko_non_hash_cache_key"),
147                        _ => None,
148                    },
149                    _ => None,
150                })
151                .unwrap_or(false);
152        found |= is_doc_marker;
153        !is_doc_marker
154    });
155    found
156}
157
158fn take_marker_attr(input_struct: &mut syn::ItemStruct, attr_name: &str) -> bool {
159    let mut found = false;
160    input_struct.attrs.retain(|attr| {
161        let matches = attr.path().is_ident(attr_name);
162        found |= matches;
163        !matches
164    });
165    found
166}
167
168/// Noop replacement for `ts_rs::TS` derive — emits no trait impls and
169/// declares the `ts` helper attribute so user-written `#[ts(...)]` in
170/// entity source doesn't error out when `ts_rs::TS` is absent.
171///
172/// `myko::TS` routes to this derive when the consuming crate has
173/// `codegen-ts` off. When on, `myko::TS` resolves to `ts_rs::TS` instead
174/// and full TS impls are generated.
175#[proc_macro_derive(TsNoop, attributes(ts))]
176pub fn ts_noop_derive(_input: TokenStream) -> TokenStream {
177    TokenStream::new()
178}
179
180/// No-op retained for call-site compatibility.
181///
182/// `#[myko_item]`/`#[myko_subtype]` now always emit `#[derive(myko::TS)]`
183/// (which resolves to the no-op `TsNoop` derive unless myko's own
184/// `codegen-ts` feature is on). Because that derive always claims the `ts`
185/// helper-attribute namespace, user-written `#[ts(...)]` attrs are valid
186/// as-is and no longer need wrapping in a consumer-side `cfg_attr`.
187pub(crate) fn gate_ts_attrs(attrs: &mut [syn::Attribute]) {
188    for attr in attrs.iter_mut() {
189        if !attr.path().is_ident("myko") {
190            continue;
191        }
192        let Ok(parsed) = attr.parse_args::<ExportOverride>() else {
193            continue;
194        };
195        let mut args = Vec::new();
196        if let Some(value) = parsed.type_override {
197            args.push(quote!(type = #value));
198        }
199        if let Some(rename) = parsed.rename {
200            args.push(quote!(rename = #rename));
201        }
202        if parsed.skip {
203            args.push(quote!(skip));
204        }
205        if parsed.nullable {
206            args.push(quote!(optional = nullable));
207        } else if parsed.optional {
208            args.push(quote!(optional));
209        }
210        *attr = syn::parse_quote!(#[ts(#(#args),*)]);
211    }
212}
213
214#[derive(Default)]
215struct ExportOverride {
216    type_override: Option<syn::LitStr>,
217    rename: Option<syn::LitStr>,
218    optional: bool,
219    nullable: bool,
220    skip: bool,
221}
222
223impl Parse for ExportOverride {
224    fn parse(input: ParseStream) -> syn::Result<Self> {
225        let export = syn::Ident::parse_any(input)?;
226        if export != "export" {
227            return Err(syn::Error::new_spanned(export, "expected `export(...)`"));
228        }
229        let content;
230        syn::parenthesized!(content in input);
231        let mut result = Self::default();
232        while !content.is_empty() {
233            let key = syn::Ident::parse_any(&content)?;
234            if key == "type" {
235                content.parse::<Token![=]>()?;
236                result.type_override = Some(content.parse()?);
237            } else if key == "rename" {
238                content.parse::<Token![=]>()?;
239                result.rename = Some(content.parse()?);
240            } else if key == "optional" {
241                result.optional = true;
242            } else if key == "nullable" {
243                result.nullable = true;
244            } else if key == "skip" {
245                result.skip = true;
246            } else {
247                return Err(syn::Error::new_spanned(
248                    key,
249                    "expected `type`, `rename`, `optional`, `nullable`, or `skip`",
250                ));
251            }
252            if content.is_empty() {
253                break;
254            }
255            content.parse::<Token![,]>()?;
256        }
257        Ok(result)
258    }
259}
260
261/// Extract a struct's doc comment (`/// ...` lines, which desugar to
262/// `#[doc = "..."]` attrs) as one joined string, or `None` if there isn't
263/// one. Call after `take_manual_cache_key_attr`/`take_non_hash_cache_key_attr`
264/// have already stripped their internal marker doc attrs, so only genuine
265/// user-written doc comments remain.
266pub(crate) fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
267    let lines: Vec<String> = attrs
268        .iter()
269        .filter_map(|attr| {
270            if !attr.path().is_ident("doc") {
271                return None;
272            }
273            let syn::Meta::NameValue(nv) = &attr.meta else {
274                return None;
275            };
276            let syn::Expr::Lit(syn::ExprLit {
277                lit: syn::Lit::Str(s),
278                ..
279            }) = &nv.value
280            else {
281                return None;
282            };
283            let line = s.value();
284            let line = line.trim();
285            (!line.is_empty()).then(|| line.to_string())
286        })
287        .collect();
288    (!lines.is_empty()).then(|| lines.join(" "))
289}
290
291/// Build a `&[#krate::reflection::OperationArgField]` token stream
292/// describing `fields`'s named members — captured directly from the struct
293/// definition at macro-expansion time (field name, its Rust type as
294/// written, and whether it's `Option<...>`) rather than re-derived from
295/// generated ts-rs output. See `myko::reflection` for why.
296pub(crate) fn field_metadata_tokens(
297    fields: &syn::Fields,
298    krate: &syn::Path,
299) -> proc_macro2::TokenStream {
300    let entries: Vec<_> = match fields {
301        syn::Fields::Named(named) => named
302            .named
303            .iter()
304            .filter_map(|f| {
305                let name = f.ident.as_ref()?.to_string();
306                let ty = &f.ty;
307                let rust_type = quote!(#ty).to_string();
308                let optional = is_option_type(ty);
309                Some(quote! {
310                    #krate::reflection::OperationArgField {
311                        name: #name,
312                        rust_type: #rust_type,
313                        optional: #optional,
314                    }
315                })
316            })
317            .collect(),
318        _ => Vec::new(),
319    };
320    quote! { &[ #(#entries),* ] }
321}
322
323pub(crate) fn operation_metadata_tokens(
324    input: &syn::ItemStruct,
325    krate: &syn::Path,
326) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
327    let description = extract_doc_comment(&input.attrs);
328    let description = description
329        .as_ref()
330        .map_or_else(|| quote!(None), |value| quote!(Some(#value)));
331    (description, field_metadata_tokens(&input.fields, krate))
332}
333
334pub(crate) fn gate_field_ts_attrs(fields: &mut syn::Fields) {
335    for field in fields {
336        prepare_typegen_field(field);
337    }
338}
339
340/// Apply language-backend metadata owned by Myko. Optional Rust fields are
341/// optional and nullable in generated bindings by default, so downstream
342/// entity definitions need no duplicate representation annotation.
343pub(crate) fn prepare_typegen_field(field: &mut syn::Field) {
344    gate_ts_attrs(&mut field.attrs);
345    if !is_option_type(&field.ty) {
346        return;
347    }
348
349    let has_explicit_policy = field
350        .attrs
351        .iter()
352        .filter(|attr| attr.path().is_ident("ts"))
353        .any(|attr| {
354            let tokens = attr.meta.to_token_stream().to_string();
355            tokens.contains("optional") || tokens.contains("skip")
356        });
357    if !has_explicit_policy {
358        field
359            .attrs
360            .push(syn::parse_quote!(#[ts(optional = nullable)]));
361    }
362}
363
364fn is_option_type(ty: &syn::Type) -> bool {
365    let syn::Type::Path(type_path) = ty else {
366        return false;
367    };
368    type_path
369        .path
370        .segments
371        .last()
372        .is_some_and(|seg| seg.ident == "Option")
373}
374
375#[proc_macro_attribute]
376pub fn myko_manual_cache_key(_attr: TokenStream, input: TokenStream) -> TokenStream {
377    let item = parse_macro_input!(input as syn::ItemStruct);
378    quote! {
379        #[doc = "__myko_manual_cache_key"]
380        #item
381    }
382    .into()
383}
384
385#[proc_macro_attribute]
386pub fn myko_non_hash_cache_key(_attr: TokenStream, input: TokenStream) -> TokenStream {
387    let item = parse_macro_input!(input as syn::ItemStruct);
388    quote! {
389        #[doc = "__myko_non_hash_cache_key"]
390        #item
391    }
392    .into()
393}
394
395/// Marks a struct as a Myko entity, generating queries, reports, commands, and supporting types.
396///
397/// # Struct Modifications
398///
399/// Adds two required fields automatically:
400/// - `pub id: Arc<str>` - Unique identifier for the entity
401///
402/// # Derives
403///
404/// On the entity:
405/// - `PartialEq`, `Clone`, `Serialize`, `Deserialize`, `Debug`, `TS`
406/// - `Default` (only if `#[ensure_for]` attributes are present)
407///
408/// On the generated `{Entity}Query`:
409/// - `Clone`, `Default`, `PartialEq`, `Debug`, `Serialize`, `Deserialize`, `TS`
410///
411/// # Generated Queries
412///
413/// | Query | Description |
414/// |-------|-------------|
415/// | `GetAll{Entity}s` | Returns all entities of this type |
416/// | `Get{Entity}sByIds { ids: Vec<Arc<str>> }` | Returns entities matching the given IDs |
417/// | `Get{Entity}sByQuery({Entity}Query)` | Returns entities matching the query — every field is `Option<<FieldType as Filterable>::Filter>` (`Eq`/`In`/`Range`/`Contains` depending on the field's type), not a flat value |
418///
419/// # Generated Reports
420///
421/// | Report | Output Type | Description |
422/// |--------|-------------|-------------|
423/// | `Get{Entity}ById { id: Arc<str> }` | `Option<{Entity}>` | Returns a single entity by ID |
424/// | `CountAll{Entity}s` | `{Entity}Count` | Returns total count of all entities |
425/// | `Count{Entity}s({Entity}Query)` | `{Entity}Count` | Returns count matching the query |
426///
427/// # Generated Commands
428///
429/// | Command | Result Type | Description |
430/// |---------|-------------|-------------|
431/// | `Delete{Entity} { id: Arc<str> }` | `Delete{Entity}Result` | Deletes a single entity |
432/// | `Delete{Entity}s { ids: Vec<Arc<str>> }` | `Delete{Entity}sResult` | Deletes multiple entities |
433///
434/// # Generated Types
435///
436/// | Type | Description |
437/// |------|-------------|
438/// | `{Entity}Id` | Entity-specific ID wrapper over `Arc<str>` (TypeScript: `string`) |
439/// | `{Entity}Query` | Per-field filter struct, for `Get{Entity}sByQuery`/`Count{Entity}s`/`ctx.query_live(...)` |
440/// | `{Entity}Count` | Count result with `count: usize` field |
441/// | `Delete{Entity}Result` | Single delete result with `deleted: bool` field |
442/// | `Delete{Entity}sResult` | Bulk delete result with `deleted_count: usize` field |
443///
444/// # Field Attributes
445///
446/// ## `#[myko_rename]`
447/// Generates a `Rename{Entity} { id, name }` command that updates the annotated field.
448/// The field is typically named `name` but can be any `String` field.
449///
450/// ```ignore
451/// #[myko_item]
452/// pub struct Target {
453///     #[myko_rename]
454///     pub name: String,
455/// }
456/// // Generates: RenameTarget { id: Arc<str>, name: Arc<str> }
457/// ```
458///
459/// ## `#[myko_setter]` / `#[myko_setter("CustomName")]`
460/// Generates a setter command for the field. Without an argument, generates
461/// `Set{Entity}{Field}`. With a string argument, uses that as the command name.
462///
463/// ```ignore
464/// #[myko_item]
465/// pub struct Scene {
466///     #[myko_setter]
467///     pub is_active: bool,
468///     #[myko_setter("ToggleSceneVisibility")]
469///     pub visible: bool,
470/// }
471/// // Generates: SetSceneIsActive { id, is_active }
472/// // Generates: ToggleSceneVisibility { id, visible }
473/// ```
474///
475/// ## `#[belongs_to(ParentEntity)]`
476/// Declares a parent-child relationship. When the parent is deleted, the child
477/// is cascade-deleted. The field should contain the parent's ID.
478///
479/// ```ignore
480/// #[myko_item]
481/// pub struct Binding {
482///     #[belongs_to(Scene)]
483///     pub scene_id: String,
484/// }
485/// // When Scene is deleted, all Bindings with that scene_id are deleted
486/// ```
487///
488/// ## `#[owns_many(ChildEntity)]`
489/// Declares ownership of child entities via an ID list. When the parent is deleted,
490/// children are deleted. When a child is deleted, its ID is removed from the list.
491///
492/// ```ignore
493/// #[myko_item]
494/// pub struct Scene {
495///     #[owns_many(BindingNode)]
496///     pub node_ids: Vec<String>,
497/// }
498/// ```
499///
500/// ## `#[ensure_for(DependencyEntity)]`
501/// Auto-creates one entity instance per dependency. Multiple `ensure_for` attributes
502/// on different fields create a Cartesian product.
503///
504/// ```ignore
505/// #[myko_item]
506/// pub struct BundleStatus {
507///     #[ensure_for(Session)]
508///     pub session_id: String,
509///     #[ensure_for(Bundle)]
510///     pub bundle_id: String,
511/// }
512/// // Creates one BundleStatus per Session×Bundle combination
513/// ```
514///
515/// ## `#[myko_client_id]`
516/// Server auto-populates this field with the WebSocket client ID that sent the event.
517///
518/// ```ignore
519/// #[myko_item]
520/// pub struct Instance {
521///     #[myko_client_id]
522///     pub client_id: Option<String>,
523/// }
524/// ```
525///
526/// ## `#[searchable]`
527/// Marks a field for full-text search indexing.
528///
529/// ```ignore
530/// #[myko_item]
531/// pub struct Target {
532///     #[searchable]
533///     pub name: String,
534///     #[searchable]
535///     pub description: String,
536///     pub internal_id: String,  // not searchable
537/// }
538/// ```
539///
540/// ## `#[default_value(expr)]`
541/// Sets a default value for the field when auto-creating via `ensure_for`.
542///
543/// # Requirements
544///
545/// All manually-added fields must implement `Clone`, `Serialize`, and `Deserialize`.
546#[proc_macro_attribute]
547pub fn myko_item(attr: TokenStream, input: TokenStream) -> TokenStream {
548    let args = parse_macro_input!(attr as item::ItemArgs);
549    let input = parse_macro_input!(input as syn::ItemStruct);
550    item::myko_item_impl(&args, input).into()
551}
552
553#[proc_macro_attribute]
554pub fn myko_query(attr: TokenStream, input: TokenStream) -> TokenStream {
555    let query_item_type = parse_macro_input!(attr as syn::Path);
556    let input = parse_macro_input!(input as syn::ItemStruct);
557    query::myko_query_impl(&query_item_type, input).into()
558}
559
560/// Defines a reactive view query.
561///
562/// Preferred stacked syntax:
563/// ```ignore
564/// #[myko_view]
565/// #[view(output = TargetTreeView, root = Target, root_out = target)]
566/// #[tree(parent_param = parent_target_id, parent_field = parent_targets, include_offline_param = include_offline)]
567/// #[source(Target, key = id)]
568/// #[source(TargetStatus, key = target_id)]
569/// #[source(Action, key = id)]
570/// #[source(Emitter, key = id)]
571/// #[join_one(Target.id == TargetStatus.target_id, out = is_online, online = Status::Online)]
572/// #[join_many(Target.id == Action.target_id, out = actions)]
573/// #[join_many(Target.id == Emitter.target_id, out = emitters)]
574/// pub struct GetTargetTreeByParentFiltered {
575///     pub parent_target_id: Option<Arc<str>>,
576///     pub include_offline: bool,
577/// }
578/// ```
579///
580/// Query-style declaration syntax:
581/// `#[myko_view(ViewItemType)]`
582/// and then implement `myko::prelude::ViewHandler` for the params type with:
583/// `fn build_cell(ctx: ViewBuildArgs<Self>) -> FilteredViewCellMap`.
584#[proc_macro_attribute]
585pub fn myko_view(attr: TokenStream, input: TokenStream) -> TokenStream {
586    let input = parse_macro_input!(input as syn::ItemStruct);
587    if attr.is_empty() {
588        return syn::Error::new(
589            input.ident.span(),
590            "#[myko_view] requires an item type: #[myko_view(ViewItemType)]",
591        )
592        .to_compile_error()
593        .into();
594    }
595    let args = parse_macro_input!(attr as view::ViewArgs);
596    view::myko_view_impl(args, input).into()
597}
598
599/// Marks a struct as a typed view item (id/hash should already be present).
600///
601/// Adds serde/TS derives, TS export registration, and implements:
602/// - `WithId` (from `id`)
603/// - `AnyItem`
604/// - `Eventable`
605#[proc_macro_attribute]
606pub fn myko_view_item(_attr: TokenStream, input: TokenStream) -> TokenStream {
607    let input = parse_macro_input!(input as syn::ItemStruct);
608    view::myko_view_item_impl(input).into()
609}
610
611/// Generates a reactive report that can depend on queries and other reports.
612///
613/// # Usage
614///
615/// ```ignore
616/// #[myko_report(Vec<Target>)]
617/// pub struct GetParentTargets {
618///     pub target_id: String,
619///     pub depth: u32,
620/// }
621///
622/// // You must implement the compute method:
623/// impl GetParentTargets {
624///     pub fn compute(
625///         report: std::sync::Arc<Self>,
626///         ctx: myko::prelude::ReportContext,
627///     ) -> std::pin::Pin<Box<dyn futures::Stream<Item = Vec<Target>> + Send>> {
628///         // Use ctx.query() and ctx.report() for reactive dependencies
629///         Box::pin(async_stream::stream! {
630///             // ... your reactive logic
631///         })
632///     }
633/// }
634/// ```
635#[proc_macro_attribute]
636pub fn myko_report(attr: TokenStream, input: TokenStream) -> TokenStream {
637    let report_output_type = parse_macro_input!(attr as syn::Path);
638    let input = parse_macro_input!(input as syn::ItemStruct);
639    report::myko_report_impl(&report_output_type, input).into()
640}
641
642/// Generates a command with handler struct and registration.
643///
644/// # Usage
645///
646/// ```ignore
647/// // With return type:
648/// #[myko_command(CreateMachineResult)]
649/// pub struct CreateMachine {
650///     pub name: String,
651/// }
652///
653/// // Without return type (returns ()):
654/// #[myko_command]
655/// pub struct DeleteMachine {
656///     pub machine_id: String,
657/// }
658///
659/// // User must implement the handler execute method:
660/// impl CreateMachineHandler {
661///     async fn execute(
662///         cmd: CreateMachine,
663///         ctx: CommandContext,
664///     ) -> Result<CreateMachineResult, CommandError> {
665///         // Handler logic
666///     }
667/// }
668/// ```
669#[proc_macro_attribute]
670pub fn myko_command(attr: TokenStream, input: TokenStream) -> TokenStream {
671    let options = if attr.is_empty() {
672        command::CommandOptions {
673            result_type: None,
674            custom_serialize: false,
675        }
676    } else {
677        parse_macro_input!(attr as CommandArgs).into()
678    };
679    let input = parse_macro_input!(input as syn::ItemStruct);
680    command::myko_command_impl(options, input).into()
681}
682
683struct CommandArgs {
684    result_type: Option<syn::Path>,
685    custom_serialize: bool,
686}
687
688impl From<CommandArgs> for command::CommandOptions {
689    fn from(value: CommandArgs) -> Self {
690        Self {
691            result_type: value.result_type,
692            custom_serialize: value.custom_serialize,
693        }
694    }
695}
696
697impl Parse for CommandArgs {
698    fn parse(input: ParseStream) -> syn::Result<Self> {
699        let args = Punctuated::<syn::Path, Token![,]>::parse_terminated(input)?;
700        let mut result_type = None;
701        let mut custom_serialize = false;
702
703        for path in args {
704            if path.is_ident("custom_serialize") {
705                if custom_serialize {
706                    return Err(syn::Error::new(
707                        path.span(),
708                        "duplicate custom_serialize flag",
709                    ));
710                }
711                custom_serialize = true;
712                continue;
713            }
714
715            if result_type.is_some() {
716                return Err(syn::Error::new(
717                    path.span(),
718                    "expected at most one result type",
719                ));
720            }
721
722            result_type = Some(path);
723        }
724
725        Ok(Self {
726            result_type,
727            custom_serialize,
728        })
729    }
730}
731
732/// Derive macro that extracts serde rename values from enum variants
733/// and generates `MessageEventRegistration` inventory submissions.
734///
735/// # Usage
736/// ```ignore
737/// #[derive(MessageEvents)]
738/// #[serde(tag = "event", content = "data")]
739/// pub enum MykoMessage<Commands> {
740///     #[serde(rename = "ws:m:query")]
741///     Query(WrappedQuery),
742///     // ...
743/// }
744/// ```
745#[proc_macro_derive(MessageEvents)]
746pub fn derive_message_events(input: TokenStream) -> TokenStream {
747    let input = parse_macro_input!(input as syn::DeriveInput);
748    message_events::derive_message_events_impl(&input).into()
749}
750
751/// Generates a saga with registration for runtime discovery.
752///
753/// # Usage
754///
755/// ```ignore
756/// #[myko_saga]
757/// pub struct CleanupSaga;
758///
759/// impl myko::saga::SagaHandler for CleanupSaga {
760///     type EventItem = myko::entities::client::Client;
761///     type Command = HandleClientDisconnected;
762///     const EVENT_TYPE: myko::event::MEventType = myko::event::MEventType::DEL;
763///
764///     fn handle(
765///         item: Self::EventItem,
766///         event: myko::event::MEvent,
767///         ctx: std::sync::Arc<myko::saga::SagaContext>,
768///     ) -> Option<Self::Command> {
769///         // Saga logic here
770///         None
771///     }
772/// }
773/// ```
774#[proc_macro_attribute]
775pub fn myko_saga(attr: TokenStream, input: TokenStream) -> TokenStream {
776    let input = parse_macro_input!(input as syn::ItemStruct);
777    let attr = attr.into();
778    saga::myko_saga_impl(&attr, &input).into()
779}
780
781/// Adds standard derives and registers for TypeScript export.
782///
783/// Use this for report output types to reduce boilerplate.
784///
785/// # Usage
786///
787/// ```ignore
788/// #[myko_report_output]
789/// pub struct ServerStatsOutput {
790///     pub server: Option<Server>,
791///     pub client_count: usize,
792/// }
793///
794/// // Expands to:
795/// #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, myko::TS)]
796/// #[serde(rename_all = "camelCase")]
797/// pub struct ServerStatsOutput { ... }
798/// myko::register_typegen_type!(ServerStatsOutput);
799/// ```
800#[proc_macro_attribute]
801pub fn myko_report_output(_attr: TokenStream, input: TokenStream) -> TokenStream {
802    let mut input = parse_macro_input!(input as syn::ItemStruct);
803    let name = &input.ident;
804    let ctx = DeriveCtx::new();
805    let krate = &ctx.krate;
806    let serde_path = &ctx.serde_path;
807    let serde_rename_attr = ctx.serde_attr(&quote!(rename_all = "camelCase"));
808
809    gate_ts_attrs(&mut input.attrs);
810    for field in &mut input.fields {
811        prepare_typegen_field(field);
812    }
813    let equal_fields = input
814        .fields
815        .iter()
816        .enumerate()
817        .map(|(index, field)| {
818            let member = field.ident.clone().map_or_else(
819                || syn::Member::Unnamed(syn::Index::from(index)),
820                syn::Member::Named,
821            );
822            quote! { self.#member == other.#member }
823        })
824        .reduce(|acc, term| quote! { (#acc) && (#term) })
825        .unwrap_or_else(|| quote! { true });
826
827    // ToValue is implemented via blanket impl for all Serialize types
828    let expanded = quote! {
829        #[derive(Debug, Clone, #serde_path::Serialize, #serde_path::Deserialize, #krate::TS)]
830        #[ts(crate = "myko::ts_rs")]
831        #serde_rename_attr
832        #input
833
834        impl PartialEq for #name {
835            fn eq(&self, other: &Self) -> bool { #equal_fields }
836        }
837
838        #krate::register_typegen_type!(#name);
839    };
840
841    expanded.into()
842}
843
844/// Declare a data subtype used by myko entities (field types, payloads,
845/// enum variants carried on commands/queries/reports/views).
846///
847/// Bundles the
848/// standard derives + serde camelCase rename + conditional TS export +
849/// `register_typegen_type!` so subtype definitions don't repeat 3–4 lines of
850/// boilerplate each.
851///
852/// Default derives: `Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize`.
853/// Always added: `#[cfg_attr(feature = "codegen-ts", derive(myko::TS))]`,
854/// `#[cfg_attr(feature = "codegen-ts", ts(export))]`, and
855/// `#[serde(rename_all = "camelCase")]`. Emits a `register_typegen_type!`
856/// call after the item so typegen picks it up when the feature is on.
857///
858/// Extra derives (e.g. `Default`, `Eq`, `Hash`, `Copy`) can be requested
859/// via `derive(...)` — they're appended to the default list.
860///
861/// `manual(serde)` opts a type out of the default `Serialize`/`Deserialize`
862/// derives and camel-case attribute when it owns a custom wire format.
863/// Backend representation overrides remain Myko-owned through
864/// `export(as = "...")`; downstream crates never implement a generator trait.
865///
866/// `Debug`/`Clone`/`PartialEq` and any `derive(...)` extras are
867/// unaffected by `manual(...)`.
868///
869/// Also auto-implements `query::Filterable`, so the type can be used as an
870/// `#[myko_item]` entity field without a hand-written
871/// `impl_filterable_eq!`/`impl_filterable_opaque!` call: deriving both `Eq`
872/// and `Ord` gets you `EqFilter` (exact-match/`In` filtering); anything
873/// less falls back to `Unfilterable` (the field still compiles, it's just
874/// not filterable). A type with its own hand-written `Filterable` impl —
875/// e.g. a custom filter whose `matches` uses domain equivalence instead of
876/// derived `PartialEq` — opts out with `manual(filterable)`; without it the
877/// auto-impl conflicts with the hand-written one.
878///
879/// # Usage
880///
881/// ```ignore
882/// #[myko_subtype]
883/// pub struct UserData {
884///     pub id: UserId,
885/// }
886///
887/// #[myko_subtype(derive(Default, Eq))]
888/// pub enum NetworkEventType {
889///     Added,
890///     Removed,
891/// }
892///
893/// #[myko_subtype(derive(Default, Eq, Hash))]
894/// pub struct DeviceShareKey {
895///     pub device_id: Arc<str>,
896///     pub user_id: Arc<str>,
897/// }
898///
899/// // Hand-written Serialize/Deserialize with a Myko-owned opaque binding.
900/// #[myko_subtype(derive(Default), manual(serde), export(as = "unknown"))]
901/// pub struct BindingValue {
902///     // ...
903/// }
904/// ```
905#[proc_macro_attribute]
906pub fn myko_subtype(attr: TokenStream, input: TokenStream) -> TokenStream {
907    let args = parse_macro_input!(attr as SubtypeArgs);
908    let item: syn::Item = parse_macro_input!(input as syn::Item);
909    myko_subtype_expand(args, item).into()
910}
911
912struct SubtypeArgs {
913    extra_derives: Vec<syn::Path>,
914    /// `manual(serde)` — the item has its own hand-written `Serialize`/
915    /// `Deserialize` impls (e.g. a custom plain-JSON wire format that
916    /// deriving would change); skips those derives AND the default
917    /// `#[serde(rename_all = "camelCase")]` (the attribute is a serde
918    /// derive-macro helper attr — emitting it with no `#[derive(Serialize/
919    /// Deserialize)]` present is a hard compile error, not a no-op).
920    manual_serde: bool,
921    /// `export(as = "...")` — a Myko-owned opaque/custom wire mapping.
922    export_as: Option<syn::LitStr>,
923    /// `manual(filterable)` — the item has its own hand-written
924    /// `query::Filterable` impl (e.g. a custom filter type whose `matches`
925    /// uses domain equivalence instead of derived `PartialEq`); skips the
926    /// auto-impl, which would otherwise conflict.
927    manual_filterable: bool,
928}
929
930impl syn::parse::Parse for SubtypeArgs {
931    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
932        let mut extra_derives = Vec::new();
933        let mut manual_serde = false;
934        let mut export_as = None;
935        let mut manual_filterable = false;
936
937        // `derive(Foo, Bar)`, `manual(serde, filterable)` — comma-separated.
938        // either or both omitted.
939        let metas: syn::punctuated::Punctuated<syn::Meta, syn::Token![,]> =
940            syn::punctuated::Punctuated::parse_terminated(input)?;
941
942        for meta in metas {
943            let syn::Meta::List(list) = &meta else {
944                return Err(syn::Error::new_spanned(
945                    &meta,
946                    "expected `derive(...)`, `manual(...)`, or `export(as = \"...\")`",
947                ));
948            };
949            if list.path.is_ident("derive") {
950                let punct: syn::punctuated::Punctuated<syn::Path, syn::Token![,]> =
951                    list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
952                extra_derives.extend(punct);
953            } else if list.path.is_ident("export") {
954                export_as = Some(list.parse_args_with(|input: ParseStream| {
955                    let keyword = syn::Ident::parse_any(input)?;
956                    if keyword != "as" {
957                        return Err(syn::Error::new_spanned(keyword, "expected `as = \"...\"`"));
958                    }
959                    input.parse::<Token![=]>()?;
960                    input.parse::<syn::LitStr>()
961                })?);
962            } else if list.path.is_ident("manual") {
963                let punct: syn::punctuated::Punctuated<syn::Ident, syn::Token![,]> =
964                    list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
965                for ident in punct {
966                    if ident == "serde" {
967                        manual_serde = true;
968                    } else if ident == "filterable" {
969                        manual_filterable = true;
970                    } else {
971                        return Err(syn::Error::new_spanned(
972                            &ident,
973                            "expected `serde` or `filterable` inside `manual(...)`",
974                        ));
975                    }
976                }
977            } else {
978                return Err(syn::Error::new_spanned(
979                    &list.path,
980                    "expected `derive(...)`, `manual(...)`, or `export(as = \"...\")`",
981                ));
982            }
983        }
984
985        Ok(Self {
986            extra_derives,
987            manual_serde,
988            export_as,
989            manual_filterable,
990        })
991    }
992}
993
994fn subtype_registration(
995    krate: &syn::Path,
996    name: &syn::Ident,
997    has_export_override: bool,
998) -> proc_macro2::TokenStream {
999    if has_export_override {
1000        quote!()
1001    } else {
1002        quote!(#krate::register_typegen_type!(#name);)
1003    }
1004}
1005
1006fn myko_subtype_expand(args: SubtypeArgs, mut item: syn::Item) -> proc_macro2::TokenStream {
1007    let SubtypeArgs {
1008        extra_derives,
1009        manual_serde,
1010        export_as,
1011        manual_filterable,
1012    } = args;
1013    let ctx = DeriveCtx::new();
1014    let krate = &ctx.krate;
1015    let serde_path = &ctx.serde_path;
1016
1017    // Common setup: gate user-written `#[ts(...)]` attrs, extract name for
1018    // the `register_typegen_type!` call. Also normalize visibility expectations
1019    // to either struct or enum — other shapes aren't meaningful as subtypes.
1020    //
1021    // `is_struct` controls whether we default to `#[serde(rename_all = "camelCase")]`.
1022    // For structs, Rust field names are snake_case and wire is camelCase → we need
1023    // the rename. For enums, Rust variants are PascalCase (matching the wire form
1024    // used historically in this codebase) so auto-renaming to camelCase would
1025    // silently change the serialized representation and break existing stored
1026    // data. Enums that want a non-default casing must supply their own
1027    // `#[serde(rename_all = ...)]`.
1028    let (name, has_rename_all, is_struct) = match &mut item {
1029        syn::Item::Struct(s) => {
1030            gate_ts_attrs(&mut s.attrs);
1031            for field in &mut s.fields {
1032                prepare_typegen_field(field);
1033            }
1034            (s.ident.clone(), attrs_have_serde_rename_all(&s.attrs), true)
1035        }
1036        syn::Item::Enum(e) => {
1037            gate_ts_attrs(&mut e.attrs);
1038            for variant in &mut e.variants {
1039                gate_ts_attrs(&mut variant.attrs);
1040                for field in &mut variant.fields {
1041                    prepare_typegen_field(field);
1042                }
1043            }
1044            (
1045                e.ident.clone(),
1046                attrs_have_serde_rename_all(&e.attrs),
1047                false,
1048            )
1049        }
1050        other => {
1051            return syn::Error::new_spanned(
1052                other,
1053                "#[myko_subtype] only supports `struct` and `enum` items",
1054            )
1055            .to_compile_error();
1056        }
1057    };
1058
1059    let extra_derive_tokens = if extra_derives.is_empty() {
1060        quote!()
1061    } else {
1062        quote!(, #(#extra_derives),*)
1063    };
1064
1065    // Only emit the default camelCase rename on structs when the user hasn't
1066    // already supplied one, and never when `manual(serde)` is set — with no
1067    // `#[derive(Serialize/Deserialize)]` present, `#[serde(...)]` is an
1068    // unrecognized attribute (a hard compile error, not a no-op).
1069    let serde_rename_attr = if is_struct && !has_rename_all && !manual_serde {
1070        ctx.serde_attr(&quote!(rename_all = "camelCase"))
1071    } else {
1072        quote!()
1073    };
1074
1075    // `manual(serde)` skips only the wire derives; generated binding metadata
1076    // remains owned by Myko, including opaque backend representations.
1077    let serde_derive_tokens = if manual_serde {
1078        quote!()
1079    } else {
1080        quote!(, #serde_path::Serialize, #serde_path::Deserialize)
1081    };
1082    let has_export_override = export_as.is_some();
1083    let ts_derive_tokens = if has_export_override {
1084        quote!()
1085    } else {
1086        quote!(, #krate::TS)
1087    };
1088    let ts_export_attr = if has_export_override {
1089        quote!()
1090    } else {
1091        let ts_crate = &ctx.ts_crate;
1092        quote!(#[ts(crate = #ts_crate, export)])
1093    };
1094    let register_export_call = subtype_registration(krate, &name, has_export_override);
1095    let export_override_impl = export_as.map_or_else(
1096        || quote!(),
1097        |wire_type| quote!(#krate::impl_ts_as!(#name, #wire_type);),
1098    );
1099
1100    // Every #[myko_subtype] auto-implements Filterable, so a type declared
1101    // this way is always usable as an entity field without a hand-written
1102    // impl_filterable_eq!/impl_filterable_opaque! call (the two escape
1103    // hatches those macros exist for downstream crates that DON'T go
1104    // through myko_subtype — e.g. a plain third-party or hand-written enum).
1105    // EqFilter<T>'s CanonicalFilter impl needs T: Ord + Clone (for the
1106    // In-set sort/dedup step query-cache identity depends on, spec §1), so
1107    // only pick EqFilter when the consumer actually derived Eq + Ord;
1108    // otherwise fall back to Unfilterable — same degenerate-but-compiling
1109    // treatment serde_json::Value and the container blanket impls get.
1110    let derives_total_order = extra_derives.iter().any(|p| p.is_ident("Ord"))
1111        && extra_derives.iter().any(|p| p.is_ident("Eq"));
1112    let filterable_impl = if manual_filterable {
1113        quote!()
1114    } else if derives_total_order {
1115        quote! {
1116            impl #krate::query::Filterable for #name {
1117                type Filter = #krate::query::EqFilter<#name>;
1118            }
1119        }
1120    } else {
1121        quote! {
1122            impl #krate::query::Filterable for #name {
1123                type Filter = #krate::query::Unfilterable;
1124            }
1125        }
1126    };
1127
1128    // `myko::TS` is the no-op `TsNoop` derive unless myko's own `codegen-ts`
1129    // feature is on, so emit it (and the `ts(export)` attr it claims)
1130    // unconditionally — no consumer-side feature gate. Concrete declarations
1131    // register with the active backend; opaque inline mappings do not create files.
1132    quote! {
1133        #[derive(Debug, Clone, PartialEq #serde_derive_tokens #ts_derive_tokens #extra_derive_tokens)]
1134        #ts_export_attr
1135        #serde_rename_attr
1136        #item
1137
1138        #export_override_impl
1139        #register_export_call
1140
1141        #filterable_impl
1142    }
1143}
1144
1145/// Returns true if any attribute in the slice is `#[serde(... rename_all = "...")]`.
1146/// Used by `myko_subtype` to skip its default camelCase rename when the user
1147/// already wrote a different one (e.g. `snake_case` for enum variants).
1148fn attrs_have_serde_rename_all(attrs: &[syn::Attribute]) -> bool {
1149    use quote::ToTokens;
1150    attrs.iter().any(|a| {
1151        a.path().is_ident("serde") && a.to_token_stream().to_string().contains("rename_all")
1152    })
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use quote::ToTokens;
1158
1159    use super::*;
1160
1161    #[test]
1162    fn translates_myko_export_field_overrides() {
1163        let mut field: syn::Field = syn::parse_quote! {
1164            #[myko(export(type = "any", optional, nullable, rename = "wireValue"))]
1165            value: Option<serde_json::Value>
1166        };
1167
1168        prepare_typegen_field(&mut field);
1169        let rendered = field
1170            .attrs
1171            .iter()
1172            .map(|attr| attr.to_token_stream().to_string())
1173            .collect::<Vec<_>>()
1174            .join(" ");
1175        assert!(rendered.contains("type = \"any\""));
1176        assert!(rendered.contains("optional = nullable"));
1177        assert!(rendered.contains("rename = \"wireValue\""));
1178    }
1179
1180    #[test]
1181    fn optional_fields_default_to_optional_and_nullable_exports() {
1182        let mut optional: syn::Field = syn::parse_quote!(value: Option<String>);
1183        prepare_typegen_field(&mut optional);
1184        let rendered = optional
1185            .attrs
1186            .iter()
1187            .map(|attr| attr.to_token_stream().to_string())
1188            .collect::<Vec<_>>()
1189            .join(" ");
1190        assert!(rendered.contains("optional = nullable"));
1191
1192        let mut required: syn::Field = syn::parse_quote!(value: String);
1193        prepare_typegen_field(&mut required);
1194        assert!(required.attrs.is_empty());
1195    }
1196
1197    #[test]
1198    fn subtype_routes_derive_through_myko_and_supports_opaque_export() {
1199        let normal = myko_subtype_expand(
1200            syn::parse_quote!(),
1201            syn::parse_quote!(
1202                pub struct Normal {
1203                    value: uuid::Uuid,
1204                }
1205            ),
1206        )
1207        .to_string();
1208        assert!(normal.contains("myko :: TS"));
1209        assert!(normal.contains("crate = \"myko::ts_rs\""));
1210
1211        let opaque = myko_subtype_expand(
1212            syn::parse_quote!(export(as = "unknown")),
1213            syn::parse_quote!(
1214                pub struct Opaque {
1215                    value: Vec<u8>,
1216                }
1217            ),
1218        )
1219        .to_string();
1220        assert!(opaque.contains("myko :: impl_ts_as ! (Opaque , \"unknown\")"));
1221        assert!(!opaque.contains(", myko :: TS"));
1222    }
1223}