1use std::borrow::Cow;
6
7use ruma_common::{
8 serde::{JsonObject, Raw, StringEnum},
9 OwnedEventId, RoomId,
10};
11#[cfg(feature = "html")]
12use ruma_html::{sanitize_html, HtmlSanitizerMode, RemoveReplyFallback};
13use ruma_macros::EventContent;
14use serde::{de::DeserializeOwned, Deserialize, Serialize};
15use serde_json::Value as JsonValue;
16use tracing::warn;
17
18use self::reply::OriginalEventData;
19#[cfg(feature = "html")]
20use self::sanitize::remove_plain_reply_fallback;
21use crate::{
22 relation::{InReplyTo, Replacement, Thread},
23 AnySyncTimelineEvent, Mentions, PrivOwnedStr,
24};
25
26mod audio;
27mod content_serde;
28mod emote;
29mod file;
30#[cfg(feature = "unstable-msc4274")]
31mod gallery;
32mod image;
33mod key_verification_request;
34mod location;
35mod media_caption;
36mod notice;
37mod relation;
38pub(crate) mod relation_serde;
39mod reply;
40pub mod sanitize;
41mod server_notice;
42mod text;
43#[cfg(feature = "unstable-msc4095")]
44mod url_preview;
45mod video;
46mod without_relation;
47
48#[cfg(feature = "unstable-msc3245-v1-compat")]
49pub use self::audio::{
50 UnstableAmplitude, UnstableAudioDetailsContentBlock, UnstableVoiceContentBlock,
51};
52#[cfg(feature = "unstable-msc4274")]
53pub use self::gallery::{GalleryItemType, GalleryMessageEventContent};
54#[cfg(feature = "unstable-msc4095")]
55pub use self::url_preview::{PreviewImage, PreviewImageSource, UrlPreview};
56pub use self::{
57 audio::{AudioInfo, AudioMessageEventContent},
58 emote::EmoteMessageEventContent,
59 file::{FileInfo, FileMessageEventContent},
60 image::ImageMessageEventContent,
61 key_verification_request::KeyVerificationRequestEventContent,
62 location::{LocationInfo, LocationMessageEventContent},
63 notice::NoticeMessageEventContent,
64 relation::{Relation, RelationWithoutReplacement},
65 relation_serde::deserialize_relation,
66 server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNoticeType},
67 text::TextMessageEventContent,
68 video::{VideoInfo, VideoMessageEventContent},
69 without_relation::RoomMessageEventContentWithoutRelation,
70};
71
72#[derive(Clone, Debug, Serialize, EventContent)]
78#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
79#[ruma_event(type = "m.room.message", kind = MessageLike)]
80pub struct RoomMessageEventContent {
81 #[serde(flatten)]
85 pub msgtype: MessageType,
86
87 #[serde(flatten, skip_serializing_if = "Option::is_none")]
91 pub relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
92
93 #[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
101 pub mentions: Option<Mentions>,
102}
103
104impl RoomMessageEventContent {
105 pub fn new(msgtype: MessageType) -> Self {
107 Self { msgtype, relates_to: None, mentions: None }
108 }
109
110 pub fn text_plain(body: impl Into<String>) -> Self {
112 Self::new(MessageType::text_plain(body))
113 }
114
115 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
117 Self::new(MessageType::text_html(body, html_body))
118 }
119
120 #[cfg(feature = "markdown")]
122 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
123 Self::new(MessageType::text_markdown(body))
124 }
125
126 pub fn notice_plain(body: impl Into<String>) -> Self {
128 Self::new(MessageType::notice_plain(body))
129 }
130
131 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
133 Self::new(MessageType::notice_html(body, html_body))
134 }
135
136 #[cfg(feature = "markdown")]
138 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
139 Self::new(MessageType::notice_markdown(body))
140 }
141
142 pub fn emote_plain(body: impl Into<String>) -> Self {
144 Self::new(MessageType::emote_plain(body))
145 }
146
147 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
149 Self::new(MessageType::emote_html(body, html_body))
150 }
151
152 #[cfg(feature = "markdown")]
154 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
155 Self::new(MessageType::emote_markdown(body))
156 }
157
158 #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
165 #[track_caller]
170 pub fn make_reply_to(
171 self,
172 original_message: &OriginalRoomMessageEvent,
173 forward_thread: ForwardThread,
174 add_mentions: AddMentions,
175 ) -> Self {
176 self.without_relation().make_reply_to(original_message, forward_thread, add_mentions)
177 }
178
179 #[track_caller]
197 pub fn make_reply_to_raw(
198 self,
199 original_event: &Raw<AnySyncTimelineEvent>,
200 original_event_id: OwnedEventId,
201 room_id: &RoomId,
202 forward_thread: ForwardThread,
203 add_mentions: AddMentions,
204 ) -> Self {
205 self.without_relation().make_reply_to_raw(
206 original_event,
207 original_event_id,
208 room_id,
209 forward_thread,
210 add_mentions,
211 )
212 }
213
214 #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
224 pub fn make_for_thread(
230 self,
231 previous_message: &OriginalRoomMessageEvent,
232 is_reply: ReplyWithinThread,
233 add_mentions: AddMentions,
234 ) -> Self {
235 self.without_relation().make_for_thread(previous_message, is_reply, add_mentions)
236 }
237
238 #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
252 #[track_caller]
263 pub fn make_replacement(
264 self,
265 metadata: impl Into<ReplacementMetadata>,
266 replied_to_message: Option<&OriginalRoomMessageEvent>,
267 ) -> Self {
268 self.without_relation().make_replacement(metadata, replied_to_message)
269 }
270
271 #[deprecated = "Call add_mentions before adding the relation instead."]
283 pub fn set_mentions(mut self, mentions: Mentions) -> Self {
284 if let Some(Relation::Replacement(replacement)) = &mut self.relates_to {
285 let old_mentions = &replacement.new_content.mentions;
286
287 let new_mentions = if let Some(old_mentions) = old_mentions {
288 let mut new_mentions = Mentions::new();
289
290 new_mentions.user_ids = mentions
291 .user_ids
292 .iter()
293 .filter(|u| !old_mentions.user_ids.contains(*u))
294 .cloned()
295 .collect();
296
297 new_mentions.room = mentions.room && !old_mentions.room;
298
299 new_mentions
300 } else {
301 mentions.clone()
302 };
303
304 replacement.new_content.mentions = Some(mentions);
305 self.mentions = Some(new_mentions);
306 } else {
307 self.mentions = Some(mentions);
308 }
309
310 self
311 }
312
313 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
324 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
325 self
326 }
327
328 pub fn msgtype(&self) -> &str {
333 self.msgtype.msgtype()
334 }
335
336 pub fn body(&self) -> &str {
338 self.msgtype.body()
339 }
340
341 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
343 let RoomMessageEventContentWithoutRelation { msgtype, mentions } = new_content;
344 self.msgtype = msgtype;
345 self.mentions = mentions;
346 }
347
348 #[cfg(feature = "html")]
361 pub fn sanitize(
362 &mut self,
363 mode: HtmlSanitizerMode,
364 remove_reply_fallback: RemoveReplyFallback,
365 ) {
366 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply { .. })) {
367 remove_reply_fallback
368 } else {
369 RemoveReplyFallback::No
370 };
371
372 self.msgtype.sanitize(mode, remove_reply_fallback);
373 }
374
375 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
376 if self.relates_to.is_some() {
377 warn!("Overwriting existing relates_to value");
378 }
379
380 self.into()
381 }
382}
383
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386#[allow(clippy::exhaustive_enums)]
387pub enum ForwardThread {
388 Yes,
395
396 No,
400}
401
402#[derive(Clone, Copy, Debug, PartialEq, Eq)]
404#[allow(clippy::exhaustive_enums)]
405pub enum AddMentions {
406 Yes,
412
413 No,
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
421#[allow(clippy::exhaustive_enums)]
422pub enum ReplyWithinThread {
423 Yes,
429
430 No,
436}
437
438#[derive(Clone, Debug, Serialize)]
440#[serde(untagged)]
441#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
442pub enum MessageType {
443 Audio(AudioMessageEventContent),
445
446 Emote(EmoteMessageEventContent),
448
449 File(FileMessageEventContent),
451
452 #[cfg(feature = "unstable-msc4274")]
454 Gallery(GalleryMessageEventContent),
455
456 Image(ImageMessageEventContent),
458
459 Location(LocationMessageEventContent),
461
462 Notice(NoticeMessageEventContent),
464
465 ServerNotice(ServerNoticeMessageEventContent),
467
468 Text(TextMessageEventContent),
470
471 Video(VideoMessageEventContent),
473
474 VerificationRequest(KeyVerificationRequestEventContent),
476
477 #[doc(hidden)]
479 _Custom(CustomEventContent),
480}
481
482impl MessageType {
483 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
498 fn deserialize_variant<T: DeserializeOwned>(
499 body: String,
500 mut obj: JsonObject,
501 ) -> serde_json::Result<T> {
502 obj.insert("body".into(), body.into());
503 serde_json::from_value(JsonValue::Object(obj))
504 }
505
506 Ok(match msgtype {
507 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
508 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
509 "m.file" => Self::File(deserialize_variant(body, data)?),
510 #[cfg(feature = "unstable-msc4274")]
511 "dm.filament.gallery" => Self::Gallery(deserialize_variant(body, data)?),
512 "m.image" => Self::Image(deserialize_variant(body, data)?),
513 "m.location" => Self::Location(deserialize_variant(body, data)?),
514 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
515 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
516 "m.text" => Self::Text(deserialize_variant(body, data)?),
517 "m.video" => Self::Video(deserialize_variant(body, data)?),
518 "m.key.verification.request" => {
519 Self::VerificationRequest(deserialize_variant(body, data)?)
520 }
521 _ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
522 })
523 }
524
525 pub fn text_plain(body: impl Into<String>) -> Self {
527 Self::Text(TextMessageEventContent::plain(body))
528 }
529
530 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
532 Self::Text(TextMessageEventContent::html(body, html_body))
533 }
534
535 #[cfg(feature = "markdown")]
537 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
538 Self::Text(TextMessageEventContent::markdown(body))
539 }
540
541 pub fn notice_plain(body: impl Into<String>) -> Self {
543 Self::Notice(NoticeMessageEventContent::plain(body))
544 }
545
546 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
548 Self::Notice(NoticeMessageEventContent::html(body, html_body))
549 }
550
551 #[cfg(feature = "markdown")]
553 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
554 Self::Notice(NoticeMessageEventContent::markdown(body))
555 }
556
557 pub fn emote_plain(body: impl Into<String>) -> Self {
559 Self::Emote(EmoteMessageEventContent::plain(body))
560 }
561
562 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
564 Self::Emote(EmoteMessageEventContent::html(body, html_body))
565 }
566
567 #[cfg(feature = "markdown")]
569 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
570 Self::Emote(EmoteMessageEventContent::markdown(body))
571 }
572
573 pub fn msgtype(&self) -> &str {
575 match self {
576 Self::Audio(_) => "m.audio",
577 Self::Emote(_) => "m.emote",
578 Self::File(_) => "m.file",
579 #[cfg(feature = "unstable-msc4274")]
580 Self::Gallery(_) => "dm.filament.gallery",
581 Self::Image(_) => "m.image",
582 Self::Location(_) => "m.location",
583 Self::Notice(_) => "m.notice",
584 Self::ServerNotice(_) => "m.server_notice",
585 Self::Text(_) => "m.text",
586 Self::Video(_) => "m.video",
587 Self::VerificationRequest(_) => "m.key.verification.request",
588 Self::_Custom(c) => &c.msgtype,
589 }
590 }
591
592 pub fn body(&self) -> &str {
594 match self {
595 MessageType::Audio(m) => &m.body,
596 MessageType::Emote(m) => &m.body,
597 MessageType::File(m) => &m.body,
598 #[cfg(feature = "unstable-msc4274")]
599 MessageType::Gallery(m) => &m.body,
600 MessageType::Image(m) => &m.body,
601 MessageType::Location(m) => &m.body,
602 MessageType::Notice(m) => &m.body,
603 MessageType::ServerNotice(m) => &m.body,
604 MessageType::Text(m) => &m.body,
605 MessageType::Video(m) => &m.body,
606 MessageType::VerificationRequest(m) => &m.body,
607 MessageType::_Custom(m) => &m.body,
608 }
609 }
610
611 pub fn data(&self) -> Cow<'_, JsonObject> {
619 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
620 match serde_json::to_value(obj).expect("message type serialization to succeed") {
621 JsonValue::Object(mut obj) => {
622 obj.remove("body");
623 obj
624 }
625 _ => panic!("all message types must serialize to objects"),
626 }
627 }
628
629 match self {
630 Self::Audio(d) => Cow::Owned(serialize(d)),
631 Self::Emote(d) => Cow::Owned(serialize(d)),
632 Self::File(d) => Cow::Owned(serialize(d)),
633 #[cfg(feature = "unstable-msc4274")]
634 Self::Gallery(d) => Cow::Owned(serialize(d)),
635 Self::Image(d) => Cow::Owned(serialize(d)),
636 Self::Location(d) => Cow::Owned(serialize(d)),
637 Self::Notice(d) => Cow::Owned(serialize(d)),
638 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
639 Self::Text(d) => Cow::Owned(serialize(d)),
640 Self::Video(d) => Cow::Owned(serialize(d)),
641 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
642 Self::_Custom(c) => Cow::Borrowed(&c.data),
643 }
644 }
645
646 #[cfg(feature = "html")]
660 pub fn sanitize(
661 &mut self,
662 mode: HtmlSanitizerMode,
663 remove_reply_fallback: RemoveReplyFallback,
664 ) {
665 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
666 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
667 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
668 {
669 if let Some(formatted) = formatted {
670 formatted.sanitize_html(mode, remove_reply_fallback);
671 }
672 if remove_reply_fallback == RemoveReplyFallback::Yes {
673 *body = remove_plain_reply_fallback(body).to_owned();
674 }
675 }
676 }
677
678 #[track_caller]
679 fn add_reply_fallback(&mut self, original_event: OriginalEventData<'_>) {
680 let empty_formatted_body = || FormattedBody::html(String::new());
681
682 let (body, formatted) = {
683 match self {
684 MessageType::Emote(m) => {
685 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
686 }
687 MessageType::Notice(m) => {
688 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
689 }
690 MessageType::Text(m) => {
691 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
692 }
693 MessageType::Audio(m) => (&mut m.body, None),
694 MessageType::File(m) => (&mut m.body, None),
695 #[cfg(feature = "unstable-msc4274")]
696 MessageType::Gallery(m) => (&mut m.body, None),
697 MessageType::Image(m) => (&mut m.body, None),
698 MessageType::Location(m) => (&mut m.body, None),
699 MessageType::ServerNotice(m) => (&mut m.body, None),
700 MessageType::Video(m) => (&mut m.body, None),
701 MessageType::VerificationRequest(m) => (&mut m.body, None),
702 MessageType::_Custom(m) => (&mut m.body, None),
703 }
704 };
705
706 if let Some(f) = formatted {
707 assert_eq!(
708 f.format,
709 MessageFormat::Html,
710 "can't add reply fallback to non-HTML formatted messages"
711 );
712
713 let formatted_body = &mut f.body;
714
715 (*body, *formatted_body) = reply::plain_and_formatted_reply_body(
716 body.as_str(),
717 (!formatted_body.is_empty()).then_some(formatted_body.as_str()),
718 original_event,
719 );
720 }
721 }
722
723 fn make_replacement_body(&mut self) {
724 let empty_formatted_body = || FormattedBody::html(String::new());
725
726 let (body, formatted) = {
727 match self {
728 MessageType::Emote(m) => {
729 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
730 }
731 MessageType::Notice(m) => {
732 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
733 }
734 MessageType::Text(m) => {
735 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
736 }
737 MessageType::Audio(m) => (&mut m.body, None),
738 MessageType::File(m) => (&mut m.body, None),
739 #[cfg(feature = "unstable-msc4274")]
740 MessageType::Gallery(m) => (&mut m.body, None),
741 MessageType::Image(m) => (&mut m.body, None),
742 MessageType::Location(m) => (&mut m.body, None),
743 MessageType::ServerNotice(m) => (&mut m.body, None),
744 MessageType::Video(m) => (&mut m.body, None),
745 MessageType::VerificationRequest(m) => (&mut m.body, None),
746 MessageType::_Custom(m) => (&mut m.body, None),
747 }
748 };
749
750 *body = format!("* {body}");
752
753 if let Some(f) = formatted {
754 assert_eq!(
755 f.format,
756 MessageFormat::Html,
757 "make_replacement can't handle non-HTML formatted messages"
758 );
759
760 f.body = format!("* {}", f.body);
761 }
762 }
763}
764
765impl From<MessageType> for RoomMessageEventContent {
766 fn from(msgtype: MessageType) -> Self {
767 Self::new(msgtype)
768 }
769}
770
771impl From<RoomMessageEventContent> for MessageType {
772 fn from(content: RoomMessageEventContent) -> Self {
773 content.msgtype
774 }
775}
776
777#[derive(Debug)]
781pub struct ReplacementMetadata {
782 event_id: OwnedEventId,
783 mentions: Option<Mentions>,
784}
785
786impl ReplacementMetadata {
787 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
789 Self { event_id, mentions }
790 }
791}
792
793impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
794 fn from(value: &OriginalRoomMessageEvent) -> Self {
795 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
796 }
797}
798
799impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
800 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
801 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
802 }
803}
804
805#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
807#[derive(Clone, PartialEq, Eq, StringEnum)]
808#[non_exhaustive]
809pub enum MessageFormat {
810 #[ruma_enum(rename = "org.matrix.custom.html")]
812 Html,
813
814 #[doc(hidden)]
815 _Custom(PrivOwnedStr),
816}
817
818#[derive(Clone, Debug, Deserialize, Serialize)]
821#[allow(clippy::exhaustive_structs)]
822pub struct FormattedBody {
823 pub format: MessageFormat,
825
826 #[serde(rename = "formatted_body")]
828 pub body: String,
829}
830
831impl FormattedBody {
832 pub fn html(body: impl Into<String>) -> Self {
834 Self { format: MessageFormat::Html, body: body.into() }
835 }
836
837 #[cfg(feature = "markdown")]
841 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
842 parse_markdown(body.as_ref()).map(Self::html)
843 }
844
845 #[cfg(feature = "html")]
856 pub fn sanitize_html(
857 &mut self,
858 mode: HtmlSanitizerMode,
859 remove_reply_fallback: RemoveReplyFallback,
860 ) {
861 if self.format == MessageFormat::Html {
862 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
863 }
864 }
865}
866
867#[doc(hidden)]
869#[derive(Clone, Debug, Deserialize, Serialize)]
870pub struct CustomEventContent {
871 msgtype: String,
873
874 body: String,
876
877 #[serde(flatten)]
879 data: JsonObject,
880}
881
882#[cfg(feature = "markdown")]
883pub(crate) fn parse_markdown(text: &str) -> Option<String> {
884 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
885
886 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
887
888 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
889 .map(|event| match event {
890 Event::SoftBreak => Event::HardBreak,
891 _ => event,
892 })
893 .collect();
894
895 let first_event_is_paragraph_start =
898 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
899 let last_event_is_paragraph_end =
900 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
901 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
902 let mut has_markdown = !is_inline;
903
904 if !has_markdown {
905 let mut pos = 0;
908
909 for event in parser_events.iter().skip(1) {
910 match event {
911 Event::Text(s) => {
912 if text[pos..].starts_with(s.as_ref()) {
917 pos += s.len();
918 continue;
919 }
920 }
921 Event::HardBreak => {
922 if text[pos..].starts_with("\r\n") {
926 pos += 2;
927 continue;
928 } else if text[pos..].starts_with(['\r', '\n']) {
929 pos += 1;
930 continue;
931 }
932 }
933 Event::End(TagEnd::Paragraph) => continue,
936 Event::Start(tag) => {
938 is_inline &= !is_block_tag(tag);
939 }
940 _ => {}
941 }
942
943 has_markdown = true;
944
945 if !is_inline {
947 break;
948 }
949 }
950
951 has_markdown |= pos != text.len();
953 }
954
955 if !has_markdown {
957 return None;
958 }
959
960 let mut events_iter = parser_events.into_iter();
961
962 if is_inline {
964 events_iter.next();
965 events_iter.next_back();
966 }
967
968 let mut html_body = String::new();
969 pulldown_cmark::html::push_html(&mut html_body, events_iter);
970
971 Some(html_body)
972}
973
974#[cfg(feature = "markdown")]
976fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
977 use pulldown_cmark::Tag;
978
979 matches!(
980 tag,
981 Tag::Paragraph
982 | Tag::Heading { .. }
983 | Tag::BlockQuote(_)
984 | Tag::CodeBlock(_)
985 | Tag::HtmlBlock
986 | Tag::List(_)
987 | Tag::FootnoteDefinition(_)
988 | Tag::Table(_)
989 )
990}
991
992#[cfg(all(test, feature = "markdown"))]
993mod tests {
994 use super::parse_markdown;
995
996 #[test]
997 fn detect_markdown() {
998 let text = "Hello world.";
1000 assert_eq!(parse_markdown(text), None);
1001
1002 let text = "Hello\nworld.";
1004 assert_eq!(parse_markdown(text), None);
1005
1006 let text = "Hello\n\nworld.";
1008 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
1009
1010 let text = "## Hello\n\nworld.";
1012 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
1013
1014 let text = "Hello\n\n```\nworld.\n```";
1016 assert_eq!(
1017 parse_markdown(text).as_deref(),
1018 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
1019 );
1020
1021 let text = "Hello **world**.";
1023 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
1024
1025 let text = r#"Hello \<world\>."#;
1027 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
1028
1029 let text = r#"\> Hello world."#;
1031 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
1032
1033 let text = r#"Hello <world>."#;
1035 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
1036
1037 let text = "Hello w⊕rld.";
1039 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
1040 }
1041
1042 #[test]
1043 fn detect_commonmark() {
1044 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
1047 assert_eq!(
1048 parse_markdown(text).as_deref(),
1049 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
1050 );
1051
1052 let text = r#"\→\A\a\ \3\φ\«"#;
1053 assert_eq!(parse_markdown(text).as_deref(), None);
1054
1055 let text = r#"\*not emphasized*"#;
1056 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
1057
1058 let text = r#"\<br/> not a tag"#;
1059 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
1060
1061 let text = r#"\[not a link](/foo)"#;
1062 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
1063
1064 let text = r#"\`not code`"#;
1065 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
1066
1067 let text = r#"1\. not a list"#;
1068 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
1069
1070 let text = r#"\* not a list"#;
1071 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
1072
1073 let text = r#"\# not a heading"#;
1074 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
1075
1076 let text = r#"\[foo]: /url "not a reference""#;
1077 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
1078
1079 let text = r#"\ö not a character entity"#;
1080 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
1081
1082 let text = r#"\\*emphasis*"#;
1083 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
1084
1085 let text = "foo\\\nbar";
1086 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1087
1088 let text = " ***\n ***\n ***";
1089 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1090
1091 let text = "Foo\n***\nbar";
1092 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1093
1094 let text = "</div>\n*foo*";
1095 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1096
1097 let text = "<div>\n*foo*\n\n*bar*";
1098 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1099
1100 let text = "aaa\nbbb\n\nccc\nddd";
1101 assert_eq!(
1102 parse_markdown(text).as_deref(),
1103 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1104 );
1105
1106 let text = " aaa\n bbb";
1107 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1108
1109 let text = "aaa\n bbb\n ccc";
1110 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1111
1112 let text = "aaa \nbbb ";
1113 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1114 }
1115}