//! Proc-macro implementation for the `loroscope` crate. //! //! Depend on [`loroscope`](https://docs.rs/loroscope) rather than using this //! crate directly. #![deny(missing_docs)] #![deny(clippy::unwrap_used)] use proc_macro::TokenStream; use quote::quote; use syn::punctuated::Punctuated; use syn::{GenericArgument, Ident, ItemStruct, Meta, PathArguments, Type, parse_macro_input}; /// Defines a typed struct over a Loro CRDT document. /// /// # Example /// /// ```ignore /// use loroscope::loroscope; /// /// #[loroscope] /// struct Player { /// name: Text, /// score: i64, /// } /// /// let player = Player::new(); /// player.set_score(100); /// assert_eq!(player.score(), 100); /// ``` /// /// # Field types /// /// | Type | Accessor | /// |---|---| /// | `f64`, `i64`, `bool`, `String` | Getter + `set_` setter | /// | [`Text`], [`Counter`] | Getter returning the container | /// | [`List`], [`Map`], [`MovableList`], [`Tree`] | Getter returning a typed collection | /// | `LoroList`, `LoroMap`, `LoroText`, … | Getter returning the raw Loro container | /// | Any `#[loroscope]` struct | Getter returning a nested typed view | /// /// # Generated methods /// /// - `new()` — creates a new [`LoroDoc`](loro::LoroDoc) and returns /// a typed view into it. /// - `doc()` — returns the [`LoroDoc`](loro::LoroDoc) this struct belongs to. /// - `raw()` — returns a reference to the underlying [`LoroMap`](loro::LoroMap). /// /// # Derives /// /// Derive macros on the input struct are forwarded to the generated struct. /// `Debug` is handled specially: it prints the struct using its field /// accessors rather than exposing internals. /// /// [`Text`]: https://docs.rs/loroscope/latest/loroscope/type.Text.html /// [`Counter`]: https://docs.rs/loroscope/latest/loroscope/type.Counter.html /// [`Tree`]: https://docs.rs/loroscope/latest/loroscope/type.Tree.html /// [`List`]: https://docs.rs/loroscope/latest/loroscope/struct.List.html /// [`Map`]: https://docs.rs/loroscope/latest/loroscope/struct.Map.html /// [`MovableList`]: https://docs.rs/loroscope/latest/loroscope/struct.MovableList.html #[proc_macro_attribute] pub fn loroscope(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { let ident = parse_macro_input!(attr as Ident); return syn::Error::new(ident.span(), "unexpected attribute argument") .to_compile_error() .into(); } let input = parse_macro_input!(item as ItemStruct); let struct_name = &input.ident; let struct_name_str = struct_name.to_string(); let vis = &input.vis; let map_key = struct_name.to_string(); // Separate derive macros from other attributes, pulling out Debug specially. let mut has_debug = false; let mut other_derives: Vec = Vec::new(); let mut other_attrs: Vec<&syn::Attribute> = Vec::new(); for attr in &input.attrs { if attr.path().is_ident("derive") { if let Meta::List(meta_list) = &attr.meta && let Ok(paths) = meta_list .parse_args_with(Punctuated::::parse_terminated) { for path in paths { if path.is_ident("Debug") { has_debug = true; } else { other_derives.push(path); } } } } else { other_attrs.push(attr); } } let derive_attr = if other_derives.is_empty() { quote!() } else { quote!(#[derive(#(#other_derives),*)]) }; let fields = match &input.fields { syn::Fields::Named(f) => &f.named, _ => { return syn::Error::new_spanned(&input, "expected named fields") .to_compile_error() .into(); } }; let mut accessors = Vec::new(); for field in fields { let field_name = field.ident.as_ref().expect("named fields"); let field_name_str = field_name.to_string(); let field_ty = &field.ty; match categorize_type(field_ty) { TypeCategory::Primitive(prim) => { accessors.push(gen_primitive_getter(field_name, &field_name_str, &prim)); accessors.push(gen_primitive_setter(field_name, &field_name_str, &prim)); } TypeCategory::NonGenericContainer(container) => { accessors.push(gen_non_generic_container_getter( field_name, &field_name_str, &container, )); } TypeCategory::GenericContainer(container, type_args) => { accessors.push(gen_generic_container_getter( field_name, &field_name_str, &container, &type_args, )); } TypeCategory::NestedLoroscope => { accessors.push(gen_nested_getter(field_name, &field_name_str, field_ty)); } } } // Generate a custom Debug impl that prints as if the original fields exist. let debug_impl = if has_debug { let debug_fields: Vec<_> = fields .iter() .map(|field| { let name = field.ident.as_ref().expect("named fields"); let name_str = name.to_string(); quote! { .field(#name_str, &self.#name()) } }) .collect(); quote! { impl ::core::fmt::Debug for #struct_name { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { f.debug_struct(#struct_name_str) #(#debug_fields)* .finish() } } } } else { quote!() }; let output = quote! { #(#other_attrs)* #derive_attr #vis struct #struct_name { _map: ::loroscope::__private::LoroMap, } impl ::core::convert::From<::loroscope::__private::LoroMap> for #struct_name { fn from(map: ::loroscope::__private::LoroMap) -> Self { Self { _map: map } } } impl ::loroscope::FromLoroMap for #struct_name { fn from_loro_map(map: ::loroscope::__private::LoroMap) -> Self { Self { _map: map } } } #debug_impl impl #struct_name { /// Creates a new [`LoroDoc`](loro::LoroDoc) and returns a typed view into it. pub fn new() -> Self { let doc = ::loroscope::__private::LoroDoc::new(); let map = doc.get_map(#map_key); Self { _map: map } } /// Returns the [`LoroDoc`](loro::LoroDoc) this struct belongs to. pub fn doc(&self) -> ::loroscope::__private::LoroDoc { use ::loroscope::__private::ContainerTrait; self._map.doc().expect("loroscope container is not attached to a document") } /// Returns a reference to the underlying [`LoroMap`](loro::LoroMap). pub fn raw(&self) -> &::loroscope::__private::LoroMap { &self._map } #(#accessors)* } }; output.into() } // --------------------------------------------------------------------------- // Type categorization // --------------------------------------------------------------------------- enum TypeCategory { Primitive(PrimitiveKind), NonGenericContainer(NonGenericContainerKind), GenericContainer(GenericContainerKind, Vec), NestedLoroscope, } enum PrimitiveKind { F64, I64, Bool, String, } enum NonGenericContainerKind { Text, Counter, LoroTree, LoroList, LoroMap, LoroMovableList, } enum GenericContainerKind { List, Map, MovableList, Tree, } /// Known crate-level prefixes. If a multi-segment path has one of these as /// the segment immediately before the type name, we treat the final segment /// the same as a bare import. For any other prefix (e.g. `foo::Text`) we /// conservatively fall back to [`TypeCategory::NestedLoroscope`]. const KNOWN_PREFIXES: &[&str] = &["loroscope", "loro"]; fn categorize_type(ty: &Type) -> TypeCategory { if let Type::Path(type_path) = ty { let segments = &type_path.path.segments; let last_segment = match segments.last() { Some(s) => s, None => return TypeCategory::NestedLoroscope, }; // For multi-segment paths (e.g. `loroscope::Text`, `loro::LoroMap`), // only proceed if the parent segment is a known crate prefix. // Proc macros cannot do real name resolution, so this is a heuristic. if segments.len() > 1 { let parent = segments[segments.len() - 2].ident.to_string(); if !KNOWN_PREFIXES.contains(&parent.as_str()) { return TypeCategory::NestedLoroscope; } } let ident = last_segment.ident.to_string(); match ident.as_str() { "f64" => TypeCategory::Primitive(PrimitiveKind::F64), "i64" => TypeCategory::Primitive(PrimitiveKind::I64), "bool" => TypeCategory::Primitive(PrimitiveKind::Bool), "String" => TypeCategory::Primitive(PrimitiveKind::String), "Text" | "LoroText" => TypeCategory::NonGenericContainer(NonGenericContainerKind::Text), "Counter" | "LoroCounter" => { TypeCategory::NonGenericContainer(NonGenericContainerKind::Counter) } "LoroTree" => TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroTree), "LoroList" => TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroList), "LoroMap" => TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroMap), "LoroMovableList" => { TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroMovableList) } "List" | "Map" | "MovableList" | "Tree" => { let kind = match ident.as_str() { "List" => GenericContainerKind::List, "Map" => GenericContainerKind::Map, "MovableList" => GenericContainerKind::MovableList, "Tree" => GenericContainerKind::Tree, _ => unreachable!(), }; let type_args = extract_type_args(&last_segment.arguments); TypeCategory::GenericContainer(kind, type_args) } _ => TypeCategory::NestedLoroscope, } } else { TypeCategory::NestedLoroscope } } fn extract_type_args(args: &PathArguments) -> Vec { match args { PathArguments::AngleBracketed(ab) => ab .args .iter() .filter_map(|a| { if let GenericArgument::Type(t) = a { Some(t.clone()) } else { None } }) .collect(), _ => Vec::new(), } } // --------------------------------------------------------------------------- // Code generation — primitives // --------------------------------------------------------------------------- fn gen_primitive_getter( field_name: &Ident, field_name_str: &str, prim: &PrimitiveKind, ) -> proc_macro2::TokenStream { match prim { PrimitiveKind::F64 => quote! { pub fn #field_name(&self) -> f64 { self._map.get(#field_name_str) .and_then(|v| v.into_value().ok()) .and_then(|v| match v { ::loroscope::__private::LoroValue::Double(d) => Some(d), _ => None, }) .unwrap_or_default() } }, PrimitiveKind::I64 => quote! { pub fn #field_name(&self) -> i64 { self._map.get(#field_name_str) .and_then(|v| v.into_value().ok()) .and_then(|v| match v { ::loroscope::__private::LoroValue::I64(i) => Some(i), _ => None, }) .unwrap_or_default() } }, PrimitiveKind::Bool => quote! { pub fn #field_name(&self) -> bool { self._map.get(#field_name_str) .and_then(|v| v.into_value().ok()) .and_then(|v| match v { ::loroscope::__private::LoroValue::Bool(b) => Some(b), _ => None, }) .unwrap_or_default() } }, PrimitiveKind::String => quote! { pub fn #field_name(&self) -> String { self._map.get(#field_name_str) .and_then(|v| v.into_value().ok()) .and_then(|v| match v { ::loroscope::__private::LoroValue::String(s) => Some(s.to_string()), _ => None, }) .unwrap_or_default() } }, } } fn gen_primitive_setter( field_name: &Ident, field_name_str: &str, prim: &PrimitiveKind, ) -> proc_macro2::TokenStream { let setter_name = Ident::new(&format!("set_{}", field_name), field_name.span()); match prim { PrimitiveKind::String => quote! { pub fn #setter_name(&self, val: &str) { self._map.insert(#field_name_str, val).expect("insert into attached map"); } }, PrimitiveKind::F64 => quote! { pub fn #setter_name(&self, val: f64) { self._map.insert(#field_name_str, val).expect("insert into attached map"); } }, PrimitiveKind::I64 => quote! { pub fn #setter_name(&self, val: i64) { self._map.insert(#field_name_str, val).expect("insert into attached map"); } }, PrimitiveKind::Bool => quote! { pub fn #setter_name(&self, val: bool) { self._map.insert(#field_name_str, val).expect("insert into attached map"); } }, } } // --------------------------------------------------------------------------- // Code generation — non-generic containers // --------------------------------------------------------------------------- fn gen_non_generic_container_getter( field_name: &Ident, field_name_str: &str, container: &NonGenericContainerKind, ) -> proc_macro2::TokenStream { let (loro_type, loroscope_type) = match container { NonGenericContainerKind::Text => ( quote!(::loroscope::__private::LoroText), quote!(::loroscope::Text), ), NonGenericContainerKind::Counter => ( quote!(::loroscope::__private::LoroCounter), quote!(::loroscope::Counter), ), NonGenericContainerKind::LoroTree => ( quote!(::loroscope::__private::LoroTree), quote!(::loroscope::__private::LoroTree), ), NonGenericContainerKind::LoroList => ( quote!(::loroscope::__private::LoroList), quote!(::loroscope::__private::LoroList), ), NonGenericContainerKind::LoroMap => ( quote!(::loroscope::__private::LoroMap), quote!(::loroscope::__private::LoroMap), ), NonGenericContainerKind::LoroMovableList => ( quote!(::loroscope::__private::LoroMovableList), quote!(::loroscope::__private::LoroMovableList), ), }; quote! { pub fn #field_name(&self) -> #loroscope_type { self._map.get_or_create_container(#field_name_str, #loro_type::new()).expect("create container on attached map") } } } // --------------------------------------------------------------------------- // Code generation — generic containers // --------------------------------------------------------------------------- fn gen_generic_container_getter( field_name: &Ident, field_name_str: &str, container: &GenericContainerKind, type_args: &[Type], ) -> proc_macro2::TokenStream { let (loro_container, wrapper_path) = match container { GenericContainerKind::List => ( quote!(::loroscope::__private::LoroList), quote!(::loroscope::List), ), GenericContainerKind::Map => ( quote!(::loroscope::__private::LoroMap), quote!(::loroscope::Map), ), GenericContainerKind::MovableList => ( quote!(::loroscope::__private::LoroMovableList), quote!(::loroscope::MovableList), ), GenericContainerKind::Tree => ( quote!(::loroscope::__private::LoroTree), quote!(::loroscope::Tree), ), }; quote! { pub fn #field_name(&self) -> #wrapper_path<#(#type_args),*> { #wrapper_path::new( self._map.get_or_create_container(#field_name_str, #loro_container::new()).expect("create container on attached map") ) } } } // --------------------------------------------------------------------------- // Code generation — nested loroscope structs // --------------------------------------------------------------------------- fn gen_nested_getter( field_name: &Ident, field_name_str: &str, field_ty: &Type, ) -> proc_macro2::TokenStream { quote! { pub fn #field_name(&self) -> #field_ty { ::loroscope::FromLoroMap::from_loro_map( self._map.get_or_create_container( #field_name_str, ::loroscope::__private::LoroMap::new(), ).expect("create container on attached map") ) } } }