Repository: ruma/ruma (rust)

Issue:
Add helper method to create replacements (edits)
We have methods to help create replies, so we should probably have one to create a replacement ([MSC2676](https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/2676-message-editing.md)), which will be part of the spec in v1.4.

In general, it's only about providing a fallback representation in `content` and put the new content in `m.new_content`. The representation of a fallback is however not defined.

There is also a more complicated use case when it is the replacement of a reply, where we also need the reply fallback in the `content` of the event.

Pull request:
events: Add method to construct a replacement
Closes #1292.

<!-- Replace -->
----
Preview: https://pr-1342--ruma-docs.surge.sh
<!-- Replace -->


Patch:
diff --git a/crates/ruma-client-api/src/media/create_content_async.rs b/crates/ruma-client-api/src/media/create_content_async.rs
index eb23097e..6c037292 100644
--- a/crates/ruma-client-api/src/media/create_content_async.rs
+++ b/crates/ruma-client-api/src/media/create_content_async.rs
@@ -19,14 +19,14 @@ pub mod unstable {
         }
 
         request: {
-            /// The media ID from the mxc:// URI (the path component).
-            #[ruma_api(path)]
-            pub media_id: &'a str,
-
             /// The server name from the mxc:// URI (the authoritory component).
             #[ruma_api(path)]
             pub server_name: &'a ServerName,
 
+            /// The media ID from the mxc:// URI (the path component).
+            #[ruma_api(path)]
+            pub media_id: &'a str,
+
             /// The file contents to upload.
             #[ruma_api(raw_body)]
             pub file: &'a [u8],
diff --git a/crates/ruma-client-api/src/media/get_content.rs b/crates/ruma-client-api/src/media/get_content.rs
index 8ff722cd..015367cb 100644
--- a/crates/ruma-client-api/src/media/get_content.rs
+++ b/crates/ruma-client-api/src/media/get_content.rs
@@ -25,14 +25,14 @@ pub mod v3 {
         }
 
         request: {
-            /// The media ID from the mxc:// URI (the path component).
-            #[ruma_api(path)]
-            pub media_id: &'a str,
-
             /// The server name from the mxc:// URI (the authoritory component).
             #[ruma_api(path)]
             pub server_name: &'a ServerName,
 
+            /// The media ID from the mxc:// URI (the path component).
+            #[ruma_api(path)]
+            pub media_id: &'a str,
+
             /// Whether to fetch media deemed remote.
             ///
             /// Used to prevent routing loops. Defaults to `true`.
diff --git a/crates/ruma-client-api/src/media/get_content_as_filename.rs b/crates/ruma-client-api/src/media/get_content_as_filename.rs
index c45ab8b8..e57002a3 100644
--- a/crates/ruma-client-api/src/media/get_content_as_filename.rs
+++ b/crates/ruma-client-api/src/media/get_content_as_filename.rs
@@ -23,14 +23,14 @@ pub mod v3 {
         }
 
         request: {
-            /// The media ID from the mxc:// URI (the path component).
-            #[ruma_api(path)]
-            pub media_id: &'a str,
-
             /// The server name from the mxc:// URI (the authoritory component).
             #[ruma_api(path)]
             pub server_name: &'a ServerName,
 
+            /// The media ID from the mxc:// URI (the path component).
+            #[ruma_api(path)]
+            pub media_id: &'a str,
+
             /// The filename to return in the `Content-Disposition` header.
             #[ruma_api(path)]
             pub filename: &'a str,
diff --git a/crates/ruma-client-api/src/media/get_content_thumbnail.rs b/crates/ruma-client-api/src/media/get_content_thumbnail.rs
index 298fac62..bcbccf07 100644
--- a/crates/ruma-client-api/src/media/get_content_thumbnail.rs
+++ b/crates/ruma-client-api/src/media/get_content_thumbnail.rs
@@ -24,14 +24,14 @@ pub mod v3 {
         }
 
         request: {
-            /// The media ID from the mxc:// URI (the path component).
-            #[ruma_api(path)]
-            pub media_id: &'a str,
-
             /// The server name from the mxc:// URI (the authoritory component).
             #[ruma_api(path)]
             pub server_name: &'a ServerName,
 
+            /// The media ID from the mxc:// URI (the path component).
+            #[ruma_api(path)]
+            pub media_id: &'a str,
+
             /// The desired resizing method.
             #[ruma_api(query)]
             #[serde(skip_serializing_if = "Option::is_none")]
diff --git a/crates/ruma-client-api/src/typing/create_typing_event.rs b/crates/ruma-client-api/src/typing/create_typing_event.rs
index d77aee51..f3d3c851 100644
--- a/crates/ruma-client-api/src/typing/create_typing_event.rs
+++ b/crates/ruma-client-api/src/typing/create_typing_event.rs
@@ -23,14 +23,14 @@ pub mod v3 {
         }
 
         request: {
-            /// The user who has started to type.
-            #[ruma_api(path)]
-            pub user_id: &'a UserId,
-
             /// The room in which the user is typing.
             #[ruma_api(path)]
             pub room_id: &'a RoomId,
 
+            /// The user who has started to type.
+            #[ruma_api(path)]
+            pub user_id: &'a UserId,
+
             /// Whether the user is typing within a length of time or not.
             #[serde(flatten)]
             pub state: Typing,
diff --git a/crates/ruma-common/src/doc/rich_reply.md b/crates/ruma-common/src/doc/rich_reply.md
index 1097e0c9..0c7c2dc3 100644
--- a/crates/ruma-common/src/doc/rich_reply.md
+++ b/crates/ruma-common/src/doc/rich_reply.md
@@ -7,7 +7,7 @@ the previous message, for which the room ID is required. If you want to reply to
 If the message was edited, the previous message should be the original message that was edited,
 with the content of its replacement, to allow the fallback to be accurate at the time it is added.
 
-It is recommended to enable the `sanitize` feature when using this method as this will
+It is recommended to enable the `unstable-sanitize` feature when using this method as this will
 clean up nested [rich reply fallbacks] in chains of replies. This uses [`sanitize_html()`]
 internally, with [`RemoveReplyFallback::Yes`].
 
diff --git a/crates/ruma-common/src/events/message.rs b/crates/ruma-common/src/events/message.rs
index 7c129790..9351d3ad 100644
--- a/crates/ruma-common/src/events/message.rs
+++ b/crates/ruma-common/src/events/message.rs
@@ -238,12 +238,9 @@ impl Text {
     /// Returns `None` if no Markdown formatting was found.
     #[cfg(feature = "markdown")]
     pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
-        let body = body.as_ref();
-        let mut html_body = String::new();
+        use super::room::message::parse_markdown;
 
-        pulldown_cmark::html::push_html(&mut html_body, pulldown_cmark::Parser::new(body));
-
-        (html_body != format!("<p>{}</p>\n", body)).then(|| Self::html(html_body))
+        parse_markdown(body.as_ref()).map(Self::html)
     }
 
     fn default_mimetype() -> String {
diff --git a/crates/ruma-common/src/events/room/message.rs b/crates/ruma-common/src/events/room/message.rs
index 21816aeb..13d7bd56 100644
--- a/crates/ruma-common/src/events/room/message.rs
+++ b/crates/ruma-common/src/events/room/message.rs
@@ -218,6 +218,81 @@ impl RoomMessageEventContent {
         self
     }
 
+    /// Turns `self` into a [replacement] (or edit) for the message with the given event ID.
+    ///
+    /// This takes the content and sets it in `m.new_content`, and modifies the `content` to include
+    /// a fallback.
+    ///
+    /// If the message that is replaced is a reply to another message, the latter should also be
+    /// provided to be able to generate a rich reply fallback that takes the `body` /
+    /// `formatted_body` (if any) in `self` for the main text and prepends a quoted version of
+    /// `original_message`.
+    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
+    ///
+    /// # Panics
+    ///
+    /// Panics if `self` has a `formatted_body` with a format other than HTML.
+    ///
+    /// [replacement]: https://spec.matrix.org/v1.4/client-server-api/#event-replacements
+    #[track_caller]
+    pub fn make_replacement(
+        mut self,
+        original_message_id: OwnedEventId,
+        replied_to_message: Option<&OriginalRoomMessageEvent>,
+    ) -> Self {
+        // Prepare relates_to with the untouched msgtype.
+        let relates_to = Relation::Replacement(Replacement {
+            event_id: original_message_id,
+            new_content: self.msgtype.clone(),
+        });
+
+        let empty_formatted_body = || FormattedBody::html(String::new());
+
+        let (body, formatted) = {
+            match &mut self.msgtype {
+                MessageType::Emote(m) => {
+                    (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
+                }
+                MessageType::Notice(m) => {
+                    (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
+                }
+                MessageType::Text(m) => {
+                    (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
+                }
+                MessageType::Audio(m) => (&mut m.body, None),
+                MessageType::File(m) => (&mut m.body, None),
+                MessageType::Image(m) => (&mut m.body, None),
+                MessageType::Location(m) => (&mut m.body, None),
+                MessageType::ServerNotice(m) => (&mut m.body, None),
+                MessageType::Video(m) => (&mut m.body, None),
+                MessageType::VerificationRequest(m) => (&mut m.body, None),
+                MessageType::_Custom(m) => (&mut m.body, None),
+            }
+        };
+
+        // Add replacement fallback.
+        *body = format!("* {body}");
+
+        if let Some(f) = formatted {
+            assert_eq!(
+                f.format,
+                MessageFormat::Html,
+                "make_replacement can't handle non-HTML formatted messages"
+            );
+
+            f.body = format!("* {}", f.body);
+        }
+
+        // Add reply fallback if needed.
+        if let Some(original_message) = replied_to_message {
+            self = self.make_reply_to(original_message, ForwardThread::No);
+        }
+
+        self.relates_to = Some(relates_to);
+
+        self
+    }
+
     /// Returns a reference to the `msgtype` string.
     ///
     /// If you want to access the message type-specific data rather than the message type itself,
@@ -592,12 +667,7 @@ impl FormattedBody {
     /// Returns `None` if no Markdown formatting was found.
     #[cfg(feature = "markdown")]
     pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
-        let body = body.as_ref();
-        let mut html_body = String::new();
-
-        pulldown_cmark::html::push_html(&mut html_body, pulldown_cmark::Parser::new(body));
-
-        (html_body != format!("<p>{}</p>\n", body)).then(|| Self::html(html_body))
+        parse_markdown(body.as_ref()).map(Self::html)
     }
 
     /// Sanitize this `FormattedBody` if its format is `MessageFormat::Html`.
@@ -636,3 +706,45 @@ pub struct CustomEventContent {
     #[serde(flatten)]
     data: JsonObject,
 }
+
+#[cfg(feature = "markdown")]
+pub(crate) fn parse_markdown(text: &str) -> Option<String> {
+    use pulldown_cmark::{Event, Options, Parser, Tag};
+
+    const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
+
+    let mut found_first_paragraph = false;
+
+    let has_markdown = Parser::new_ext(text, OPTIONS).any(|ref event| {
+        let is_text = matches!(event, Event::Text(_));
+        let is_break = matches!(event, Event::SoftBreak | Event::HardBreak);
+        let is_first_paragraph_start = if matches!(event,
+            Event::Start(tag)
+                if matches!(tag, Tag::Paragraph)
+        ) {
+            if found_first_paragraph {
+                false
+            } else {
+                found_first_paragraph = true;
+                true
+            }
+        } else {
+            false
+        };
+        let is_paragraph_end = matches!(event,
+                Event::End(tag)
+                if matches!(tag, Tag::Paragraph)
+        );
+
+        !is_text && !is_break && !is_first_paragraph_start && !is_paragraph_end
+    });
+
+    if !has_markdown {
+        return None;
+    }
+
+    let mut html_body = String::new();
+    pulldown_cmark::html::push_html(&mut html_body, Parser::new_ext(text, OPTIONS));
+
+    Some(html_body)
+}
diff --git a/crates/ruma-macros/src/api.rs b/crates/ruma-macros/src/api.rs
index 41dfb797..7ea57710 100644
--- a/crates/ruma-macros/src/api.rs
+++ b/crates/ruma-macros/src/api.rs
@@ -12,7 +12,12 @@ use syn::{
     Attribute, Field, Token, Type,
 };
 
-use self::{api_metadata::Metadata, api_request::Request, api_response::Response};
+use self::{
+    api_metadata::Metadata,
+    api_request::Request,
+    api_response::Response,
+    request::{RequestField, RequestFieldKind},
+};
 use crate::util::import_ruma_common;
 
 mod api_metadata;
@@ -50,7 +55,8 @@ pub struct Api {
 
 impl Api {
     pub fn expand_all(self) -> TokenStream {
-        let maybe_error = ensure_feature_presence().map(syn::Error::to_compile_error);
+        let maybe_feature_error = ensure_feature_presence().map(syn::Error::to_compile_error);
+        let maybe_path_error = self.check_paths().err().map(syn::Error::into_compile_error);
 
         let ruma_common = import_ruma_common();
         let http = quote! { #ruma_common::exports::http };
@@ -79,7 +85,8 @@ impl Api {
         let metadata_doc = format!("Metadata for the `{}` API endpoint.", name.value());
 
         quote! {
-            #maybe_error
+            #maybe_feature_error
+            #maybe_path_error
 
             #[doc = #metadata_doc]
             pub const METADATA: #ruma_common::api::Metadata = #ruma_common::api::Metadata {
@@ -103,6 +110,54 @@ impl Api {
             type _SilenceUnusedError = #error_ty;
         }
     }
+
+    fn check_paths(&self) -> syn::Result<()> {
+        let mut path_iter = self
+            .metadata
+            .unstable_path
+            .iter()
+            .chain(&self.metadata.r0_path)
+            .chain(&self.metadata.stable_path);
+
+        let path = path_iter.next().ok_or_else(|| {
+            syn::Error::new(Span::call_site(), "at least one path metadata field must be set")
+        })?;
+        let path_args = get_path_args(&path.value());
+
+        for extra_path in path_iter {
+            let extra_path_args = get_path_args(&extra_path.value());
+            if extra_path_args != path_args {
+                return Err(syn::Error::new(
+                    Span::call_site(),
+                    "paths have different path parameters",
+                ));
+            }
+        }
+
+        if let Some(req) = &self.request {
+            let path_field_names: Vec<_> = req
+                .fields
+                .iter()
+                .cloned()
+                .filter_map(|f| match RequestField::try_from(f) {
+                    Ok(RequestField { kind: RequestFieldKind::Path, inner }) => {
+                        Some(Ok(inner.ident.unwrap().to_string()))
+                    }
+                    Ok(_) => None,
+                    Err(e) => Some(Err(e)),
+                })
+                .collect::<syn::Result<_>>()?;
+
+            if path_args != path_field_names {
+                return Err(syn::Error::new_spanned(
+                    req.request_kw,
+                    "path fields must be in the same order as they appear in the path segments",
+                ));
+            }
+        }
+
+        Ok(())
+    }
 }
 
 impl Parse for Api {
@@ -215,3 +270,7 @@ fn ensure_feature_presence() -> Option<&'static syn::Error> {
 
     RESULT.as_ref().err()
 }
+
+fn get_path_args(path: &str) -> Vec<String> {
+    path.split('/').filter_map(|s| s.strip_prefix(':').map(ToOwned::to_owned)).collect()
+}
diff --git a/crates/ruma-macros/src/api/api_request.rs b/crates/ruma-macros/src/api/api_request.rs
index d3aa957c..88aed2bb 100644
--- a/crates/ruma-macros/src/api/api_request.rs
+++ b/crates/ruma-macros/src/api/api_request.rs
@@ -84,9 +84,6 @@ impl Request {
 
         let method = &metadata.method;
         let authentication = &metadata.authentication;
-        let unstable_attr = metadata.unstable_path.as_ref().map(|p| quote! { unstable = #p, });
-        let r0_attr = metadata.r0_path.as_ref().map(|p| quote! { r0 = #p, });
-        let stable_attr = metadata.stable_path.as_ref().map(|p| quote! { stable = #p, });
 
         let request_ident = Ident::new("Request", self.request_kw.span());
         let lifetimes = self.all_lifetimes();
@@ -107,9 +104,6 @@ impl Request {
             #[ruma_api(
                 method = #method,
                 authentication = #authentication,
-                #unstable_attr
-                #r0_attr
-                #stable_attr
                 error_ty = #error_ty,
             )]
             #( #struct_attributes )*
diff --git a/crates/ruma-macros/src/api/attribute.rs b/crates/ruma-macros/src/api/attribute.rs
index d0a1d50b..14e9b426 100644
--- a/crates/ruma-macros/src/api/attribute.rs
+++ b/crates/ruma-macros/src/api/attribute.rs
@@ -2,7 +2,7 @@
 
 use syn::{
     parse::{Parse, ParseStream},
-    Ident, LitStr, Token, Type,
+    Ident, Token, Type,
 };
 
 mod kw {
@@ -62,9 +62,6 @@ pub enum DeriveRequestMeta {
     Authentication(Type),
     Method(Type),
     ErrorTy(Type),
-    UnstablePath(LitStr),
-    R0Path(LitStr),
-    StablePath(LitStr),
 }
 
 impl Parse for DeriveRequestMeta {
@@ -82,18 +79,6 @@ impl Parse for DeriveRequestMeta {
             let _: kw::error_ty = input.parse()?;
             let _: Token![=] = input.parse()?;
             input.parse().map(Self::ErrorTy)
-        } else if lookahead.peek(kw::unstable) {
-            let _: kw::unstable = input.parse()?;
-            let _: Token![=] = input.parse()?;
-            input.parse().map(Self::UnstablePath)
-        } else if lookahead.peek(kw::r0) {
-            let _: kw::r0 = input.parse()?;
-            let _: Token![=] = input.parse()?;
-            input.parse().map(Self::R0Path)
-        } else if lookahead.peek(kw::stable) {
-            let _: kw::stable = input.parse()?;
-            let _: Token![=] = input.parse()?;
-            input.parse().map(Self::StablePath)
         } else {
             Err(lookahead.error())
         }
diff --git a/crates/ruma-macros/src/api/request.rs b/crates/ruma-macros/src/api/request.rs
index 865fe8f3..bccd3c23 100644
--- a/crates/ruma-macros/src/api/request.rs
+++ b/crates/ruma-macros/src/api/request.rs
@@ -1,4 +1,4 @@
-use std::collections::{BTreeMap, BTreeSet};
+use std::collections::BTreeSet;
 
 use proc_macro2::TokenStream;
 use quote::{quote, ToTokens};
@@ -6,7 +6,7 @@ use syn::{
     parse::{Parse, ParseStream},
     parse_quote,
     punctuated::Punctuated,
-    DeriveInput, Field, Generics, Ident, Lifetime, LitStr, Token, Type,
+    DeriveInput, Field, Generics, Ident, Lifetime, Token, Type,
 };
 
 use super::{
@@ -49,9 +49,6 @@ pub fn expand_derive_request(input: DeriveInput) -> syn::Result<TokenStream> {
     let mut authentication = None;
     let mut error_ty = None;
     let mut method = None;
-    let mut unstable_path = None;
-    let mut r0_path = None;
-    let mut stable_path = None;
 
     for attr in input.attrs {
         if !attr.path.is_ident("ruma_api") {
@@ -65,9 +62,6 @@ pub fn expand_derive_request(input: DeriveInput) -> syn::Result<TokenStream> {
                 DeriveRequestMeta::Authentication(t) => authentication = Some(parse_quote!(#t)),
                 DeriveRequestMeta::Method(t) => method = Some(parse_quote!(#t)),
                 DeriveRequestMeta::ErrorTy(t) => error_ty = Some(t),
-                DeriveRequestMeta::UnstablePath(s) => unstable_path = Some(s),
-                DeriveRequestMeta::R0Path(s) => r0_path = Some(s),
-                DeriveRequestMeta::StablePath(s) => stable_path = Some(s),
             }
         }
     }
@@ -79,9 +73,6 @@ pub fn expand_derive_request(input: DeriveInput) -> syn::Result<TokenStream> {
         lifetimes,
         authentication: authentication.expect("missing authentication attribute"),
         method: method.expect("missing method attribute"),
-        unstable_path,
-        r0_path,
-        stable_path,
         error_ty: error_ty.expect("missing error_ty attribute"),
     };
 
@@ -105,9 +96,6 @@ struct Request {
 
     authentication: AuthScheme,
     method: Ident,
-    unstable_path: Option<LitStr>,
-    r0_path: Option<LitStr>,
-    stable_path: Option<LitStr>,
     error_ty: Type,
 }
 
@@ -149,27 +137,8 @@ impl Request {
         self.fields.iter().filter_map(RequestField::as_header_field)
     }
 
-    fn path_fields_ordered(&self) -> impl Iterator<Item = &Field> {
-        let map: BTreeMap<String, &Field> = self
-            .fields
-            .iter()
-            .filter_map(RequestField::as_path_field)
-            .map(|f| (f.ident.as_ref().unwrap().to_string(), f))
-            .collect();
-
-        self.stable_path
-            .as_ref()
-            .or(self.r0_path.as_ref())
-            .or(self.unstable_path.as_ref())
-            .expect("one of the paths to be defined")
-            .value()
-            .split('/')
-            .filter_map(|s| {
-                s.strip_prefix(':')
-                    .map(|s| *map.get(s).expect("path args have already been checked"))
-            })
-            .collect::<Vec<_>>()
-            .into_iter()
+    fn path_fields(&self) -> impl Iterator<Item = &Field> {
+        self.fields.iter().filter_map(RequestField::as_path_field)
     }
 
     fn raw_body_field(&self) -> Option<&Field> {
@@ -252,13 +221,6 @@ impl Request {
     pub(super) fn check(&self) -> syn::Result<()> {
         // TODO: highlight problematic fields
 
-        let path_fields: Vec<_> =
-            self.fields.iter().filter_map(RequestField::as_path_field).collect();
-
-        self.check_path(&path_fields, self.unstable_path.as_ref())?;
-        self.check_path(&path_fields, self.r0_path.as_ref())?;
-        self.check_path(&path_fields, self.stable_path.as_ref())?;
-
         let newtype_body_fields = self.fields.iter().filter(|f| {
             matches!(&f.kind, RequestFieldKind::NewtypeBody | RequestFieldKind::RawBody)
         });
@@ -322,58 +284,16 @@ impl Request {
 
         Ok(())
     }
-
-    fn check_path(&self, fields: &[&Field], path: Option<&LitStr>) -> syn::Result<()> {
-        let path = if let Some(lit) = path { lit } else { return Ok(()) };
-
-        let path_args: Vec<_> = path
-            .value()
-            .split('/')
-            .filter_map(|s| s.strip_prefix(':').map(str::to_string))
-            .collect();
-
-        let field_map: BTreeMap<_, _> =
-            fields.iter().map(|&f| (f.ident.as_ref().unwrap().to_string(), f)).collect();
-
-        // test if all macro fields exist in the path
-        for (name, field) in field_map.iter() {
-            if !path_args.contains(name) {
-                return Err({
-                    let mut err = syn::Error::new_spanned(
-                        field,
-                        "this path argument field is not defined in...",
-                    );
-                    err.combine(syn::Error::new_spanned(path, "...this path."));
-                    err
-                });
-            }
-        }
-
-        // test if all path fields exists in macro fields
-        for arg in &path_args {
-            if !field_map.contains_key(arg) {
-                return Err(syn::Error::new_spanned(
-                    path,
-                    format!(
-                        "a corresponding request path argument field for \"{}\" does not exist",
-                        arg
-                    ),
-                ));
-            }
-        }
-
-        Ok(())
-    }
 }
 
 /// A field of the request struct.
-struct RequestField {
-    inner: Field,
-    kind: RequestFieldKind,
+pub(super) struct RequestField {
+    pub(super) inner: Field,
+    pub(super) kind: RequestFieldKind,
 }
 
 /// The kind of a request field.
-enum RequestFieldKind {
+pub(super) enum RequestFieldKind {
     /// JSON data in the body of the request.
     Body,
 
diff --git a/crates/ruma-macros/src/api/request/incoming.rs b/crates/ruma-macros/src/api/request/incoming.rs
index 71a56c8d..80f2fba4 100644
--- a/crates/ruma-macros/src/api/request/incoming.rs
+++ b/crates/ruma-macros/src/api/request/incoming.rs
@@ -24,8 +24,7 @@ impl Request {
         // except this one. If we get errors about missing fields in IncomingRequest for
         // a path field look here.
         let (parse_request_path, path_vars) = if self.has_path_fields() {
-            let path_vars: Vec<_> =
-                self.path_fields_ordered().filter_map(|f| f.ident.as_ref()).collect();
+            let path_vars: Vec<_> = self.path_fields().filter_map(|f| f.ident.as_ref()).collect();
 
             let parse_request_path = quote! {
                 let (#(#path_vars,)*) = #serde::Deserialize::deserialize(
diff --git a/crates/ruma-macros/src/api/request/outgoing.rs b/crates/ruma-macros/src/api/request/outgoing.rs
index 6b89c2f2..d954d672 100644
--- a/crates/ruma-macros/src/api/request/outgoing.rs
+++ b/crates/ruma-macros/src/api/request/outgoing.rs
@@ -14,7 +14,7 @@ impl Request {
         let error_ty = &self.error_ty;
 
         let path_fields =
-            self.path_fields_ordered().map(|f| f.ident.as_ref().expect("path fields have a name"));
+            self.path_fields().map(|f| f.ident.as_ref().expect("path fields have a name"));
 
         let request_query_string = if let Some(field) = self.query_map_field() {
             let field_name = field.ident.as_ref().expect("expected field to have identifier");


Test patch:
diff --git a/crates/ruma-common/tests/api/mod.rs b/crates/ruma-common/tests/api/mod.rs
index e61dcaf6..a01f92eb 100644
--- a/crates/ruma-common/tests/api/mod.rs
+++ b/crates/ruma-common/tests/api/mod.rs
@@ -5,7 +5,6 @@ mod header_override;
 mod manual_endpoint_impl;
 mod no_fields;
 mod optional_headers;
-mod path_arg_ordering;
 mod ruma_api;
 mod ruma_api_lifetime;
 mod ruma_api_macros;
diff --git a/crates/ruma-common/tests/api/path_arg_ordering.rs b/crates/ruma-common/tests/api/path_arg_ordering.rs
deleted file mode 100644
index 8cff98dc..00000000
--- a/crates/ruma-common/tests/api/path_arg_ordering.rs
+++ /dev/null
@@ -1,42 +0,0 @@
-use ruma_common::api::{ruma_api, IncomingRequest as _};
-
-ruma_api! {
-    metadata: {
-        description: "Does something.",
-        method: GET,
-        name: "some_path_args",
-        unstable_path: "/_matrix/:one/a/:two/b/:three/c",
-        rate_limited: false,
-        authentication: None,
-    }
-
-    request: {
-        #[ruma_api(path)]
-        pub three: String,
-
-        #[ruma_api(path)]
-        pub one: String,
-
-        #[ruma_api(path)]
-        pub two: String,
-    }
-
-    response: {}
-}
-
-#[test]
-fn path_ordering_is_correct() {
-    let request = http::Request::builder()
-        .method("GET")
-        // This explicitly puts wrong values in the URI, as now we rely on the side-supplied
-        // path_args slice, so this is just to ensure it *is* using that slice.
-        .uri("https://www.rust-lang.org/_matrix/non/a/non/b/non/c")
-        .body("")
-        .unwrap();
-
-    let resp = Request::try_from_http_request(request, &["1", "2", "3"]).unwrap();
-
-    assert_eq!(resp.one, "1");
-    assert_eq!(resp.two, "2");
-    assert_eq!(resp.three, "3");
-}
diff --git a/crates/ruma-common/tests/events/room_message.rs b/crates/ruma-common/tests/events/room_message.rs
index 1cbe66d8..48c8f1a2 100644
--- a/crates/ruma-common/tests/events/room_message.rs
+++ b/crates/ruma-common/tests/events/room_message.rs
@@ -9,7 +9,7 @@ use ruma_common::{
         room::{
             message::{
                 AudioMessageEventContent, KeyVerificationRequestEventContent, MessageType,
-                OriginalRoomMessageEvent, RoomMessageEventContent,
+                OriginalRoomMessageEvent, RoomMessageEventContent, TextMessageEventContent,
             },
             MediaSource,
         },
@@ -302,6 +302,53 @@ fn markdown_content_serialization() {
     );
 }
 
+#[test]
+#[cfg(feature = "markdown")]
+fn markdown_detection() {
+    use ruma_common::events::room::message::FormattedBody;
+
+    // No markdown
+    let formatted_body = FormattedBody::markdown("A simple message.");
+    assert_matches!(formatted_body, None);
+
+    // Multiple paragraphs trigger markdown
+    let formatted_body = FormattedBody::markdown("A message\nwith\n\nmultiple\n\nparagraphs");
+    formatted_body.unwrap();
+
+    // HTML entities don't trigger markdown.
+    let formatted_body = FormattedBody::markdown("A message with & HTML < entities");
+    assert_matches!(formatted_body, None);
+
+    // HTML triggers markdown.
+    let formatted_body = FormattedBody::markdown("<span>An HTML message</span>");
+    formatted_body.unwrap();
+}
+
+#[test]
+#[cfg(feature = "markdown")]
+fn markdown_options() {
+    use ruma_common::events::room::message::FormattedBody;
+
+    // Tables
+    let formatted_body = FormattedBody::markdown(
+        "|head1|head2|\n\
+        |---|---|\n\
+        |body1|body2|\
+        ",
+    );
+    assert_eq!(
+        formatted_body.unwrap().body,
+        "<table>\
+            <thead><tr><th>head1</th><th>head2</th></tr></thead>\
+            <tbody>\n<tr><td>body1</td><td>body2</td></tr>\n</tbody>\
+        </table>\n"
+    );
+
+    // Strikethrough
+    let formatted_body = FormattedBody::markdown("A message with a ~~strike~~");
+    assert_eq!(formatted_body.unwrap().body, "<p>A message with a <del>strike</del></p>\n");
+}
+
 #[test]
 fn verification_request_deserialization() {
     let user_id = user_id!("@example2:localhost");
@@ -390,7 +437,7 @@ fn content_deserialization_failure() {
 #[test]
 #[cfg(feature = "unstable-sanitize")]
 fn reply_sanitize() {
-    use ruma_common::events::room::message::{ForwardThread, TextMessageEventContent};
+    use ruma_common::events::room::message::ForwardThread;
 
     let first_message = OriginalRoomMessageEvent {
         content: RoomMessageEventContent::text_html(
@@ -483,3 +530,72 @@ fn reply_sanitize() {
         "
     );
 }
+
+#[test]
+fn make_replacement_no_reply() {
+    let content = RoomMessageEventContent::text_html(
+        "This is _an edited_ message.",
+        "This is <em>an edited</em> message.",
+    );
+    let event_id = event_id!("$143273582443PhrSn:example.org").to_owned();
+
+    let content = content.make_replacement(event_id, None);
+
+    let (body, formatted) = assert_matches!(
+        content.msgtype,
+        MessageType::Text(TextMessageEventContent { body, formatted, .. }) => (body, formatted)
+    );
+    assert_eq!(body, "* This is _an edited_ message.");
+    let formatted = formatted.unwrap();
+    assert_eq!(formatted.body, "* This is <em>an edited</em> message.");
+}
+
+#[test]
+fn make_replacement_with_reply() {
+    let replied_to_message = OriginalRoomMessageEvent {
+        content: RoomMessageEventContent::text_html(
+            "# This is the first message",
+            "<h1>This is the first message</h1>",
+        ),
+        event_id: event_id!("$143273582443PhrSn:example.org").to_owned(),
+        origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
+        room_id: room_id!("!testroomid:example.org").to_owned(),
+        sender: user_id!("@user:example.org").to_owned(),
+        unsigned: MessageLikeUnsigned::default(),
+    };
+
+    let content = RoomMessageEventContent::text_html(
+        "This is _an edited_ reply.",
+        "This is <em>an edited</em> reply.",
+    );
+    let event_id = event_id!("$143273582443PhrSn:example.org").to_owned();
+
+    let content = content.make_replacement(event_id, Some(&replied_to_message));
+
+    let (body, formatted) = assert_matches!(
+        content.msgtype,
+        MessageType::Text(TextMessageEventContent { body, formatted, .. }) => (body, formatted)
+    );
+    assert_eq!(
+        body,
+        "\
+        > <@user:example.org> # This is the first message\n\
+        * This is _an edited_ reply.\
+        "
+    );
+    let formatted = formatted.unwrap();
+    assert_eq!(
+        formatted.body,
+        "\
+        <mx-reply>\
+            <blockquote>\
+                <a href=\"https://matrix.to/#/!testroomid:example.org/$143273582443PhrSn:example.org\">In reply to</a> \
+                <a href=\"https://matrix.to/#/@user:example.org\">@user:example.org</a>\
+                <br>\
+                <h1>This is the first message</h1>\
+            </blockquote>\
+        </mx-reply>\
+        * This is <em>an edited</em> reply.\
+        "
+    );
+}