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("e!(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("e!(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}