Typed structs for Loro CRDTs
rust
crdt
1//! Proc-macro implementation for the `loroscope` crate.
2//!
3//! Depend on [`loroscope`](https://docs.rs/loroscope) rather than using this
4//! crate directly.
5
6#![deny(missing_docs)]
7#![deny(clippy::unwrap_used)]
8
9use proc_macro::TokenStream;
10use quote::quote;
11use syn::punctuated::Punctuated;
12use syn::{GenericArgument, Ident, ItemStruct, Meta, PathArguments, Type, parse_macro_input};
13
14/// Defines a typed struct over a Loro CRDT document.
15///
16/// # Example
17///
18/// ```ignore
19/// use loroscope::loroscope;
20///
21/// #[loroscope]
22/// struct Player {
23/// name: Text,
24/// score: i64,
25/// }
26///
27/// let player = Player::new();
28/// player.set_score(100);
29/// assert_eq!(player.score(), 100);
30/// ```
31///
32/// # Field types
33///
34/// | Type | Accessor |
35/// |---|---|
36/// | `f64`, `i64`, `bool`, `String` | Getter + `set_` setter |
37/// | [`Text`], [`Counter`] | Getter returning the container |
38/// | [`List<T>`], [`Map<V>`], [`MovableList<T>`], [`Tree<T>`] | Getter returning a typed collection |
39/// | `LoroList`, `LoroMap`, `LoroText`, … | Getter returning the raw Loro container |
40/// | Any `#[loroscope]` struct | Getter returning a nested typed view |
41///
42/// # Generated methods
43///
44/// - `new()` — creates a new [`LoroDoc`](loro::LoroDoc) and returns
45/// a typed view into it.
46/// - `doc()` — returns the [`LoroDoc`](loro::LoroDoc) this struct belongs to.
47/// - `raw()` — returns a reference to the underlying [`LoroMap`](loro::LoroMap).
48///
49/// # Derives
50///
51/// Derive macros on the input struct are forwarded to the generated struct.
52/// `Debug` is handled specially: it prints the struct using its field
53/// accessors rather than exposing internals.
54///
55/// [`Text`]: https://docs.rs/loroscope/latest/loroscope/type.Text.html
56/// [`Counter`]: https://docs.rs/loroscope/latest/loroscope/type.Counter.html
57/// [`Tree`]: https://docs.rs/loroscope/latest/loroscope/type.Tree.html
58/// [`List<T>`]: https://docs.rs/loroscope/latest/loroscope/struct.List.html
59/// [`Map<V>`]: https://docs.rs/loroscope/latest/loroscope/struct.Map.html
60/// [`MovableList<T>`]: https://docs.rs/loroscope/latest/loroscope/struct.MovableList.html
61#[proc_macro_attribute]
62pub fn loroscope(attr: TokenStream, item: TokenStream) -> TokenStream {
63 if !attr.is_empty() {
64 let ident = parse_macro_input!(attr as Ident);
65 return syn::Error::new(ident.span(), "unexpected attribute argument")
66 .to_compile_error()
67 .into();
68 }
69
70 let input = parse_macro_input!(item as ItemStruct);
71 let struct_name = &input.ident;
72 let struct_name_str = struct_name.to_string();
73 let vis = &input.vis;
74 let map_key = struct_name.to_string();
75
76 // Separate derive macros from other attributes, pulling out Debug specially.
77 let mut has_debug = false;
78 let mut other_derives: Vec<syn::Path> = Vec::new();
79 let mut other_attrs: Vec<&syn::Attribute> = Vec::new();
80
81 for attr in &input.attrs {
82 if attr.path().is_ident("derive") {
83 if let Meta::List(meta_list) = &attr.meta
84 && let Ok(paths) = meta_list
85 .parse_args_with(Punctuated::<syn::Path, syn::token::Comma>::parse_terminated)
86 {
87 for path in paths {
88 if path.is_ident("Debug") {
89 has_debug = true;
90 } else {
91 other_derives.push(path);
92 }
93 }
94 }
95 } else {
96 other_attrs.push(attr);
97 }
98 }
99
100 let derive_attr = if other_derives.is_empty() {
101 quote!()
102 } else {
103 quote!(#[derive(#(#other_derives),*)])
104 };
105
106 let fields = match &input.fields {
107 syn::Fields::Named(f) => &f.named,
108 _ => {
109 return syn::Error::new_spanned(&input, "expected named fields")
110 .to_compile_error()
111 .into();
112 }
113 };
114
115 let mut accessors = Vec::new();
116
117 for field in fields {
118 let field_name = field.ident.as_ref().expect("named fields");
119 let field_name_str = field_name.to_string();
120 let field_ty = &field.ty;
121
122 match categorize_type(field_ty) {
123 TypeCategory::Primitive(prim) => {
124 accessors.push(gen_primitive_getter(field_name, &field_name_str, &prim));
125 accessors.push(gen_primitive_setter(field_name, &field_name_str, &prim));
126 }
127 TypeCategory::NonGenericContainer(container) => {
128 accessors.push(gen_non_generic_container_getter(
129 field_name,
130 &field_name_str,
131 &container,
132 ));
133 }
134 TypeCategory::GenericContainer(container, type_args) => {
135 accessors.push(gen_generic_container_getter(
136 field_name,
137 &field_name_str,
138 &container,
139 &type_args,
140 ));
141 }
142 TypeCategory::NestedLoroscope => {
143 accessors.push(gen_nested_getter(field_name, &field_name_str, field_ty));
144 }
145 }
146 }
147
148 // Generate a custom Debug impl that prints as if the original fields exist.
149 let debug_impl = if has_debug {
150 let debug_fields: Vec<_> = fields
151 .iter()
152 .map(|field| {
153 let name = field.ident.as_ref().expect("named fields");
154 let name_str = name.to_string();
155 quote! { .field(#name_str, &self.#name()) }
156 })
157 .collect();
158
159 quote! {
160 impl ::core::fmt::Debug for #struct_name {
161 fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
162 f.debug_struct(#struct_name_str)
163 #(#debug_fields)*
164 .finish()
165 }
166 }
167 }
168 } else {
169 quote!()
170 };
171
172 let output = quote! {
173 #(#other_attrs)*
174 #derive_attr
175 #vis struct #struct_name {
176 _map: ::loroscope::__private::LoroMap,
177 }
178
179 impl ::core::convert::From<::loroscope::__private::LoroMap> for #struct_name {
180 fn from(map: ::loroscope::__private::LoroMap) -> Self {
181 Self { _map: map }
182 }
183 }
184
185 impl ::loroscope::FromLoroMap for #struct_name {
186 fn from_loro_map(map: ::loroscope::__private::LoroMap) -> Self {
187 Self { _map: map }
188 }
189 }
190
191 #debug_impl
192
193 impl #struct_name {
194 /// Creates a new [`LoroDoc`](loro::LoroDoc) and returns a typed view into it.
195 pub fn new() -> Self {
196 let doc = ::loroscope::__private::LoroDoc::new();
197 let map = doc.get_map(#map_key);
198 Self { _map: map }
199 }
200
201 /// Returns the [`LoroDoc`](loro::LoroDoc) this struct belongs to.
202 pub fn doc(&self) -> ::loroscope::__private::LoroDoc {
203 use ::loroscope::__private::ContainerTrait;
204 self._map.doc().expect("loroscope container is not attached to a document")
205 }
206
207 /// Returns a reference to the underlying [`LoroMap`](loro::LoroMap).
208 pub fn raw(&self) -> &::loroscope::__private::LoroMap {
209 &self._map
210 }
211
212 #(#accessors)*
213 }
214 };
215
216 output.into()
217}
218
219// ---------------------------------------------------------------------------
220// Type categorization
221// ---------------------------------------------------------------------------
222
223enum TypeCategory {
224 Primitive(PrimitiveKind),
225 NonGenericContainer(NonGenericContainerKind),
226 GenericContainer(GenericContainerKind, Vec<Type>),
227 NestedLoroscope,
228}
229
230enum PrimitiveKind {
231 F64,
232 I64,
233 Bool,
234 String,
235}
236
237enum NonGenericContainerKind {
238 Text,
239 Counter,
240 LoroTree,
241 LoroList,
242 LoroMap,
243 LoroMovableList,
244}
245
246enum GenericContainerKind {
247 List,
248 Map,
249 MovableList,
250 Tree,
251}
252
253/// Known crate-level prefixes. If a multi-segment path has one of these as
254/// the segment immediately before the type name, we treat the final segment
255/// the same as a bare import. For any other prefix (e.g. `foo::Text`) we
256/// conservatively fall back to [`TypeCategory::NestedLoroscope`].
257const KNOWN_PREFIXES: &[&str] = &["loroscope", "loro"];
258
259fn categorize_type(ty: &Type) -> TypeCategory {
260 if let Type::Path(type_path) = ty {
261 let segments = &type_path.path.segments;
262 let last_segment = match segments.last() {
263 Some(s) => s,
264 None => return TypeCategory::NestedLoroscope,
265 };
266
267 // For multi-segment paths (e.g. `loroscope::Text`, `loro::LoroMap`),
268 // only proceed if the parent segment is a known crate prefix.
269 // Proc macros cannot do real name resolution, so this is a heuristic.
270 if segments.len() > 1 {
271 let parent = segments[segments.len() - 2].ident.to_string();
272 if !KNOWN_PREFIXES.contains(&parent.as_str()) {
273 return TypeCategory::NestedLoroscope;
274 }
275 }
276
277 let ident = last_segment.ident.to_string();
278
279 match ident.as_str() {
280 "f64" => TypeCategory::Primitive(PrimitiveKind::F64),
281 "i64" => TypeCategory::Primitive(PrimitiveKind::I64),
282 "bool" => TypeCategory::Primitive(PrimitiveKind::Bool),
283 "String" => TypeCategory::Primitive(PrimitiveKind::String),
284 "Text" | "LoroText" => TypeCategory::NonGenericContainer(NonGenericContainerKind::Text),
285 "Counter" | "LoroCounter" => {
286 TypeCategory::NonGenericContainer(NonGenericContainerKind::Counter)
287 }
288 "LoroTree" => TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroTree),
289 "LoroList" => TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroList),
290 "LoroMap" => TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroMap),
291 "LoroMovableList" => {
292 TypeCategory::NonGenericContainer(NonGenericContainerKind::LoroMovableList)
293 }
294 "List" | "Map" | "MovableList" | "Tree" => {
295 let kind = match ident.as_str() {
296 "List" => GenericContainerKind::List,
297 "Map" => GenericContainerKind::Map,
298 "MovableList" => GenericContainerKind::MovableList,
299 "Tree" => GenericContainerKind::Tree,
300 _ => unreachable!(),
301 };
302 let type_args = extract_type_args(&last_segment.arguments);
303 TypeCategory::GenericContainer(kind, type_args)
304 }
305 _ => TypeCategory::NestedLoroscope,
306 }
307 } else {
308 TypeCategory::NestedLoroscope
309 }
310}
311
312fn extract_type_args(args: &PathArguments) -> Vec<Type> {
313 match args {
314 PathArguments::AngleBracketed(ab) => ab
315 .args
316 .iter()
317 .filter_map(|a| {
318 if let GenericArgument::Type(t) = a {
319 Some(t.clone())
320 } else {
321 None
322 }
323 })
324 .collect(),
325 _ => Vec::new(),
326 }
327}
328
329// ---------------------------------------------------------------------------
330// Code generation — primitives
331// ---------------------------------------------------------------------------
332
333fn gen_primitive_getter(
334 field_name: &Ident,
335 field_name_str: &str,
336 prim: &PrimitiveKind,
337) -> proc_macro2::TokenStream {
338 match prim {
339 PrimitiveKind::F64 => quote! {
340 pub fn #field_name(&self) -> f64 {
341 self._map.get(#field_name_str)
342 .and_then(|v| v.into_value().ok())
343 .and_then(|v| match v {
344 ::loroscope::__private::LoroValue::Double(d) => Some(d),
345 _ => None,
346 })
347 .unwrap_or_default()
348 }
349 },
350 PrimitiveKind::I64 => quote! {
351 pub fn #field_name(&self) -> i64 {
352 self._map.get(#field_name_str)
353 .and_then(|v| v.into_value().ok())
354 .and_then(|v| match v {
355 ::loroscope::__private::LoroValue::I64(i) => Some(i),
356 _ => None,
357 })
358 .unwrap_or_default()
359 }
360 },
361 PrimitiveKind::Bool => quote! {
362 pub fn #field_name(&self) -> bool {
363 self._map.get(#field_name_str)
364 .and_then(|v| v.into_value().ok())
365 .and_then(|v| match v {
366 ::loroscope::__private::LoroValue::Bool(b) => Some(b),
367 _ => None,
368 })
369 .unwrap_or_default()
370 }
371 },
372 PrimitiveKind::String => quote! {
373 pub fn #field_name(&self) -> String {
374 self._map.get(#field_name_str)
375 .and_then(|v| v.into_value().ok())
376 .and_then(|v| match v {
377 ::loroscope::__private::LoroValue::String(s) => Some(s.to_string()),
378 _ => None,
379 })
380 .unwrap_or_default()
381 }
382 },
383 }
384}
385
386fn gen_primitive_setter(
387 field_name: &Ident,
388 field_name_str: &str,
389 prim: &PrimitiveKind,
390) -> proc_macro2::TokenStream {
391 let setter_name = Ident::new(&format!("set_{}", field_name), field_name.span());
392
393 match prim {
394 PrimitiveKind::String => quote! {
395 pub fn #setter_name(&self, val: &str) {
396 self._map.insert(#field_name_str, val).expect("insert into attached map");
397 }
398 },
399 PrimitiveKind::F64 => quote! {
400 pub fn #setter_name(&self, val: f64) {
401 self._map.insert(#field_name_str, val).expect("insert into attached map");
402 }
403 },
404 PrimitiveKind::I64 => quote! {
405 pub fn #setter_name(&self, val: i64) {
406 self._map.insert(#field_name_str, val).expect("insert into attached map");
407 }
408 },
409 PrimitiveKind::Bool => quote! {
410 pub fn #setter_name(&self, val: bool) {
411 self._map.insert(#field_name_str, val).expect("insert into attached map");
412 }
413 },
414 }
415}
416
417// ---------------------------------------------------------------------------
418// Code generation — non-generic containers
419// ---------------------------------------------------------------------------
420
421fn gen_non_generic_container_getter(
422 field_name: &Ident,
423 field_name_str: &str,
424 container: &NonGenericContainerKind,
425) -> proc_macro2::TokenStream {
426 let (loro_type, loroscope_type) = match container {
427 NonGenericContainerKind::Text => (
428 quote!(::loroscope::__private::LoroText),
429 quote!(::loroscope::Text),
430 ),
431 NonGenericContainerKind::Counter => (
432 quote!(::loroscope::__private::LoroCounter),
433 quote!(::loroscope::Counter),
434 ),
435 NonGenericContainerKind::LoroTree => (
436 quote!(::loroscope::__private::LoroTree),
437 quote!(::loroscope::__private::LoroTree),
438 ),
439 NonGenericContainerKind::LoroList => (
440 quote!(::loroscope::__private::LoroList),
441 quote!(::loroscope::__private::LoroList),
442 ),
443 NonGenericContainerKind::LoroMap => (
444 quote!(::loroscope::__private::LoroMap),
445 quote!(::loroscope::__private::LoroMap),
446 ),
447 NonGenericContainerKind::LoroMovableList => (
448 quote!(::loroscope::__private::LoroMovableList),
449 quote!(::loroscope::__private::LoroMovableList),
450 ),
451 };
452
453 quote! {
454 pub fn #field_name(&self) -> #loroscope_type {
455 self._map.get_or_create_container(#field_name_str, #loro_type::new()).expect("create container on attached map")
456 }
457 }
458}
459
460// ---------------------------------------------------------------------------
461// Code generation — generic containers
462// ---------------------------------------------------------------------------
463
464fn gen_generic_container_getter(
465 field_name: &Ident,
466 field_name_str: &str,
467 container: &GenericContainerKind,
468 type_args: &[Type],
469) -> proc_macro2::TokenStream {
470 let (loro_container, wrapper_path) = match container {
471 GenericContainerKind::List => (
472 quote!(::loroscope::__private::LoroList),
473 quote!(::loroscope::List),
474 ),
475 GenericContainerKind::Map => (
476 quote!(::loroscope::__private::LoroMap),
477 quote!(::loroscope::Map),
478 ),
479 GenericContainerKind::MovableList => (
480 quote!(::loroscope::__private::LoroMovableList),
481 quote!(::loroscope::MovableList),
482 ),
483 GenericContainerKind::Tree => (
484 quote!(::loroscope::__private::LoroTree),
485 quote!(::loroscope::Tree),
486 ),
487 };
488
489 quote! {
490 pub fn #field_name(&self) -> #wrapper_path<#(#type_args),*> {
491 #wrapper_path::new(
492 self._map.get_or_create_container(#field_name_str, #loro_container::new()).expect("create container on attached map")
493 )
494 }
495 }
496}
497
498// ---------------------------------------------------------------------------
499// Code generation — nested loroscope structs
500// ---------------------------------------------------------------------------
501
502fn gen_nested_getter(
503 field_name: &Ident,
504 field_name_str: &str,
505 field_ty: &Type,
506) -> proc_macro2::TokenStream {
507 quote! {
508 pub fn #field_name(&self) -> #field_ty {
509 ::loroscope::FromLoroMap::from_loro_map(
510 self._map.get_or_create_container(
511 #field_name_str,
512 ::loroscope::__private::LoroMap::new(),
513 ).expect("create container on attached map")
514 )
515 }
516 }
517}