use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{ToTokens, quote};
use syn::{
Token,
ext::IdentExt,
parse::{Parse, ParseStream},
parse_macro_input,
punctuated::Punctuated,
spanned::Spanned,
};
mod command;
mod graph;
mod item;
mod message_events;
mod query;
mod relationship;
mod report;
mod saga;
mod setter;
mod view;
#[proc_macro_attribute]
pub fn myko_category(_attr: TokenStream, input: TokenStream) -> TokenStream {
graph::category(&parse_macro_input!(input as syn::ItemStruct)).into()
}
#[proc_macro_attribute]
pub fn myko_in(attr: TokenStream, input: TokenStream) -> TokenStream {
let categories =
parse_macro_input!(attr with Punctuated::<syn::Path, Token![,]>::parse_terminated);
graph::category_membership(&categories, &parse_macro_input!(input as syn::ItemStruct)).into()
}
#[proc_macro_attribute]
pub fn myko_edge(_attr: TokenStream, input: TokenStream) -> TokenStream {
graph::edge(parse_macro_input!(input as syn::ItemImpl)).into()
}
pub(crate) fn is_myko_crate() -> bool {
std::env::var("CARGO_PKG_NAME").is_ok_and(|name| name == "myko")
}
pub(crate) fn myko_path() -> syn::Path {
if is_myko_crate() {
syn::Path::from(syn::Ident::new("crate", Span::call_site()))
} else {
syn::Path::from(syn::Ident::new("myko", Span::call_site()))
}
}
pub(crate) struct DeriveCtx {
pub krate: syn::Path,
pub serde_path: proc_macro2::TokenStream,
pub serde_crate_attr: Option<String>,
pub ts_crate: String,
}
impl DeriveCtx {
pub fn new() -> Self {
let krate = myko_path();
if is_myko_crate() {
Self {
krate,
serde_path: quote!(serde),
serde_crate_attr: None,
ts_crate: "crate::ts_rs".to_string(),
}
} else {
let serde_crate_str = "myko::serde".to_string();
Self {
krate,
serde_path: quote!(myko::serde),
serde_crate_attr: Some(serde_crate_str),
ts_crate: "myko::ts_rs".to_string(),
}
}
}
pub fn serde_attr(&self, rest: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
self.serde_crate_attr.as_ref().map_or_else(
|| {
if rest.is_empty() {
quote!()
} else {
quote!(#[serde(#rest)])
}
},
|crate_str| {
if rest.is_empty() {
quote!(#[serde(crate = #crate_str)])
} else {
quote!(#[serde(crate = #crate_str, #rest)])
}
},
)
}
}
pub(crate) fn take_manual_cache_key_attr(input_struct: &mut syn::ItemStruct) -> bool {
let mut found = take_marker_attr(input_struct, "myko_manual_cache_key");
input_struct.attrs.retain(|attr| {
let is_doc_marker = attr.path().is_ident("doc")
&& attr
.meta
.require_name_value()
.ok()
.and_then(|nv| match &nv.value {
syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
syn::Lit::Str(s) => Some(s.value() == "__myko_manual_cache_key"),
_ => None,
},
_ => None,
})
.unwrap_or(false);
found |= is_doc_marker;
!is_doc_marker
});
found
}
pub(crate) fn take_non_hash_cache_key_attr(input_struct: &mut syn::ItemStruct) -> bool {
let mut found = take_marker_attr(input_struct, "myko_non_hash_cache_key");
input_struct.attrs.retain(|attr| {
let is_doc_marker = attr.path().is_ident("doc")
&& attr
.meta
.require_name_value()
.ok()
.and_then(|nv| match &nv.value {
syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
syn::Lit::Str(s) => Some(s.value() == "__myko_non_hash_cache_key"),
_ => None,
},
_ => None,
})
.unwrap_or(false);
found |= is_doc_marker;
!is_doc_marker
});
found
}
fn take_marker_attr(input_struct: &mut syn::ItemStruct, attr_name: &str) -> bool {
let mut found = false;
input_struct.attrs.retain(|attr| {
let matches = attr.path().is_ident(attr_name);
found |= matches;
!matches
});
found
}
#[proc_macro_derive(TsNoop, attributes(ts))]
pub fn ts_noop_derive(_input: TokenStream) -> TokenStream {
TokenStream::new()
}
pub(crate) fn gate_ts_attrs(attrs: &mut [syn::Attribute]) {
for attr in attrs.iter_mut() {
if !attr.path().is_ident("myko") {
continue;
}
let Ok(parsed) = attr.parse_args::<ExportOverride>() else {
continue;
};
let mut args = Vec::new();
if let Some(value) = parsed.type_override {
args.push(quote!(type = #value));
}
if let Some(rename) = parsed.rename {
args.push(quote!(rename = #rename));
}
if parsed.skip {
args.push(quote!(skip));
}
if parsed.nullable {
args.push(quote!(optional = nullable));
} else if parsed.optional {
args.push(quote!(optional));
}
*attr = syn::parse_quote!(#[ts(#(#args),*)]);
}
}
#[derive(Default)]
struct ExportOverride {
type_override: Option<syn::LitStr>,
rename: Option<syn::LitStr>,
optional: bool,
nullable: bool,
skip: bool,
}
impl Parse for ExportOverride {
fn parse(input: ParseStream) -> syn::Result<Self> {
let export = syn::Ident::parse_any(input)?;
if export != "export" {
return Err(syn::Error::new_spanned(export, "expected `export(...)`"));
}
let content;
syn::parenthesized!(content in input);
let mut result = Self::default();
while !content.is_empty() {
let key = syn::Ident::parse_any(&content)?;
if key == "type" {
content.parse::<Token![=]>()?;
result.type_override = Some(content.parse()?);
} else if key == "rename" {
content.parse::<Token![=]>()?;
result.rename = Some(content.parse()?);
} else if key == "optional" {
result.optional = true;
} else if key == "nullable" {
result.nullable = true;
} else if key == "skip" {
result.skip = true;
} else {
return Err(syn::Error::new_spanned(
key,
"expected `type`, `rename`, `optional`, `nullable`, or `skip`",
));
}
if content.is_empty() {
break;
}
content.parse::<Token![,]>()?;
}
Ok(result)
}
}
pub(crate) fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
let lines: Vec<String> = attrs
.iter()
.filter_map(|attr| {
if !attr.path().is_ident("doc") {
return None;
}
let syn::Meta::NameValue(nv) = &attr.meta else {
return None;
};
let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
else {
return None;
};
let line = s.value();
let line = line.trim();
(!line.is_empty()).then(|| line.to_string())
})
.collect();
(!lines.is_empty()).then(|| lines.join(" "))
}
pub(crate) fn field_metadata_tokens(
fields: &syn::Fields,
krate: &syn::Path,
) -> proc_macro2::TokenStream {
let entries: Vec<_> = match fields {
syn::Fields::Named(named) => named
.named
.iter()
.filter_map(|f| {
let name = f.ident.as_ref()?.to_string();
let ty = &f.ty;
let rust_type = quote!(#ty).to_string();
let optional = is_option_type(ty);
Some(quote! {
#krate::reflection::OperationArgField {
name: #name,
rust_type: #rust_type,
optional: #optional,
}
})
})
.collect(),
_ => Vec::new(),
};
quote! { &[ #(#entries),* ] }
}
pub(crate) fn operation_metadata_tokens(
input: &syn::ItemStruct,
krate: &syn::Path,
) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
let description = extract_doc_comment(&input.attrs);
let description = description
.as_ref()
.map_or_else(|| quote!(None), |value| quote!(Some(#value)));
(description, field_metadata_tokens(&input.fields, krate))
}
pub(crate) fn gate_field_ts_attrs(fields: &mut syn::Fields) {
for field in fields {
prepare_typegen_field(field);
}
}
pub(crate) fn prepare_typegen_field(field: &mut syn::Field) {
gate_ts_attrs(&mut field.attrs);
if !is_option_type(&field.ty) {
return;
}
let has_explicit_policy = field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("ts"))
.any(|attr| {
let tokens = attr.meta.to_token_stream().to_string();
tokens.contains("optional") || tokens.contains("skip")
});
if !has_explicit_policy {
field
.attrs
.push(syn::parse_quote!(#[ts(optional = nullable)]));
}
}
fn is_option_type(ty: &syn::Type) -> bool {
let syn::Type::Path(type_path) = ty else {
return false;
};
type_path
.path
.segments
.last()
.is_some_and(|seg| seg.ident == "Option")
}
#[proc_macro_attribute]
pub fn myko_manual_cache_key(_attr: TokenStream, input: TokenStream) -> TokenStream {
let item = parse_macro_input!(input as syn::ItemStruct);
quote! {
#[doc = "__myko_manual_cache_key"]
#item
}
.into()
}
#[proc_macro_attribute]
pub fn myko_non_hash_cache_key(_attr: TokenStream, input: TokenStream) -> TokenStream {
let item = parse_macro_input!(input as syn::ItemStruct);
quote! {
#[doc = "__myko_non_hash_cache_key"]
#item
}
.into()
}
#[proc_macro_attribute]
pub fn myko_item(attr: TokenStream, input: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as item::ItemArgs);
let input = parse_macro_input!(input as syn::ItemStruct);
item::myko_item_impl(&args, input).into()
}
#[proc_macro_attribute]
pub fn myko_query(attr: TokenStream, input: TokenStream) -> TokenStream {
let query_item_type = parse_macro_input!(attr as syn::Path);
let input = parse_macro_input!(input as syn::ItemStruct);
query::myko_query_impl(&query_item_type, input).into()
}
#[proc_macro_attribute]
pub fn myko_view(attr: TokenStream, input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::ItemStruct);
if attr.is_empty() {
return syn::Error::new(
input.ident.span(),
"#[myko_view] requires an item type: #[myko_view(ViewItemType)]",
)
.to_compile_error()
.into();
}
let args = parse_macro_input!(attr as view::ViewArgs);
view::myko_view_impl(args, input).into()
}
#[proc_macro_attribute]
pub fn myko_view_item(_attr: TokenStream, input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::ItemStruct);
view::myko_view_item_impl(input).into()
}
#[proc_macro_attribute]
pub fn myko_report(attr: TokenStream, input: TokenStream) -> TokenStream {
let report_output_type = parse_macro_input!(attr as syn::Path);
let input = parse_macro_input!(input as syn::ItemStruct);
report::myko_report_impl(&report_output_type, input).into()
}
#[proc_macro_attribute]
pub fn myko_command(attr: TokenStream, input: TokenStream) -> TokenStream {
let options = if attr.is_empty() {
command::CommandOptions {
result_type: None,
custom_serialize: false,
}
} else {
parse_macro_input!(attr as CommandArgs).into()
};
let input = parse_macro_input!(input as syn::ItemStruct);
command::myko_command_impl(options, input).into()
}
struct CommandArgs {
result_type: Option<syn::Path>,
custom_serialize: bool,
}
impl From<CommandArgs> for command::CommandOptions {
fn from(value: CommandArgs) -> Self {
Self {
result_type: value.result_type,
custom_serialize: value.custom_serialize,
}
}
}
impl Parse for CommandArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let args = Punctuated::<syn::Path, Token![,]>::parse_terminated(input)?;
let mut result_type = None;
let mut custom_serialize = false;
for path in args {
if path.is_ident("custom_serialize") {
if custom_serialize {
return Err(syn::Error::new(
path.span(),
"duplicate custom_serialize flag",
));
}
custom_serialize = true;
continue;
}
if result_type.is_some() {
return Err(syn::Error::new(
path.span(),
"expected at most one result type",
));
}
result_type = Some(path);
}
Ok(Self {
result_type,
custom_serialize,
})
}
}
#[proc_macro_derive(MessageEvents)]
pub fn derive_message_events(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::DeriveInput);
message_events::derive_message_events_impl(&input).into()
}
#[proc_macro_attribute]
pub fn myko_saga(attr: TokenStream, input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::ItemStruct);
let attr = attr.into();
saga::myko_saga_impl(&attr, &input).into()
}
#[proc_macro_attribute]
pub fn myko_report_output(_attr: TokenStream, input: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(input as syn::ItemStruct);
let name = &input.ident;
let ctx = DeriveCtx::new();
let krate = &ctx.krate;
let serde_path = &ctx.serde_path;
let serde_rename_attr = ctx.serde_attr("e!(rename_all = "camelCase"));
gate_ts_attrs(&mut input.attrs);
for field in &mut input.fields {
prepare_typegen_field(field);
}
let equal_fields = input
.fields
.iter()
.enumerate()
.map(|(index, field)| {
let member = field.ident.clone().map_or_else(
|| syn::Member::Unnamed(syn::Index::from(index)),
syn::Member::Named,
);
quote! { self.#member == other.#member }
})
.reduce(|acc, term| quote! { (#acc) && (#term) })
.unwrap_or_else(|| quote! { true });
let expanded = quote! {
#[derive(Debug, Clone, #serde_path::Serialize, #serde_path::Deserialize, #krate::TS)]
#[ts(crate = "myko::ts_rs")]
#serde_rename_attr
#input
impl PartialEq for #name {
fn eq(&self, other: &Self) -> bool { #equal_fields }
}
#krate::register_typegen_type!(#name);
};
expanded.into()
}
#[proc_macro_attribute]
pub fn myko_subtype(attr: TokenStream, input: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as SubtypeArgs);
let item: syn::Item = parse_macro_input!(input as syn::Item);
myko_subtype_expand(args, item).into()
}
struct SubtypeArgs {
extra_derives: Vec<syn::Path>,
manual_serde: bool,
export_as: Option<syn::LitStr>,
manual_filterable: bool,
}
impl syn::parse::Parse for SubtypeArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut extra_derives = Vec::new();
let mut manual_serde = false;
let mut export_as = None;
let mut manual_filterable = false;
let metas: syn::punctuated::Punctuated<syn::Meta, syn::Token![,]> =
syn::punctuated::Punctuated::parse_terminated(input)?;
for meta in metas {
let syn::Meta::List(list) = &meta else {
return Err(syn::Error::new_spanned(
&meta,
"expected `derive(...)`, `manual(...)`, or `export(as = \"...\")`",
));
};
if list.path.is_ident("derive") {
let punct: syn::punctuated::Punctuated<syn::Path, syn::Token![,]> =
list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
extra_derives.extend(punct);
} else if list.path.is_ident("export") {
export_as = Some(list.parse_args_with(|input: ParseStream| {
let keyword = syn::Ident::parse_any(input)?;
if keyword != "as" {
return Err(syn::Error::new_spanned(keyword, "expected `as = \"...\"`"));
}
input.parse::<Token![=]>()?;
input.parse::<syn::LitStr>()
})?);
} else if list.path.is_ident("manual") {
let punct: syn::punctuated::Punctuated<syn::Ident, syn::Token![,]> =
list.parse_args_with(syn::punctuated::Punctuated::parse_terminated)?;
for ident in punct {
if ident == "serde" {
manual_serde = true;
} else if ident == "filterable" {
manual_filterable = true;
} else {
return Err(syn::Error::new_spanned(
&ident,
"expected `serde` or `filterable` inside `manual(...)`",
));
}
}
} else {
return Err(syn::Error::new_spanned(
&list.path,
"expected `derive(...)`, `manual(...)`, or `export(as = \"...\")`",
));
}
}
Ok(Self {
extra_derives,
manual_serde,
export_as,
manual_filterable,
})
}
}
fn subtype_registration(
krate: &syn::Path,
name: &syn::Ident,
has_export_override: bool,
) -> proc_macro2::TokenStream {
if has_export_override {
quote!()
} else {
quote!(#krate::register_typegen_type!(#name);)
}
}
fn myko_subtype_expand(args: SubtypeArgs, mut item: syn::Item) -> proc_macro2::TokenStream {
let SubtypeArgs {
extra_derives,
manual_serde,
export_as,
manual_filterable,
} = args;
let ctx = DeriveCtx::new();
let krate = &ctx.krate;
let serde_path = &ctx.serde_path;
let (name, has_rename_all, is_struct) = match &mut item {
syn::Item::Struct(s) => {
gate_ts_attrs(&mut s.attrs);
for field in &mut s.fields {
prepare_typegen_field(field);
}
(s.ident.clone(), attrs_have_serde_rename_all(&s.attrs), true)
}
syn::Item::Enum(e) => {
gate_ts_attrs(&mut e.attrs);
for variant in &mut e.variants {
gate_ts_attrs(&mut variant.attrs);
for field in &mut variant.fields {
prepare_typegen_field(field);
}
}
(
e.ident.clone(),
attrs_have_serde_rename_all(&e.attrs),
false,
)
}
other => {
return syn::Error::new_spanned(
other,
"#[myko_subtype] only supports `struct` and `enum` items",
)
.to_compile_error();
}
};
let extra_derive_tokens = if extra_derives.is_empty() {
quote!()
} else {
quote!(, #(#extra_derives),*)
};
let serde_rename_attr = if is_struct && !has_rename_all && !manual_serde {
ctx.serde_attr("e!(rename_all = "camelCase"))
} else {
quote!()
};
let serde_derive_tokens = if manual_serde {
quote!()
} else {
quote!(, #serde_path::Serialize, #serde_path::Deserialize)
};
let has_export_override = export_as.is_some();
let ts_derive_tokens = if has_export_override {
quote!()
} else {
quote!(, #krate::TS)
};
let ts_export_attr = if has_export_override {
quote!()
} else {
let ts_crate = &ctx.ts_crate;
quote!(#[ts(crate = #ts_crate, export)])
};
let register_export_call = subtype_registration(krate, &name, has_export_override);
let export_override_impl = export_as.map_or_else(
|| quote!(),
|wire_type| quote!(#krate::impl_ts_as!(#name, #wire_type);),
);
let derives_total_order = extra_derives.iter().any(|p| p.is_ident("Ord"))
&& extra_derives.iter().any(|p| p.is_ident("Eq"));
let filterable_impl = if manual_filterable {
quote!()
} else if derives_total_order {
quote! {
impl #krate::query::Filterable for #name {
type Filter = #krate::query::EqFilter<#name>;
}
}
} else {
quote! {
impl #krate::query::Filterable for #name {
type Filter = #krate::query::Unfilterable;
}
}
};
quote! {
#[derive(Debug, Clone, PartialEq #serde_derive_tokens #ts_derive_tokens #extra_derive_tokens)]
#ts_export_attr
#serde_rename_attr
#item
#export_override_impl
#register_export_call
#filterable_impl
}
}
fn attrs_have_serde_rename_all(attrs: &[syn::Attribute]) -> bool {
use quote::ToTokens;
attrs.iter().any(|a| {
a.path().is_ident("serde") && a.to_token_stream().to_string().contains("rename_all")
})
}
#[cfg(test)]
mod tests {
use quote::ToTokens;
use super::*;
#[test]
fn translates_myko_export_field_overrides() {
let mut field: syn::Field = syn::parse_quote! {
#[myko(export(type = "any", optional, nullable, rename = "wireValue"))]
value: Option<serde_json::Value>
};
prepare_typegen_field(&mut field);
let rendered = field
.attrs
.iter()
.map(|attr| attr.to_token_stream().to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(rendered.contains("type = \"any\""));
assert!(rendered.contains("optional = nullable"));
assert!(rendered.contains("rename = \"wireValue\""));
}
#[test]
fn optional_fields_default_to_optional_and_nullable_exports() {
let mut optional: syn::Field = syn::parse_quote!(value: Option<String>);
prepare_typegen_field(&mut optional);
let rendered = optional
.attrs
.iter()
.map(|attr| attr.to_token_stream().to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(rendered.contains("optional = nullable"));
let mut required: syn::Field = syn::parse_quote!(value: String);
prepare_typegen_field(&mut required);
assert!(required.attrs.is_empty());
}
#[test]
fn subtype_routes_derive_through_myko_and_supports_opaque_export() {
let normal = myko_subtype_expand(
syn::parse_quote!(),
syn::parse_quote!(
pub struct Normal {
value: uuid::Uuid,
}
),
)
.to_string();
assert!(normal.contains("myko :: TS"));
assert!(normal.contains("crate = \"myko::ts_rs\""));
let opaque = myko_subtype_expand(
syn::parse_quote!(export(as = "unknown")),
syn::parse_quote!(
pub struct Opaque {
value: Vec<u8>,
}
),
)
.to_string();
assert!(opaque.contains("myko :: impl_ts_as ! (Opaque , \"unknown\")"));
assert!(!opaque.contains(", myko :: TS"));
}
}