diff --git a/ruma-events-macros/.gitignore b/ruma-events-macros/.gitignore new file mode 100644 index 00000000..69369904 --- /dev/null +++ b/ruma-events-macros/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +Cargo.lock diff --git a/ruma-events-macros/.travis.yml b/ruma-events-macros/.travis.yml new file mode 100644 index 00000000..681414c3 --- /dev/null +++ b/ruma-events-macros/.travis.yml @@ -0,0 +1,20 @@ +language: "rust" +cache: "cargo" +before_script: + - "rustup component add rustfmt" + - "rustup component add clippy" + - "cargo install --force cargo-audit" + - "cargo generate-lockfile" +script: + - "cargo audit" + - "cargo fmt --all -- --check" + - "cargo clippy --all-targets --all-features -- -D warnings" + - "cargo build --verbose" + - "cargo test --verbose" +if: "type != push OR (tag IS blank AND branch = master)" +notifications: + email: false + irc: + channels: + - secure: "HvfXk7XMbm+iDeGoNNO886q4xMOUqJncfAxqklG6FJMCVxyrf8afyyXveCxnTH1F5oDvJXw33m6YetEj1oc7RQcB3+36XkxhjC/IzmupfD9KsikGiamL9YDrfQopvY4RXqodTR3YEof7SkFkEAzuobT0QStemX6TCkC9a7BX1QpMvEbo1pS5wlswy2G2WDbiicoiS93su73AKTQ2jOmzFdwUDZdhpNnPNJqVm5TM2Am8tj6hbX6A2y2AecRZISf8rv8LhmgpZi97NjeeK4CbsQO7G4KANGr8RA7oxlgzbW2q7FbDupB6+zLT4a4/R5GjtJoi8pvaJSL9r2GYpP4VLTYF3+tJVfLbvmQVtUjhHE4masGYfnZgpgRtiH6o+DiF/ErSE/SjJEy/S8ujqXS9mjLFtSg6nLM4k4JdCr7MLrX0buNUsv5mtmhyUvYgJtd9E+ZxLHV5TG5lF28JPMrpKrEE5UvQr/xHZh+70AwCTI5jMoSPqpBwsyQ1agxTIDmiyuo60FhVUoLyiXn25m0ZIf7v1sg4A8vFq0zA9xnhpxtZATXa7StZQn1BH2k82kuyO0hkbFhEHTv25sWJdtaFy/vmrGdchxVy7ogdOXOjXkeg+0oAnOHMsRyZlVusQ4mixM/PYet860XNcW4P6P9Nz0u5ZNmagggXSKCpCqs3smY=" + use_notice: true diff --git a/ruma-events-macros/Cargo.toml b/ruma-events-macros/Cargo.toml new file mode 100644 index 00000000..2d808a5b --- /dev/null +++ b/ruma-events-macros/Cargo.toml @@ -0,0 +1,27 @@ +[package] +authors = ["Jimmy Cuadra "] +categories = ["api-bindings", "web-programming"] +description = "A procedural macro used by the ruma-events crate." +documentation = "https://docs.rs/ruma-events-macros" +edition = "2018" +homepage = "https://github.com/ruma/ruma-events-macros" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-events-macros" +readme = "README.md" +repository = "https://github.com/ruma/ruma-api-macros" +version = "0.1.0" + +[dependencies] +syn = { version = "1.0.4", features = ["full"] } +quote = "1.0.2" +proc-macro2 = "1.0.1" + +[lib] +proc-macro = true + +[dev-dependencies] +ruma-identifiers = "0.14.0" +serde_json = "1.0.40" +js_int = { version = "0.1.2", features = ["serde"] } +serde = { version = "1.0.99", features = ["derive"] } diff --git a/ruma-events-macros/LICENSE b/ruma-events-macros/LICENSE new file mode 100644 index 00000000..1ea21301 --- /dev/null +++ b/ruma-events-macros/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Jimmy Cuadra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/ruma-events-macros/README.md b/ruma-events-macros/README.md new file mode 100644 index 00000000..bc81d6df --- /dev/null +++ b/ruma-events-macros/README.md @@ -0,0 +1,13 @@ +# ruma-events-macros + +[![Build Status](https://travis-ci.org/ruma/ruma-events-macros.svg?branch=master)](https://travis-ci.org/ruma/ruma-events-macros) + +**ruma-events-macros** provides a procedural macro for easily generating event types for [ruma-events](https://github.com/ruma/ruma-events). + +## Documentation + +ruma-events-macros has [comprehensive documentation](https://docs.rs/ruma-events-macros) available on docs.rs. + +## License + +[MIT](http://opensource.org/licenses/MIT) diff --git a/ruma-events-macros/src/gen.rs b/ruma-events-macros/src/gen.rs new file mode 100644 index 00000000..c3fc9b96 --- /dev/null +++ b/ruma-events-macros/src/gen.rs @@ -0,0 +1,587 @@ +//! Details of generating code for the `ruma_event` procedural macro. + +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{ + parse::{self, Parse, ParseStream}, + parse_quote, + punctuated::Punctuated, + spanned::Spanned, + Attribute, Field, Ident, Path, Token, Type, +}; + +use crate::parse::{Content, EventKind, RumaEventInput}; + +/// The result of processing the `ruma_event` macro, ready for output back to source code. +pub struct RumaEvent { + /// Outer attributes on the field, such as a docstring. + attrs: Vec, + + /// Information for generating the type used for the event's `content` field. + content: Content, + + /// The name of the type of the event's `content` field. + content_name: Ident, + + /// The variant of `ruma_events::EventType` for this event, determined by the `event_type` + /// field. + event_type: Path, + + /// Struct fields of the event. + fields: Vec, + + /// Whether or not the event type is `EventType::Custom`. + is_custom: bool, + + /// The kind of event. + kind: EventKind, + + /// The name of the event. + name: Ident, +} + +impl From for RumaEvent { + fn from(input: RumaEventInput) -> Self { + let kind = input.kind; + let name = input.name; + let content_name = Ident::new(&format!("{}Content", &name), Span::call_site()); + let event_type = input.event_type; + let is_custom = is_custom_event_type(&event_type); + + let mut fields = match kind { + EventKind::Event => populate_event_fields( + is_custom, + content_name.clone(), + input.fields.unwrap_or_else(Vec::new), + ), + EventKind::RoomEvent => populate_room_event_fields( + is_custom, + content_name.clone(), + input.fields.unwrap_or_else(Vec::new), + ), + EventKind::StateEvent => populate_state_fields( + is_custom, + content_name.clone(), + input.fields.unwrap_or_else(Vec::new), + ), + }; + + fields.sort_unstable_by_key(|field| field.ident.clone().unwrap()); + + Self { + attrs: input.attrs, + content: input.content, + content_name, + event_type, + fields, + is_custom, + kind, + name, + } + } +} + +impl ToTokens for RumaEvent { + // TODO: Maybe break this off into functions so it's not so large. Then remove the clippy + // allowance. + #[allow(clippy::cognitive_complexity)] + fn to_tokens(&self, tokens: &mut TokenStream) { + let attrs = &self.attrs; + let content_name = &self.content_name; + let event_fields = &self.fields; + + let event_type = if self.is_custom { + quote! { + crate::EventType::Custom(self.event_type.clone()) + } + } else { + let event_type = &self.event_type; + + quote! { + #event_type + } + }; + + let name = &self.name; + let name_str = format!("{}", name); + let content_docstring = format!("The payload for `{}`.", name); + + let content = match &self.content { + Content::Struct(fields) => { + quote! { + #[doc = #content_docstring] + #[derive(Clone, Debug, PartialEq, serde::Serialize)] + pub struct #content_name { + #(#fields),* + } + } + } + Content::Typedef(typedef) => { + let content_attrs = &typedef.attrs; + let path = &typedef.path; + + quote! { + #(#content_attrs)* + pub type #content_name = #path; + } + } + }; + + let raw_content = match &self.content { + Content::Struct(fields) => { + quote! { + #[doc = #content_docstring] + #[derive(Clone, Debug, PartialEq, serde::Deserialize)] + pub struct #content_name { + #(#fields),* + } + } + } + Content::Typedef(_) => TokenStream::new(), + }; + + // Custom events will already have an event_type field. All other events need to account + // for this field being manually inserted in `Serialize` impls. + let mut base_field_count: usize = if self.is_custom { 0 } else { 1 }; + + // Keep track of all the optional fields, because we'll need to check at runtime if they + // are `Some` in order to increase the number of fields we tell serde to serialize. + let mut optional_field_idents = Vec::with_capacity(event_fields.len()); + + let mut try_from_field_values: Vec = Vec::with_capacity(event_fields.len()); + let mut serialize_field_calls: Vec = Vec::with_capacity(event_fields.len()); + + for field in event_fields { + let ident = field.ident.clone().unwrap(); + + let ident_str = if ident == "event_type" { + "type".to_string() + } else { + format!("{}", ident) + }; + + let span = field.span(); + + let try_from_field_value = if ident == "content" { + match &self.content { + Content::Struct(content_fields) => { + let mut content_field_values: Vec = + Vec::with_capacity(content_fields.len()); + + for content_field in content_fields { + let content_field_ident = content_field.ident.clone().unwrap(); + let span = content_field.span(); + + let token_stream = quote_spanned! {span=> + #content_field_ident: raw.content.#content_field_ident, + }; + + content_field_values.push(token_stream); + } + + quote_spanned! {span=> + content: #content_name { + #(#content_field_values)* + }, + } + } + Content::Typedef(_) => { + quote_spanned! {span=> + content: raw.content, + } + } + } + } else if ident == "prev_content" { + match &self.content { + Content::Struct(content_fields) => { + let mut content_field_values: Vec = + Vec::with_capacity(content_fields.len()); + + for content_field in content_fields { + let content_field_ident = content_field.ident.clone().unwrap(); + let span = content_field.span(); + + let token_stream = quote_spanned! {span=> + #content_field_ident: prev.#content_field_ident, + }; + + content_field_values.push(token_stream); + } + + quote_spanned! {span=> + prev_content: raw.prev_content.map(|prev| { + #content_name { + #(#content_field_values)* + } + }), + } + } + Content::Typedef(_) => { + quote_spanned! {span=> + prev_content: raw.prev_content, + } + } + } + } else { + quote_spanned! {span=> + #ident: raw.#ident, + } + }; + + try_from_field_values.push(try_from_field_value); + + // Does the same thing as #[serde(skip_serializing_if = "Option::is_none")] + let serialize_field_call = if is_option(&field.ty) { + optional_field_idents.push(ident.clone()); + + quote_spanned! {span=> + if self.#ident.is_some() { + state.serialize_field(#ident_str, &self.#ident)?; + } + } + } else { + base_field_count += 1; + + quote_spanned! {span=> + state.serialize_field(#ident_str, &self.#ident)?; + } + }; + + serialize_field_calls.push(serialize_field_call); + } + + let (manually_serialize_type_field, import_event_in_serialize_impl) = if self.is_custom { + (TokenStream::new(), TokenStream::new()) + } else { + let manually_serialize_type_field = quote! { + state.serialize_field("type", &self.event_type())?; + }; + + let import_event_in_serialize_impl = quote! { + use crate::Event as _; + }; + + ( + manually_serialize_type_field, + import_event_in_serialize_impl, + ) + }; + + let increment_struct_len_statements: Vec = optional_field_idents + .iter() + .map(|ident| { + let span = ident.span(); + + quote_spanned! {span=> + if self.#ident.is_some() { + len += 1; + } + } + }) + .collect(); + + let set_up_struct_serializer = quote! { + let mut len = #base_field_count; + + #(#increment_struct_len_statements)* + + let mut state = serializer.serialize_struct(#name_str, len)?; + }; + + let impl_room_event = match self.kind { + EventKind::RoomEvent | EventKind::StateEvent => { + quote! { + impl crate::RoomEvent for #name { + /// The unique identifier for the event. + fn event_id(&self) -> &ruma_identifiers::EventId { + &self.event_id + } + + /// Timestamp (milliseconds since the UNIX epoch) on originating homeserver when this event was + /// sent. + fn origin_server_ts(&self) -> js_int::UInt { + self.origin_server_ts + } + + /// The unique identifier for the room associated with this event. + /// + /// This can be `None` if the event came from a context where there is + /// no ambiguity which room it belongs to, like a `/sync` response for example. + fn room_id(&self) -> Option<&ruma_identifiers::RoomId> { + self.room_id.as_ref() + } + + /// The unique identifier for the user who sent this event. + fn sender(&self) -> &ruma_identifiers::UserId { + &self.sender + } + + /// Additional key-value pairs not signed by the homeserver. + fn unsigned(&self) -> Option<&serde_json::Value> { + self.unsigned.as_ref() + } + } + } + } + _ => TokenStream::new(), + }; + + let impl_state_event = if self.kind == EventKind::StateEvent { + quote! { + impl crate::StateEvent for #name { + /// The previous content for this state key, if any. + fn prev_content(&self) -> Option<&Self::Content> { + self.prev_content.as_ref() + } + + /// A key that determines which piece of room state the event represents. + fn state_key(&self) -> &str { + &self.state_key + } + } + } + } else { + TokenStream::new() + }; + + let impl_conversions_for_content = if let Content::Struct(content_fields) = &self.content { + let mut content_field_values: Vec = + Vec::with_capacity(content_fields.len()); + + for content_field in content_fields { + let content_field_ident = content_field.ident.clone().unwrap(); + let span = content_field.span(); + + let token_stream = quote_spanned! {span=> + #content_field_ident: raw.#content_field_ident, + }; + + content_field_values.push(token_stream); + } + + quote! { + impl<'de> serde::Deserialize<'de> for crate::EventResult<#content_name> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let json = serde_json::Value::deserialize(deserializer)?; + + let raw: raw::#content_name = match serde_json::from_value(json.clone()) { + Ok(raw) => raw, + Err(error) => { + return Ok(crate::EventResult::Err(crate::InvalidEvent( + crate::InnerInvalidEvent::Validation { + json, + message: error.to_string(), + }, + ))); + } + }; + + Ok(crate::EventResult::Ok(#content_name { + #(#content_field_values)* + })) + } + } + + } + } else { + TokenStream::new() + }; + + let output = quote!( + #(#attrs)* + #[derive(Clone, PartialEq, Debug)] + pub struct #name { + #(#event_fields),* + } + + #content + + impl<'de> serde::Deserialize<'de> for crate::EventResult<#name> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let json = serde_json::Value::deserialize(deserializer)?; + + let raw: raw::#name = match serde_json::from_value(json.clone()) { + Ok(raw) => raw, + Err(error) => { + return Ok(crate::EventResult::Err(crate::InvalidEvent( + crate::InnerInvalidEvent::Validation { + json, + message: error.to_string(), + }, + ))); + } + }; + + Ok(crate::EventResult::Ok(#name { + #(#try_from_field_values)* + })) + } + } + + #impl_conversions_for_content + + use serde::ser::SerializeStruct as _; + + impl serde::Serialize for #name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer + { + #import_event_in_serialize_impl + + #set_up_struct_serializer + + #(#serialize_field_calls)* + #manually_serialize_type_field + + state.end() + } + } + + impl crate::Event for #name { + /// The type of this event's `content` field. + type Content = #content_name; + + /// The event's content. + fn content(&self) -> &Self::Content { + &self.content + } + + /// The type of the event. + fn event_type(&self) -> crate::EventType { + #event_type + } + } + + #impl_room_event + + #impl_state_event + + /// "Raw" versions of the event and its content which implement `serde::Deserialize`. + mod raw { + use super::*; + + #(#attrs)* + #[derive(Clone, Debug, PartialEq, serde::Deserialize)] + pub struct #name { + #(#event_fields),* + } + + #raw_content + } + ); + + output.to_tokens(tokens); + } +} + +/// Fills in the event's struct definition with fields common to all basic events. +fn populate_event_fields( + is_custom: bool, + content_name: Ident, + mut fields: Vec, +) -> Vec { + let punctuated_fields: Punctuated = if is_custom { + parse_quote! { + /// The event's content. + pub content: #content_name, + + /// The custom type of the event. + pub event_type: String, + } + } else { + parse_quote! { + /// The event's content. + pub content: #content_name, + } + }; + + fields.extend(punctuated_fields.into_iter().map(|p| p.field)); + + fields +} + +/// Fills in the event's struct definition with fields common to all room events. +fn populate_room_event_fields( + is_custom: bool, + content_name: Ident, + fields: Vec, +) -> Vec { + let mut fields = populate_event_fields(is_custom, content_name, fields); + + let punctuated_fields: Punctuated = parse_quote! { + /// The unique identifier for the event. + pub event_id: ruma_identifiers::EventId, + + /// Timestamp (milliseconds since the UNIX epoch) on originating homeserver when this + /// event was sent. + pub origin_server_ts: js_int::UInt, + + /// The unique identifier for the room associated with this event. + pub room_id: Option, + + /// The unique identifier for the user who sent this event. + pub sender: ruma_identifiers::UserId, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: Option, + }; + + fields.extend(punctuated_fields.into_iter().map(|p| p.field)); + + fields +} + +/// Fills in the event's struct definition with fields common to all state events. +fn populate_state_fields(is_custom: bool, content_name: Ident, fields: Vec) -> Vec { + let mut fields = populate_room_event_fields(is_custom, content_name.clone(), fields); + + let punctuated_fields: Punctuated = parse_quote! { + /// The previous content for this state key, if any. + pub prev_content: Option<#content_name>, + + /// A key that determines which piece of room state the event represents. + pub state_key: String, + }; + + fields.extend(punctuated_fields.into_iter().map(|p| p.field)); + + fields +} + +/// Checks if the given `Path` refers to `EventType::Custom`. +fn is_custom_event_type(event_type: &Path) -> bool { + event_type.segments.last().unwrap().ident == "Custom" +} + +/// Checks if a type is an `Option`. +fn is_option(ty: &Type) -> bool { + if let Type::Path(ref type_path) = ty { + type_path.path.segments.first().unwrap().ident == "Option" + } else { + panic!("struct field had unexpected non-path type"); + } +} + +/// A wrapper around `syn::Field` that makes it possible to parse `Punctuated` +/// from a `TokenStream`. +/// +/// See https://github.com/dtolnay/syn/issues/651 for more context. +struct ParsableNamedField { + /// The wrapped `Field`. + pub field: Field, +} + +impl Parse for ParsableNamedField { + fn parse(input: ParseStream<'_>) -> parse::Result { + let field = Field::parse_named(input)?; + + Ok(Self { field }) + } +} diff --git a/ruma-events-macros/src/lib.rs b/ruma-events-macros/src/lib.rs new file mode 100644 index 00000000..6b7ea701 --- /dev/null +++ b/ruma-events-macros/src/lib.rs @@ -0,0 +1,139 @@ +//! Crate `ruma_events_macros` provides a procedural macro for generating +//! [ruma-events](https://github.com/ruma/ruma-events) events. +//! +//! See the documentation for the `ruma_event!` macro for usage details. +//! +#![deny( + missing_copy_implementations, + missing_debug_implementations, + // missing_docs, # Uncomment when https://github.com/rust-lang/rust/pull/60562 is released. + warnings +)] +#![warn( + clippy::empty_line_after_outer_attr, + clippy::expl_impl_clone_on_copy, + clippy::if_not_else, + clippy::items_after_statements, + clippy::match_same_arms, + clippy::mem_forget, + clippy::missing_docs_in_private_items, + clippy::multiple_inherent_impl, + clippy::mut_mut, + clippy::needless_borrow, + clippy::needless_continue, + clippy::single_match_else, + clippy::unicode_not_nfc, + clippy::use_self, + clippy::used_underscore_binding, + clippy::wrong_pub_self_convention, + clippy::wrong_self_convention +)] +#![recursion_limit = "128"] + +extern crate proc_macro; + +use proc_macro::TokenStream; +use quote::ToTokens; + +use crate::{gen::RumaEvent, parse::RumaEventInput}; + +mod gen; +mod parse; + +// A note about the `example` modules that appears in doctests: +// +// This is necessary because otherwise the expanded code appears in function context, which makes +// the compiler interpret the output of the macro as a statement, and proc macros currently aren't +// allowed to expand to statements, resulting in a compiler error. + +/// Generates a Rust type for a Matrix event. +/// +/// # Examples +/// +/// The most common form of event is a struct with all the standard fields for an event of its +/// kind and a struct for its `content` field: +/// +/// ```ignore +/// # pub mod example { +/// # use ruma_events_macros::ruma_event; +/// ruma_event! { +/// /// Informs the room about what room aliases it has been given. +/// AliasesEvent { +/// kind: StateEvent, +/// event_type: RoomAliases, +/// content: { +/// /// A list of room aliases. +/// pub aliases: Vec, +/// } +/// } +/// } +/// # } +/// ``` +/// +/// Occasionally an event will have non-standard fields at its top level (outside the `content` +/// field). These extra fields are declared in block labeled with `fields`: +/// +/// ```ignore +/// # pub mod example { +/// # use ruma_events_macros::ruma_event; +/// ruma_event! { +/// /// A redaction of an event. +/// RedactionEvent { +/// kind: RoomEvent, +/// event_type: RoomRedaction, +/// fields: { +/// /// The ID of the event that was redacted. +/// pub redacts: ruma_identifiers::EventId +/// }, +/// content: { +/// /// The reason for the redaction, if any. +/// pub reason: Option, +/// }, +/// } +/// } +/// # } +/// ``` +/// +/// Sometimes the type of the `content` should be a type alias rather than a struct or enum. This +/// is designated with `content_type_alias`: +/// +/// ```ignore +/// # pub mod example { +/// # use ruma_events_macros::ruma_event; +/// ruma_event! { +/// /// Informs the client about the rooms that are considered direct by a user. +/// DirectEvent { +/// kind: Event, +/// event_type: Direct, +/// content_type_alias: { +/// /// The payload of a `DirectEvent`. +/// /// +/// /// A mapping of `UserId`'s to a collection of `RoomId`'s which are considered +/// /// *direct* for that particular user. +/// std::collections::HashMap> +/// } +/// } +/// } +/// # } +/// ``` +/// +/// If `content` and `content_type_alias` are both supplied, the second one listed will overwrite +/// the first. +/// +/// The event type and content type will have copies generated inside a private `raw` module. These +/// "raw" versions are the same, except they implement `serde::Deserialize`. An implementation of +/// `std::str::FromStr` (and for completeness, `std::convert::TryFrom<&str>`) will be provided, +/// which will allow the user to call `parse` on a string slice of JSON data in attempt to convert +/// into the event type. `FromStr` attempts to deserialize the type using the "raw" version. If +/// deserialization fails, an error is returned to the user. If deserialization succeeds, a value of +/// the public event type will be populated from the raw version's fields and returned. If any +/// semantic error is found after deserialization, a `serde_json::Value` of the deserialized data +/// will be returned in an `InvalidEvent`. +#[proc_macro] +pub fn ruma_event(input: TokenStream) -> TokenStream { + let ruma_event_input = syn::parse_macro_input!(input as RumaEventInput); + + let ruma_event = RumaEvent::from(ruma_event_input); + + ruma_event.into_token_stream().into() +} diff --git a/ruma-events-macros/src/parse.rs b/ruma-events-macros/src/parse.rs new file mode 100644 index 00000000..e2fb6ba5 --- /dev/null +++ b/ruma-events-macros/src/parse.rs @@ -0,0 +1,289 @@ +//! Details of parsing input for the `ruma_event` procedural macro. + +use proc_macro2::Span; + +use syn::{ + braced, + parse::{self, Parse, ParseStream}, + punctuated::Punctuated, + token::Colon, + Attribute, Expr, Field, FieldValue, Ident, Member, Path, PathArguments, PathSegment, Token, + TypePath, +}; + +/// The entire `ruma_event!` macro structure directly as it appears in the source code.. +pub struct RumaEventInput { + /// Outer attributes on the field, such as a docstring. + pub attrs: Vec, + + /// The name of the event. + pub name: Ident, + + /// The kind of event, determiend by the `kind` field. + pub kind: EventKind, + + /// The variant of `ruma_events::EventType` for this event, determined by the `event_type` + /// field. + pub event_type: Path, + + /// Additional named struct fields in the top level event struct. + pub fields: Option>, + + /// A struct definition or type alias to be used as the event's `content` field. + pub content: Content, +} + +impl Parse for RumaEventInput { + fn parse(input: ParseStream<'_>) -> parse::Result { + let attrs = input.call(Attribute::parse_outer)?; + let name: Ident = input.parse()?; + let body; + braced!(body in input); + + let mut kind = None; + let mut event_type = None; + let mut fields = None; + let mut content = None; + + #[allow(clippy::identity_conversion)] + for field_value_inline_struct in + body.parse_terminated::(RumaEventField::parse)? + { + match field_value_inline_struct { + RumaEventField::Block(field_block) => { + let ident = match field_block.member { + Member::Named(ident) => ident, + Member::Unnamed(_) => panic!("fields with block values in `ruma_event!` must named `content_type_alias`"), + }; + + if ident == "content_type_alias" { + content = Some(Content::Typedef(field_block.typedef)); + } + } + RumaEventField::InlineStruct(field_inline_struct) => { + let ident = match field_inline_struct.member { + Member::Named(ident) => ident, + Member::Unnamed(_) => panic!("fields with inline struct values in `ruma_event!` must be named `fields` or `content`."), + }; + + if ident == "fields" { + fields = Some(field_inline_struct.fields); + } else if ident == "content" { + content = Some(Content::Struct(field_inline_struct.fields)); + } + } + RumaEventField::Value(field_value) => { + let ident = match field_value.member { + Member::Named(ident) => ident, + Member::Unnamed(_) => panic!("fields with expression values in `ruma_event!` must be named `kind` or `event_type`, ."), + }; + + if ident == "kind" { + let event_kind = match field_value.expr { + Expr::Path(expr_path) => { + if expr_path.path.is_ident("Event") { + EventKind::Event + } else if expr_path.path.is_ident("RoomEvent") { + EventKind::RoomEvent + } else if expr_path.path.is_ident("StateEvent") { + EventKind::StateEvent + } else { + panic!("value of field `kind` must be one of `Event`, `RoomEvent`, or `StateEvent`"); + } + } + _ => panic!( + "value of field `kind` is required to be an ident by `ruma_event!`" + ), + }; + + kind = Some(event_kind); + } else if ident == "event_type" { + match field_value.expr { + Expr::Path(expr_path) => { + if expr_path.path.segments.len() != 1 { + panic!("value of field `event_type` is required to be an ident by `ruma_event!`"); + } + + let path = expr_path.path; + let variant = path.segments.first().unwrap(); + + let mut punctuated = Punctuated::new(); + punctuated.push(PathSegment { + ident: Ident::new("crate", Span::call_site()), + arguments: PathArguments::None, + }); + punctuated.push(PathSegment { + ident: Ident::new("EventType", Span::call_site()), + arguments: PathArguments::None, + }); + punctuated.push(variant.clone()); + + event_type = Some(Path { + leading_colon: None, + segments: punctuated, + }); + } + _ => panic!( + "value of field `event_type` is required to be an ident by `ruma_event!`" + ), + } + } else { + panic!("unexpected field-value pair with field name `{}`", ident); + } + } + } + } + + if kind.is_none() { + panic!("field `kind` is required by `ruma_event!`"); + } else if event_type.is_none() { + panic!("field `event_type` is required by `ruma_event!`"); + } else if content.is_none() { + panic!( + "one field named `content` or `content_type_alias` is required by `ruma_event!`" + ); + } + + Ok(Self { + attrs, + name, + kind: kind.unwrap(), + event_type: event_type.unwrap(), + fields, + content: content.unwrap(), + }) + } +} + +/// Which kind of event is being generated. +/// +/// Determined by the `kind` field in the macro body. +#[derive(PartialEq)] +pub enum EventKind { + /// A basic event. + Event, + + /// A room event. + RoomEvent, + + /// A state event. + StateEvent, +} + +/// Information for generating the type used for the event's `content` field. +pub enum Content { + /// A struct, e.g. `ExampleEventContent { ... }`. + Struct(Vec), + + /// A type alias, e.g. `type ExampleEventContent = SomeExistingType` + Typedef(Typedef), +} + +/// The style of field within the macro body. +#[allow(clippy::large_enum_variant)] +enum RumaEventField { + /// The value of a field is a block with a type alias in it. + /// + /// Used for `content_type_alias`. + Block(FieldBlock), + + /// The value of a field is a block with named struct fields in it. + /// + /// Used for `content`. + InlineStruct(FieldInlineStruct), + + /// A standard named struct field. + /// + /// Used for `kind` and `event_type`. + Value(FieldValue), +} + +impl Parse for RumaEventField { + fn parse(input: ParseStream<'_>) -> parse::Result { + let ahead = input.fork(); + let field_ident: Ident = ahead.parse()?; + + match field_ident.to_string().as_ref() { + "content" | "fields" => { + let attrs = input.call(Attribute::parse_outer)?; + let member = input.parse()?; + let colon_token = input.parse()?; + let body; + braced!(body in input); + let fields = body + .parse_terminated::(Field::parse_named)? + .into_iter() + .collect(); + + Ok(RumaEventField::InlineStruct(FieldInlineStruct { + attrs, + member, + colon_token, + fields, + })) + } + "content_type_alias" => Ok(RumaEventField::Block(FieldBlock { + attrs: input.call(Attribute::parse_outer)?, + member: input.parse()?, + colon_token: input.parse()?, + typedef: input.parse()?, + })), + _ => Ok(RumaEventField::Value(input.parse()?)), + } + } +} + +/// The value of a field is a block with a type alias in it. +/// +/// Used for `content_type_alias`. +struct FieldBlock { + /// Outer attributes on the field, such as a docstring. + pub attrs: Vec, + + /// The name of the field. + pub member: Member, + + /// The colon that appears between the field name and type. + pub colon_token: Colon, + + /// The path to the type that will be used in a type alias for the event's `content` type. + pub typedef: Typedef, +} + +/// The value of a field is a block with named struct fields in it. +/// +/// Used for `content`. +struct FieldInlineStruct { + /// Outer attributes on the field, such as a docstring. + pub attrs: Vec, + + /// The name of the field. + pub member: Member, + + /// The colon that appears between the field name and type. + pub colon_token: Colon, + + /// The fields that define the `content` struct. + pub fields: Vec, +} + +/// Path to a type to be used in a type alias for an event's `content` type. +pub struct Typedef { + /// Outer attributes on the field, such as a docstring. + pub attrs: Vec, + + /// Path to the type. + pub path: TypePath, +} + +impl Parse for Typedef { + fn parse(input: ParseStream<'_>) -> parse::Result { + let body; + braced!(body in input); + + Ok(Self { + attrs: body.call(Attribute::parse_outer)?, + path: body.parse()?, + }) + } +} diff --git a/ruma-events-macros/tests/ruma_events_macros.rs b/ruma-events-macros/tests/ruma_events_macros.rs new file mode 100644 index 00000000..dd47dc7b --- /dev/null +++ b/ruma-events-macros/tests/ruma_events_macros.rs @@ -0,0 +1,345 @@ +use std::fmt::{Debug, Display, Formatter, Result as FmtResult}; + +use serde::{ + de::{Error as SerdeError, Visitor}, + Deserialize, Deserializer, Serialize, Serializer, +}; + +/// The type of an event. +#[derive(Clone, Debug, PartialEq)] +pub enum EventType { + /// m.direct + Direct, + + /// m.room.aliases + RoomAliases, + + /// m.room.redaction + RoomRedaction, + + /// Any event that is not part of the specification. + Custom(String), +} + +impl Display for EventType { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let event_type_str = match *self { + EventType::Direct => "m.direct", + EventType::RoomAliases => "m.room.aliases", + EventType::RoomRedaction => "m.room.redaction", + EventType::Custom(ref event_type) => event_type, + }; + + write!(f, "{}", event_type_str) + } +} + +impl<'a> From<&'a str> for EventType { + fn from(s: &'a str) -> EventType { + match s { + "m.direct" => EventType::Direct, + "m.room.aliases" => EventType::RoomAliases, + "m.room.redaction" => EventType::RoomRedaction, + event_type => EventType::Custom(event_type.to_string()), + } + } +} + +impl Serialize for EventType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for EventType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct EventTypeVisitor; + + impl<'de> Visitor<'de> for EventTypeVisitor { + type Value = EventType; + + fn expecting(&self, formatter: &mut Formatter<'_>) -> FmtResult { + write!(formatter, "a Matrix event type as a string") + } + + fn visit_str(self, v: &str) -> Result + where + E: SerdeError, + { + Ok(EventType::from(v)) + } + } + + deserializer.deserialize_str(EventTypeVisitor) + } +} + +/// The result of deserializing an event, which may or may not be valid. +#[derive(Debug)] +pub enum EventResult { + /// `T` deserialized and validated successfully. + Ok(T), + + /// `T` deserialized but was invalid. + /// + /// `InvalidEvent` contains the original input. + Err(InvalidEvent), +} + +impl EventResult { + /// Convert `EventResult` into the equivalent `std::result::Result`. + pub fn into_result(self) -> Result { + match self { + EventResult::Ok(t) => Ok(t), + EventResult::Err(invalid_event) => Err(invalid_event), + } + } +} + +/// A basic event. +pub trait Event +where + Self: Debug + Serialize, +{ + /// The type of this event's `content` field. + type Content: Debug + Serialize; + + /// The event's content. + fn content(&self) -> &Self::Content; + + /// The type of the event. + fn event_type(&self) -> EventType; +} + +/// An event within the context of a room. +pub trait RoomEvent: Event { + /// The unique identifier for the event. + fn event_id(&self) -> &ruma_identifiers::EventId; + + /// Timestamp (milliseconds since the UNIX epoch) on originating homeserver when this event was + /// sent. + fn origin_server_ts(&self) -> js_int::UInt; + + /// The unique identifier for the room associated with this event. + /// + /// This can be `None` if the event came from a context where there is + /// no ambiguity which room it belongs to, like a `/sync` response for example. + fn room_id(&self) -> Option<&ruma_identifiers::RoomId>; + + /// The unique identifier for the user who sent this event. + fn sender(&self) -> &ruma_identifiers::UserId; + + /// Additional key-value pairs not signed by the homeserver. + fn unsigned(&self) -> Option<&serde_json::Value>; +} + +/// An event that describes persistent state about a room. +pub trait StateEvent: RoomEvent { + /// The previous content for this state key, if any. + fn prev_content(&self) -> Option<&Self::Content>; + + /// A key that determines which piece of room state the event represents. + fn state_key(&self) -> &str; +} + +/// An event that is malformed or otherwise invalid. +/// +/// When attempting to create an event from a string of JSON data, an error in the input data may +/// cause deserialization to fail, or the JSON structure may not corresponded to ruma-events's +/// strict definition of the event's schema. If deserialization completely fails, this type will +/// provide a message with details about the deserialization error. If deserialization succeeds but +/// the event is otherwise invalid, a similar message will be provided, as well as a +/// `serde_json::Value` containing the raw JSON data as it was deserialized. +#[derive(Debug)] +pub struct InvalidEvent(InnerInvalidEvent); + +/// An event that is malformed or otherwise invalid. +#[derive(Debug)] +enum InnerInvalidEvent { + /// An event that deserialized but failed validation. + Validation { + /// The raw `serde_json::Value` representation of the invalid event. + json: serde_json::Value, + + /// An message describing why the event was invalid. + message: String, + }, +} + +// See note about wrapping macro expansion in a module from `src/lib.rs` +pub mod common_case { + use std::convert::TryFrom; + + use js_int::UInt; + use ruma_events_macros::ruma_event; + use ruma_identifiers::{EventId, RoomAliasId, RoomId, UserId}; + use serde_json::Value; + + use super::EventResult; + + ruma_event! { + /// Informs the room about what room aliases it has been given. + AliasesEvent { + kind: StateEvent, + event_type: RoomAliases, + content: { + /// A list of room aliases. + pub aliases: Vec, + } + } + } + + #[test] + fn serialization_with_optional_fields_as_none() { + let event = AliasesEvent { + content: AliasesEventContent { + aliases: Vec::with_capacity(0), + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UInt::try_from(1).unwrap(), + prev_content: None, + room_id: None, + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "example.com".to_string(), + unsigned: None, + }; + + let actual = serde_json::to_string(&event).unwrap(); + let expected = r#"{"content":{"aliases":[]},"event_id":"$h29iv0s8:example.com","origin_server_ts":1,"sender":"@carl:example.com","state_key":"example.com","type":"m.room.aliases"}"#; + + assert_eq!(actual, expected); + } + + #[test] + fn serialization_with_some_optional_fields_as_some() { + let event = AliasesEvent { + content: AliasesEventContent { + aliases: vec![RoomAliasId::try_from("#room:example.org").unwrap()], + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UInt::try_from(1).unwrap(), + prev_content: Some(AliasesEventContent { + aliases: Vec::with_capacity(0), + }), + room_id: Some(RoomId::try_from("!n8f893n9:example.com").unwrap()), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "example.com".to_string(), + unsigned: None, + }; + + let actual = serde_json::to_string(&event).unwrap(); + let expected = r##"{"content":{"aliases":["#room:example.org"]},"event_id":"$h29iv0s8:example.com","origin_server_ts":1,"prev_content":{"aliases":[]},"room_id":"!n8f893n9:example.com","sender":"@carl:example.com","state_key":"example.com","type":"m.room.aliases"}"##; + + assert_eq!(actual, expected); + } + + #[test] + fn serialization_with_all_optional_fields_as_some() { + let event = AliasesEvent { + content: AliasesEventContent { + aliases: vec![RoomAliasId::try_from("#room:example.org").unwrap()], + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UInt::try_from(1).unwrap(), + prev_content: Some(AliasesEventContent { + aliases: Vec::with_capacity(0), + }), + room_id: Some(RoomId::try_from("!n8f893n9:example.com").unwrap()), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "example.com".to_string(), + unsigned: Some(serde_json::from_str::(r#"{"foo":"bar"}"#).unwrap()), + }; + + let actual = serde_json::to_string(&event).unwrap(); + let expected = r##"{"content":{"aliases":["#room:example.org"]},"event_id":"$h29iv0s8:example.com","origin_server_ts":1,"prev_content":{"aliases":[]},"room_id":"!n8f893n9:example.com","sender":"@carl:example.com","state_key":"example.com","unsigned":{"foo":"bar"},"type":"m.room.aliases"}"##; + + assert_eq!(actual, expected); + } + + #[test] + fn deserialization() { + let json = r##"{"content":{"aliases":["#room:example.org"]},"event_id":"$h29iv0s8:example.com","origin_server_ts":1,"prev_content":{"aliases":[]},"room_id":"!n8f893n9:example.com","sender":"@carl:example.com","state_key":"example.com","unsigned":{"foo":"bar"},"type":"m.room.aliases"}"##; + + let event_result: EventResult = serde_json::from_str(json).unwrap(); + let actual: AliasesEvent = event_result.into_result().unwrap(); + + let expected = AliasesEvent { + content: AliasesEventContent { + aliases: vec![RoomAliasId::try_from("#room:example.org").unwrap()], + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UInt::try_from(1).unwrap(), + prev_content: Some(AliasesEventContent { + aliases: Vec::with_capacity(0), + }), + room_id: Some(RoomId::try_from("!n8f893n9:example.com").unwrap()), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "example.com".to_string(), + unsigned: Some(serde_json::from_str::(r#"{"foo":"bar"}"#).unwrap()), + }; + + assert_eq!(actual, expected); + } +} + +pub mod custom_event_type { + use ruma_events_macros::ruma_event; + use serde_json::Value; + + ruma_event! { + /// A custom event. + CustomEvent { + kind: Event, + event_type: Custom, + content_type_alias: { + /// The payload for `CustomEvent`. + Value + }, + } + } +} + +pub mod extra_fields { + use ruma_events_macros::ruma_event; + + ruma_event! { + /// A redaction of an event. + RedactionEvent { + kind: RoomEvent, + event_type: RoomRedaction, + fields: { + /// The ID of the event that was redacted. + pub redacts: ruma_identifiers::EventId + }, + content: { + /// The reason for the redaction, if any. + pub reason: Option, + }, + } + } +} + +pub mod type_alias { + use ruma_events_macros::ruma_event; + + ruma_event! { + /// Informs the client about the rooms that are considered direct by a user. + DirectEvent { + kind: Event, + event_type: Direct, + content_type_alias: { + /// The payload of a `DirectEvent`. + /// + /// A mapping of `UserId`'s to a collection of `RoomId`'s which are considered + /// *direct* for that particular user. + std::collections::HashMap> + } + } + } +}