1use std::borrow::Cow;
6
7use as_variant::as_variant;
8use ruma_common::{
9 EventId, OwnedEventId, UserId,
10 serde::{JsonObject, StringEnum},
11};
12#[cfg(feature = "html")]
13use ruma_html::{HtmlSanitizerMode, RemoveReplyFallback, sanitize_html};
14use ruma_macros::EventContent;
15use serde::{Deserialize, Serialize, de::DeserializeOwned};
16use serde_json::Value as JsonValue;
17use tracing::warn;
18
19#[cfg(feature = "html")]
20use self::sanitize::remove_plain_reply_fallback;
21#[cfg(feature = "unstable-msc4471")]
22use crate::stream::StreamDescriptor;
23use crate::{Mentions, PrivOwnedStr, relation::Thread};
24
25mod audio;
26mod content_serde;
27mod emote;
28mod file;
29#[cfg(feature = "unstable-msc4274")]
30mod gallery;
31mod image;
32mod key_verification_request;
33mod location;
34mod media_caption;
35mod notice;
36mod relation;
37pub(crate) mod relation_serde;
38pub mod sanitize;
39mod server_notice;
40mod text;
41#[cfg(feature = "unstable-msc4095")]
42mod url_preview;
43mod video;
44mod without_relation;
45
46#[cfg(feature = "unstable-msc3245-v1-compat")]
47pub use self::audio::{
48 UnstableAmplitude, UnstableAudioDetailsContentBlock, UnstableVoiceContentBlock,
49};
50#[cfg(feature = "unstable-msc4274")]
51pub use self::gallery::{GalleryItemType, GalleryMessageEventContent};
52#[cfg(feature = "unstable-msc4095")]
53pub use self::url_preview::{PreviewImage, PreviewImageSource, UrlPreview};
54pub use self::{
55 audio::{AudioInfo, AudioMessageEventContent},
56 emote::EmoteMessageEventContent,
57 file::{FileInfo, FileMessageEventContent},
58 image::ImageMessageEventContent,
59 key_verification_request::KeyVerificationRequestEventContent,
60 location::{LocationInfo, LocationMessageEventContent},
61 notice::NoticeMessageEventContent,
62 relation::{Relation, RelationWithoutReplacement},
63 relation_serde::deserialize_relation,
64 server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNoticeType},
65 text::TextMessageEventContent,
66 video::{VideoInfo, VideoMessageEventContent},
67 without_relation::RoomMessageEventContentWithoutRelation,
68};
69
70#[derive(Clone, Debug, Serialize, EventContent)]
76#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
77#[ruma_event(type = "m.room.message", kind = MessageLike)]
78pub struct RoomMessageEventContent {
79 #[serde(flatten)]
83 pub msgtype: MessageType,
84
85 #[serde(flatten, skip_serializing_if = "Option::is_none")]
89 pub relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
90
91 #[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
101 pub mentions: Option<Mentions>,
102
103 #[cfg(feature = "unstable-msc4471")]
109 #[serde(rename = "org.matrix.msc4471.stream", skip_serializing_if = "Option::is_none")]
110 pub stream: Option<StreamDescriptor>,
111}
112
113impl RoomMessageEventContent {
114 pub fn new(msgtype: MessageType) -> Self {
116 Self {
117 msgtype,
118 relates_to: None,
119 mentions: None,
120 #[cfg(feature = "unstable-msc4471")]
121 stream: None,
122 }
123 }
124
125 pub fn text_plain(body: impl Into<String>) -> Self {
127 Self::new(MessageType::text_plain(body))
128 }
129
130 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
132 Self::new(MessageType::text_html(body, html_body))
133 }
134
135 #[cfg(feature = "markdown")]
137 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
138 Self::new(MessageType::text_markdown(body))
139 }
140
141 pub fn notice_plain(body: impl Into<String>) -> Self {
143 Self::new(MessageType::notice_plain(body))
144 }
145
146 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
148 Self::new(MessageType::notice_html(body, html_body))
149 }
150
151 #[cfg(feature = "markdown")]
153 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
154 Self::new(MessageType::notice_markdown(body))
155 }
156
157 pub fn emote_plain(body: impl Into<String>) -> Self {
159 Self::new(MessageType::emote_plain(body))
160 }
161
162 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
164 Self::new(MessageType::emote_html(body, html_body))
165 }
166
167 #[cfg(feature = "markdown")]
169 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
170 Self::new(MessageType::emote_markdown(body))
171 }
172
173 #[track_caller]
182 pub fn make_reply_to<'a>(
183 self,
184 metadata: impl Into<ReplyMetadata<'a>>,
185 forward_thread: ForwardThread,
186 add_mentions: AddMentions,
187 ) -> Self {
188 self.without_relation().make_reply_to(metadata, forward_thread, add_mentions)
189 }
190
191 pub fn make_for_thread<'a>(
206 self,
207 metadata: impl Into<ReplyMetadata<'a>>,
208 is_reply: ReplyWithinThread,
209 add_mentions: AddMentions,
210 ) -> Self {
211 self.without_relation().make_for_thread(metadata, is_reply, add_mentions)
212 }
213
214 #[track_caller]
233 pub fn make_replacement(self, metadata: impl Into<ReplacementMetadata>) -> Self {
234 self.without_relation().make_replacement(metadata)
235 }
236
237 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
248 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
249 self
250 }
251
252 pub fn msgtype(&self) -> &str {
257 self.msgtype.msgtype()
258 }
259
260 pub fn body(&self) -> &str {
262 self.msgtype.body()
263 }
264
265 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
269 let RoomMessageEventContentWithoutRelation {
270 msgtype,
271 mentions,
272 #[cfg(feature = "unstable-msc4471")]
273 stream,
274 } = new_content;
275 self.msgtype = msgtype;
276 self.mentions = mentions;
277 #[cfg(feature = "unstable-msc4471")]
278 {
279 self.stream = stream;
280 }
281 }
282
283 #[cfg(feature = "html")]
296 pub fn sanitize(
297 &mut self,
298 mode: HtmlSanitizerMode,
299 remove_reply_fallback: RemoveReplyFallback,
300 ) {
301 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply(_))) {
302 remove_reply_fallback
303 } else {
304 RemoveReplyFallback::No
305 };
306
307 self.msgtype.sanitize(mode, remove_reply_fallback);
308 }
309
310 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
311 if self.relates_to.is_some() {
312 warn!("Overwriting existing relates_to value");
313 }
314
315 self.into()
316 }
317
318 fn thread(&self) -> Option<&Thread> {
320 self.relates_to.as_ref().and_then(|relates_to| as_variant!(relates_to, Relation::Thread))
321 }
322}
323
324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
326#[allow(clippy::exhaustive_enums)]
327pub enum ForwardThread {
328 Yes,
335
336 No,
340}
341
342#[derive(Clone, Copy, Debug, PartialEq, Eq)]
344#[allow(clippy::exhaustive_enums)]
345pub enum AddMentions {
346 Yes,
352
353 No,
357}
358
359#[derive(Clone, Copy, Debug, PartialEq, Eq)]
361#[allow(clippy::exhaustive_enums)]
362pub enum ReplyWithinThread {
363 Yes,
369
370 No,
376}
377
378#[derive(Clone, Debug, Serialize)]
380#[serde(tag = "msgtype")]
381#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
382pub enum MessageType {
383 #[serde(rename = "m.audio")]
385 Audio(AudioMessageEventContent),
386
387 #[serde(rename = "m.emote")]
389 Emote(EmoteMessageEventContent),
390
391 #[serde(rename = "m.file")]
393 File(FileMessageEventContent),
394
395 #[cfg(feature = "unstable-msc4274")]
397 #[serde(rename = "dm.filament.gallery")]
398 Gallery(GalleryMessageEventContent),
399
400 #[serde(rename = "m.image")]
402 Image(ImageMessageEventContent),
403
404 #[serde(rename = "m.location")]
406 Location(LocationMessageEventContent),
407
408 #[serde(rename = "m.notice")]
410 Notice(NoticeMessageEventContent),
411
412 #[serde(rename = "m.server_notice")]
414 ServerNotice(ServerNoticeMessageEventContent),
415
416 #[serde(rename = "m.text")]
418 Text(TextMessageEventContent),
419
420 #[serde(rename = "m.video")]
422 Video(VideoMessageEventContent),
423
424 #[serde(rename = "m.key.verification.request")]
426 VerificationRequest(KeyVerificationRequestEventContent),
427
428 #[doc(hidden)]
430 #[serde(untagged)]
431 _Custom(CustomMessageContent),
432}
433
434impl MessageType {
435 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
450 fn deserialize_variant<T: DeserializeOwned>(
451 body: String,
452 mut obj: JsonObject,
453 ) -> serde_json::Result<T> {
454 obj.insert("body".into(), body.into());
455 serde_json::from_value(JsonValue::Object(obj))
456 }
457
458 Ok(match msgtype {
459 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
460 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
461 "m.file" => Self::File(deserialize_variant(body, data)?),
462 #[cfg(feature = "unstable-msc4274")]
463 "dm.filament.gallery" => Self::Gallery(deserialize_variant(body, data)?),
464 "m.image" => Self::Image(deserialize_variant(body, data)?),
465 "m.location" => Self::Location(deserialize_variant(body, data)?),
466 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
467 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
468 "m.text" => Self::Text(deserialize_variant(body, data)?),
469 "m.video" => Self::Video(deserialize_variant(body, data)?),
470 "m.key.verification.request" => {
471 Self::VerificationRequest(deserialize_variant(body, data)?)
472 }
473 _ => Self::_Custom(CustomMessageContent { msgtype: msgtype.to_owned(), body, data }),
474 })
475 }
476
477 pub fn text_plain(body: impl Into<String>) -> Self {
479 Self::Text(TextMessageEventContent::plain(body))
480 }
481
482 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
484 Self::Text(TextMessageEventContent::html(body, html_body))
485 }
486
487 #[cfg(feature = "markdown")]
489 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
490 Self::Text(TextMessageEventContent::markdown(body))
491 }
492
493 pub fn notice_plain(body: impl Into<String>) -> Self {
495 Self::Notice(NoticeMessageEventContent::plain(body))
496 }
497
498 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
500 Self::Notice(NoticeMessageEventContent::html(body, html_body))
501 }
502
503 #[cfg(feature = "markdown")]
505 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
506 Self::Notice(NoticeMessageEventContent::markdown(body))
507 }
508
509 pub fn emote_plain(body: impl Into<String>) -> Self {
511 Self::Emote(EmoteMessageEventContent::plain(body))
512 }
513
514 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
516 Self::Emote(EmoteMessageEventContent::html(body, html_body))
517 }
518
519 #[cfg(feature = "markdown")]
521 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
522 Self::Emote(EmoteMessageEventContent::markdown(body))
523 }
524
525 pub fn msgtype(&self) -> &str {
527 match self {
528 Self::Audio(_) => "m.audio",
529 Self::Emote(_) => "m.emote",
530 Self::File(_) => "m.file",
531 #[cfg(feature = "unstable-msc4274")]
532 Self::Gallery(_) => "dm.filament.gallery",
533 Self::Image(_) => "m.image",
534 Self::Location(_) => "m.location",
535 Self::Notice(_) => "m.notice",
536 Self::ServerNotice(_) => "m.server_notice",
537 Self::Text(_) => "m.text",
538 Self::Video(_) => "m.video",
539 Self::VerificationRequest(_) => "m.key.verification.request",
540 Self::_Custom(c) => &c.msgtype,
541 }
542 }
543
544 pub fn body(&self) -> &str {
546 match self {
547 MessageType::Audio(m) => &m.body,
548 MessageType::Emote(m) => &m.body,
549 MessageType::File(m) => &m.body,
550 #[cfg(feature = "unstable-msc4274")]
551 MessageType::Gallery(m) => &m.body,
552 MessageType::Image(m) => &m.body,
553 MessageType::Location(m) => &m.body,
554 MessageType::Notice(m) => &m.body,
555 MessageType::ServerNotice(m) => &m.body,
556 MessageType::Text(m) => &m.body,
557 MessageType::Video(m) => &m.body,
558 MessageType::VerificationRequest(m) => &m.body,
559 MessageType::_Custom(m) => &m.body,
560 }
561 }
562
563 pub fn data(&self) -> Cow<'_, JsonObject> {
571 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
572 match serde_json::to_value(obj).expect("message type serialization to succeed") {
573 JsonValue::Object(mut obj) => {
574 obj.remove("body");
575 obj
576 }
577 _ => panic!("all message types must serialize to objects"),
578 }
579 }
580
581 match self {
582 Self::Audio(d) => Cow::Owned(serialize(d)),
583 Self::Emote(d) => Cow::Owned(serialize(d)),
584 Self::File(d) => Cow::Owned(serialize(d)),
585 #[cfg(feature = "unstable-msc4274")]
586 Self::Gallery(d) => Cow::Owned(serialize(d)),
587 Self::Image(d) => Cow::Owned(serialize(d)),
588 Self::Location(d) => Cow::Owned(serialize(d)),
589 Self::Notice(d) => Cow::Owned(serialize(d)),
590 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
591 Self::Text(d) => Cow::Owned(serialize(d)),
592 Self::Video(d) => Cow::Owned(serialize(d)),
593 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
594 Self::_Custom(c) => Cow::Borrowed(&c.data),
595 }
596 }
597
598 #[cfg(feature = "html")]
612 pub fn sanitize(
613 &mut self,
614 mode: HtmlSanitizerMode,
615 remove_reply_fallback: RemoveReplyFallback,
616 ) {
617 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
618 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
619 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
620 {
621 if let Some(formatted) = formatted {
622 formatted.sanitize_html(mode, remove_reply_fallback);
623 }
624 if remove_reply_fallback == RemoveReplyFallback::Yes {
625 *body = remove_plain_reply_fallback(body).to_owned();
626 }
627 }
628 }
629
630 fn make_replacement_body(&mut self) {
631 let empty_formatted_body = || FormattedBody::html(String::new());
632
633 let (body, formatted) = {
634 match self {
635 MessageType::Emote(m) => {
636 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
637 }
638 MessageType::Notice(m) => {
639 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
640 }
641 MessageType::Text(m) => {
642 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
643 }
644 MessageType::Audio(m) => (&mut m.body, None),
645 MessageType::File(m) => (&mut m.body, None),
646 #[cfg(feature = "unstable-msc4274")]
647 MessageType::Gallery(m) => (&mut m.body, None),
648 MessageType::Image(m) => (&mut m.body, None),
649 MessageType::Location(m) => (&mut m.body, None),
650 MessageType::ServerNotice(m) => (&mut m.body, None),
651 MessageType::Video(m) => (&mut m.body, None),
652 MessageType::VerificationRequest(m) => (&mut m.body, None),
653 MessageType::_Custom(m) => (&mut m.body, None),
654 }
655 };
656
657 *body = format!("* {body}");
659
660 if let Some(f) = formatted {
661 assert_eq!(
662 f.format,
663 MessageFormat::Html,
664 "make_replacement can't handle non-HTML formatted messages"
665 );
666
667 f.body = format!("* {}", f.body);
668 }
669 }
670}
671
672impl From<MessageType> for RoomMessageEventContent {
673 fn from(msgtype: MessageType) -> Self {
674 Self::new(msgtype)
675 }
676}
677
678impl From<RoomMessageEventContent> for MessageType {
679 fn from(content: RoomMessageEventContent) -> Self {
680 content.msgtype
681 }
682}
683
684#[derive(Debug)]
688pub struct ReplacementMetadata {
689 event_id: OwnedEventId,
690 mentions: Option<Mentions>,
691}
692
693impl ReplacementMetadata {
694 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
696 Self { event_id, mentions }
697 }
698}
699
700impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
701 fn from(value: &OriginalRoomMessageEvent) -> Self {
702 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
703 }
704}
705
706impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
707 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
708 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
709 }
710}
711
712#[derive(Clone, Copy, Debug)]
717pub struct ReplyMetadata<'a> {
718 event_id: &'a EventId,
720 sender: &'a UserId,
722 thread: Option<&'a Thread>,
724}
725
726impl<'a> ReplyMetadata<'a> {
727 pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
729 Self { event_id, sender, thread }
730 }
731}
732
733impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
734 fn from(value: &'a OriginalRoomMessageEvent) -> Self {
735 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
736 }
737}
738
739impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
740 fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
741 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
742 }
743}
744
745#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
747#[derive(Clone, StringEnum)]
748#[non_exhaustive]
749pub enum MessageFormat {
750 #[ruma_enum(rename = "org.matrix.custom.html")]
752 Html,
753
754 #[doc(hidden)]
755 _Custom(PrivOwnedStr),
756}
757
758#[derive(Clone, Debug, Deserialize, Serialize)]
761#[allow(clippy::exhaustive_structs)]
762pub struct FormattedBody {
763 pub format: MessageFormat,
765
766 #[serde(rename = "formatted_body")]
768 pub body: String,
769}
770
771impl FormattedBody {
772 pub fn html(body: impl Into<String>) -> Self {
774 Self { format: MessageFormat::Html, body: body.into() }
775 }
776
777 #[cfg(feature = "markdown")]
781 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
782 parse_markdown(body.as_ref()).map(Self::html)
783 }
784
785 #[cfg(feature = "html")]
796 pub fn sanitize_html(
797 &mut self,
798 mode: HtmlSanitizerMode,
799 remove_reply_fallback: RemoveReplyFallback,
800 ) {
801 if self.format == MessageFormat::Html {
802 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
803 }
804 }
805}
806
807#[doc(hidden)]
809#[derive(Clone, Debug, Serialize)]
810pub struct CustomMessageContent {
811 msgtype: String,
813
814 body: String,
816
817 #[serde(flatten)]
819 data: JsonObject,
820}
821
822#[cfg(feature = "markdown")]
823pub(crate) fn parse_markdown(text: &str) -> Option<String> {
824 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
825
826 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
827
828 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
829 .map(|event| match event {
830 Event::SoftBreak => Event::HardBreak,
831 _ => event,
832 })
833 .collect();
834
835 let first_event_is_paragraph_start =
838 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
839 let last_event_is_paragraph_end =
840 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
841 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
842 let mut has_markdown = !is_inline;
843
844 if !has_markdown {
845 let mut pos = 0;
848
849 for event in parser_events.iter().skip(1) {
850 match event {
851 Event::Text(s) if text[pos..].starts_with(s.as_ref()) => {
856 pos += s.len();
857 continue;
858 }
859 Event::HardBreak => {
860 if text[pos..].starts_with("\r\n") {
864 pos += 2;
865 continue;
866 } else if text[pos..].starts_with(['\r', '\n']) {
867 pos += 1;
868 continue;
869 }
870 }
871 Event::End(TagEnd::Paragraph) => continue,
874 Event::Start(tag) => {
876 is_inline &= !is_block_tag(tag);
877 }
878 _ => {}
879 }
880
881 has_markdown = true;
882
883 if !is_inline {
885 break;
886 }
887 }
888
889 has_markdown |= pos != text.len();
891 }
892
893 if !has_markdown {
895 return None;
896 }
897
898 let mut events_iter = parser_events.into_iter();
899
900 if is_inline {
902 events_iter.next();
903 events_iter.next_back();
904 }
905
906 let mut html_body = String::new();
907 pulldown_cmark::html::push_html(&mut html_body, events_iter);
908
909 Some(html_body)
910}
911
912#[cfg(feature = "markdown")]
914fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
915 use pulldown_cmark::Tag;
916
917 matches!(
918 tag,
919 Tag::Paragraph
920 | Tag::Heading { .. }
921 | Tag::BlockQuote(_)
922 | Tag::CodeBlock(_)
923 | Tag::HtmlBlock
924 | Tag::List(_)
925 | Tag::FootnoteDefinition(_)
926 | Tag::Table(_)
927 )
928}
929
930#[cfg(all(test, feature = "markdown"))]
931mod tests {
932 use super::parse_markdown;
933
934 #[test]
935 fn detect_markdown() {
936 let text = "Hello world.";
938 assert_eq!(parse_markdown(text), None);
939
940 let text = "Hello\nworld.";
942 assert_eq!(parse_markdown(text), None);
943
944 let text = "Hello\n\nworld.";
946 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
947
948 let text = "## Hello\n\nworld.";
950 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
951
952 let text = "Hello\n\n```\nworld.\n```";
954 assert_eq!(
955 parse_markdown(text).as_deref(),
956 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
957 );
958
959 let text = "Hello **world**.";
961 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
962
963 let text = r#"Hello \<world\>."#;
965 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
966
967 let text = r#"\> Hello world."#;
969 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
970
971 let text = r#"Hello <world>."#;
973 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
974
975 let text = "Hello w⊕rld.";
977 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
978 }
979
980 #[test]
981 fn detect_commonmark() {
982 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
985 assert_eq!(
986 parse_markdown(text).as_deref(),
987 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
988 );
989
990 let text = r#"\→\A\a\ \3\φ\«"#;
991 assert_eq!(parse_markdown(text).as_deref(), None);
992
993 let text = r#"\*not emphasized*"#;
994 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
995
996 let text = r#"\<br/> not a tag"#;
997 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
998
999 let text = r#"\[not a link](/foo)"#;
1000 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
1001
1002 let text = r#"\`not code`"#;
1003 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
1004
1005 let text = r#"1\. not a list"#;
1006 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
1007
1008 let text = r#"\* not a list"#;
1009 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
1010
1011 let text = r#"\# not a heading"#;
1012 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
1013
1014 let text = r#"\[foo]: /url "not a reference""#;
1015 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
1016
1017 let text = r#"\ö not a character entity"#;
1018 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
1019
1020 let text = r#"\\*emphasis*"#;
1021 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
1022
1023 let text = "foo\\\nbar";
1024 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1025
1026 let text = " ***\n ***\n ***";
1027 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1028
1029 let text = "Foo\n***\nbar";
1030 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1031
1032 let text = "</div>\n*foo*";
1033 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1034
1035 let text = "<div>\n*foo*\n\n*bar*";
1036 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1037
1038 let text = "aaa\nbbb\n\nccc\nddd";
1039 assert_eq!(
1040 parse_markdown(text).as_deref(),
1041 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1042 );
1043
1044 let text = " aaa\n bbb";
1045 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1046
1047 let text = "aaa\n bbb\n ccc";
1048 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1049
1050 let text = "aaa \nbbb ";
1051 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1052 }
1053}