1use std::borrow::Cow;
6
7use as_variant::as_variant;
8use ruma_common::{
9 serde::{JsonObject, StringEnum},
10 EventId, OwnedEventId, UserId,
11};
12#[cfg(feature = "html")]
13use ruma_html::{sanitize_html, HtmlSanitizerMode, RemoveReplyFallback};
14use ruma_macros::EventContent;
15use serde::{de::DeserializeOwned, Deserialize, Serialize};
16use serde_json::Value as JsonValue;
17use tracing::warn;
18
19#[cfg(feature = "html")]
20use self::sanitize::remove_plain_reply_fallback;
21use crate::{
22 relation::{InReplyTo, Replacement, Thread},
23 Mentions, PrivOwnedStr,
24};
25
26mod audio;
27mod content_serde;
28mod emote;
29mod file;
30mod image;
31mod key_verification_request;
32mod location;
33mod media_caption;
34mod notice;
35mod relation;
36pub(crate) mod relation_serde;
37pub mod sanitize;
38mod server_notice;
39mod text;
40#[cfg(feature = "unstable-msc4095")]
41mod url_preview;
42mod video;
43mod without_relation;
44
45#[cfg(feature = "unstable-msc3245-v1-compat")]
46pub use self::audio::{
47 UnstableAmplitude, UnstableAudioDetailsContentBlock, UnstableVoiceContentBlock,
48};
49#[cfg(feature = "unstable-msc4095")]
50pub use self::url_preview::{PreviewImage, PreviewImageSource, UrlPreview};
51pub use self::{
52 audio::{AudioInfo, AudioMessageEventContent},
53 emote::EmoteMessageEventContent,
54 file::{FileInfo, FileMessageEventContent},
55 image::ImageMessageEventContent,
56 key_verification_request::KeyVerificationRequestEventContent,
57 location::{LocationInfo, LocationMessageEventContent},
58 notice::NoticeMessageEventContent,
59 relation::{Relation, RelationWithoutReplacement},
60 relation_serde::deserialize_relation,
61 server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNoticeType},
62 text::TextMessageEventContent,
63 video::{VideoInfo, VideoMessageEventContent},
64 without_relation::RoomMessageEventContentWithoutRelation,
65};
66
67#[derive(Clone, Debug, Serialize, EventContent)]
73#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
74#[ruma_event(type = "m.room.message", kind = MessageLike)]
75pub struct RoomMessageEventContent {
76 #[serde(flatten)]
80 pub msgtype: MessageType,
81
82 #[serde(flatten, skip_serializing_if = "Option::is_none")]
86 pub relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
87
88 #[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
96 pub mentions: Option<Mentions>,
97}
98
99impl RoomMessageEventContent {
100 pub fn new(msgtype: MessageType) -> Self {
102 Self { msgtype, relates_to: None, mentions: None }
103 }
104
105 pub fn text_plain(body: impl Into<String>) -> Self {
107 Self::new(MessageType::text_plain(body))
108 }
109
110 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
112 Self::new(MessageType::text_html(body, html_body))
113 }
114
115 #[cfg(feature = "markdown")]
117 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
118 Self::new(MessageType::text_markdown(body))
119 }
120
121 pub fn notice_plain(body: impl Into<String>) -> Self {
123 Self::new(MessageType::notice_plain(body))
124 }
125
126 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
128 Self::new(MessageType::notice_html(body, html_body))
129 }
130
131 #[cfg(feature = "markdown")]
133 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
134 Self::new(MessageType::notice_markdown(body))
135 }
136
137 pub fn emote_plain(body: impl Into<String>) -> Self {
139 Self::new(MessageType::emote_plain(body))
140 }
141
142 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
144 Self::new(MessageType::emote_html(body, html_body))
145 }
146
147 #[cfg(feature = "markdown")]
149 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
150 Self::new(MessageType::emote_markdown(body))
151 }
152
153 #[track_caller]
162 pub fn make_reply_to<'a>(
163 self,
164 metadata: impl Into<ReplyMetadata<'a>>,
165 forward_thread: ForwardThread,
166 add_mentions: AddMentions,
167 ) -> Self {
168 self.without_relation().make_reply_to(metadata, forward_thread, add_mentions)
169 }
170
171 pub fn make_for_thread<'a>(
186 self,
187 metadata: impl Into<ReplyMetadata<'a>>,
188 is_reply: ReplyWithinThread,
189 add_mentions: AddMentions,
190 ) -> Self {
191 self.without_relation().make_for_thread(metadata, is_reply, add_mentions)
192 }
193
194 #[track_caller]
213 pub fn make_replacement(self, metadata: impl Into<ReplacementMetadata>) -> Self {
214 self.without_relation().make_replacement(metadata)
215 }
216
217 #[deprecated = "Call add_mentions before adding the relation instead."]
229 pub fn set_mentions(mut self, mentions: Mentions) -> Self {
230 if let Some(Relation::Replacement(replacement)) = &mut self.relates_to {
231 let old_mentions = &replacement.new_content.mentions;
232
233 let new_mentions = if let Some(old_mentions) = old_mentions {
234 let mut new_mentions = Mentions::new();
235
236 new_mentions.user_ids = mentions
237 .user_ids
238 .iter()
239 .filter(|u| !old_mentions.user_ids.contains(*u))
240 .cloned()
241 .collect();
242
243 new_mentions.room = mentions.room && !old_mentions.room;
244
245 new_mentions
246 } else {
247 mentions.clone()
248 };
249
250 replacement.new_content.mentions = Some(mentions);
251 self.mentions = Some(new_mentions);
252 } else {
253 self.mentions = Some(mentions);
254 }
255
256 self
257 }
258
259 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
270 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
271 self
272 }
273
274 pub fn msgtype(&self) -> &str {
279 self.msgtype.msgtype()
280 }
281
282 pub fn body(&self) -> &str {
284 self.msgtype.body()
285 }
286
287 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
289 let RoomMessageEventContentWithoutRelation { msgtype, mentions } = new_content;
290 self.msgtype = msgtype;
291 self.mentions = mentions;
292 }
293
294 #[cfg(feature = "html")]
307 pub fn sanitize(
308 &mut self,
309 mode: HtmlSanitizerMode,
310 remove_reply_fallback: RemoveReplyFallback,
311 ) {
312 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply { .. })) {
313 remove_reply_fallback
314 } else {
315 RemoveReplyFallback::No
316 };
317
318 self.msgtype.sanitize(mode, remove_reply_fallback);
319 }
320
321 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
322 if self.relates_to.is_some() {
323 warn!("Overwriting existing relates_to value");
324 }
325
326 self.into()
327 }
328
329 fn thread(&self) -> Option<&Thread> {
331 self.relates_to.as_ref().and_then(|relates_to| as_variant!(relates_to, Relation::Thread))
332 }
333}
334
335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337#[allow(clippy::exhaustive_enums)]
338pub enum ForwardThread {
339 Yes,
346
347 No,
351}
352
353#[derive(Clone, Copy, Debug, PartialEq, Eq)]
355#[allow(clippy::exhaustive_enums)]
356pub enum AddMentions {
357 Yes,
363
364 No,
368}
369
370#[derive(Clone, Copy, Debug, PartialEq, Eq)]
372#[allow(clippy::exhaustive_enums)]
373pub enum ReplyWithinThread {
374 Yes,
380
381 No,
387}
388
389#[derive(Clone, Debug, Serialize)]
391#[serde(untagged)]
392#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
393pub enum MessageType {
394 Audio(AudioMessageEventContent),
396
397 Emote(EmoteMessageEventContent),
399
400 File(FileMessageEventContent),
402
403 Image(ImageMessageEventContent),
405
406 Location(LocationMessageEventContent),
408
409 Notice(NoticeMessageEventContent),
411
412 ServerNotice(ServerNoticeMessageEventContent),
414
415 Text(TextMessageEventContent),
417
418 Video(VideoMessageEventContent),
420
421 VerificationRequest(KeyVerificationRequestEventContent),
423
424 #[doc(hidden)]
426 _Custom(CustomEventContent),
427}
428
429impl MessageType {
430 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
445 fn deserialize_variant<T: DeserializeOwned>(
446 body: String,
447 mut obj: JsonObject,
448 ) -> serde_json::Result<T> {
449 obj.insert("body".into(), body.into());
450 serde_json::from_value(JsonValue::Object(obj))
451 }
452
453 Ok(match msgtype {
454 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
455 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
456 "m.file" => Self::File(deserialize_variant(body, data)?),
457 "m.image" => Self::Image(deserialize_variant(body, data)?),
458 "m.location" => Self::Location(deserialize_variant(body, data)?),
459 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
460 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
461 "m.text" => Self::Text(deserialize_variant(body, data)?),
462 "m.video" => Self::Video(deserialize_variant(body, data)?),
463 "m.key.verification.request" => {
464 Self::VerificationRequest(deserialize_variant(body, data)?)
465 }
466 _ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
467 })
468 }
469
470 pub fn text_plain(body: impl Into<String>) -> Self {
472 Self::Text(TextMessageEventContent::plain(body))
473 }
474
475 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
477 Self::Text(TextMessageEventContent::html(body, html_body))
478 }
479
480 #[cfg(feature = "markdown")]
482 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
483 Self::Text(TextMessageEventContent::markdown(body))
484 }
485
486 pub fn notice_plain(body: impl Into<String>) -> Self {
488 Self::Notice(NoticeMessageEventContent::plain(body))
489 }
490
491 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
493 Self::Notice(NoticeMessageEventContent::html(body, html_body))
494 }
495
496 #[cfg(feature = "markdown")]
498 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
499 Self::Notice(NoticeMessageEventContent::markdown(body))
500 }
501
502 pub fn emote_plain(body: impl Into<String>) -> Self {
504 Self::Emote(EmoteMessageEventContent::plain(body))
505 }
506
507 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
509 Self::Emote(EmoteMessageEventContent::html(body, html_body))
510 }
511
512 #[cfg(feature = "markdown")]
514 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
515 Self::Emote(EmoteMessageEventContent::markdown(body))
516 }
517
518 pub fn msgtype(&self) -> &str {
520 match self {
521 Self::Audio(_) => "m.audio",
522 Self::Emote(_) => "m.emote",
523 Self::File(_) => "m.file",
524 Self::Image(_) => "m.image",
525 Self::Location(_) => "m.location",
526 Self::Notice(_) => "m.notice",
527 Self::ServerNotice(_) => "m.server_notice",
528 Self::Text(_) => "m.text",
529 Self::Video(_) => "m.video",
530 Self::VerificationRequest(_) => "m.key.verification.request",
531 Self::_Custom(c) => &c.msgtype,
532 }
533 }
534
535 pub fn body(&self) -> &str {
537 match self {
538 MessageType::Audio(m) => &m.body,
539 MessageType::Emote(m) => &m.body,
540 MessageType::File(m) => &m.body,
541 MessageType::Image(m) => &m.body,
542 MessageType::Location(m) => &m.body,
543 MessageType::Notice(m) => &m.body,
544 MessageType::ServerNotice(m) => &m.body,
545 MessageType::Text(m) => &m.body,
546 MessageType::Video(m) => &m.body,
547 MessageType::VerificationRequest(m) => &m.body,
548 MessageType::_Custom(m) => &m.body,
549 }
550 }
551
552 pub fn data(&self) -> Cow<'_, JsonObject> {
560 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
561 match serde_json::to_value(obj).expect("message type serialization to succeed") {
562 JsonValue::Object(mut obj) => {
563 obj.remove("body");
564 obj
565 }
566 _ => panic!("all message types must serialize to objects"),
567 }
568 }
569
570 match self {
571 Self::Audio(d) => Cow::Owned(serialize(d)),
572 Self::Emote(d) => Cow::Owned(serialize(d)),
573 Self::File(d) => Cow::Owned(serialize(d)),
574 Self::Image(d) => Cow::Owned(serialize(d)),
575 Self::Location(d) => Cow::Owned(serialize(d)),
576 Self::Notice(d) => Cow::Owned(serialize(d)),
577 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
578 Self::Text(d) => Cow::Owned(serialize(d)),
579 Self::Video(d) => Cow::Owned(serialize(d)),
580 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
581 Self::_Custom(c) => Cow::Borrowed(&c.data),
582 }
583 }
584
585 #[cfg(feature = "html")]
599 pub fn sanitize(
600 &mut self,
601 mode: HtmlSanitizerMode,
602 remove_reply_fallback: RemoveReplyFallback,
603 ) {
604 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
605 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
606 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
607 {
608 if let Some(formatted) = formatted {
609 formatted.sanitize_html(mode, remove_reply_fallback);
610 }
611 if remove_reply_fallback == RemoveReplyFallback::Yes {
612 *body = remove_plain_reply_fallback(body).to_owned();
613 }
614 }
615 }
616
617 fn make_replacement_body(&mut self) {
618 let empty_formatted_body = || FormattedBody::html(String::new());
619
620 let (body, formatted) = {
621 match self {
622 MessageType::Emote(m) => {
623 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
624 }
625 MessageType::Notice(m) => {
626 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
627 }
628 MessageType::Text(m) => {
629 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
630 }
631 MessageType::Audio(m) => (&mut m.body, None),
632 MessageType::File(m) => (&mut m.body, None),
633 MessageType::Image(m) => (&mut m.body, None),
634 MessageType::Location(m) => (&mut m.body, None),
635 MessageType::ServerNotice(m) => (&mut m.body, None),
636 MessageType::Video(m) => (&mut m.body, None),
637 MessageType::VerificationRequest(m) => (&mut m.body, None),
638 MessageType::_Custom(m) => (&mut m.body, None),
639 }
640 };
641
642 *body = format!("* {body}");
644
645 if let Some(f) = formatted {
646 assert_eq!(
647 f.format,
648 MessageFormat::Html,
649 "make_replacement can't handle non-HTML formatted messages"
650 );
651
652 f.body = format!("* {}", f.body);
653 }
654 }
655}
656
657impl From<MessageType> for RoomMessageEventContent {
658 fn from(msgtype: MessageType) -> Self {
659 Self::new(msgtype)
660 }
661}
662
663impl From<RoomMessageEventContent> for MessageType {
664 fn from(content: RoomMessageEventContent) -> Self {
665 content.msgtype
666 }
667}
668
669#[derive(Debug)]
673pub struct ReplacementMetadata {
674 event_id: OwnedEventId,
675 mentions: Option<Mentions>,
676}
677
678impl ReplacementMetadata {
679 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
681 Self { event_id, mentions }
682 }
683}
684
685impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
686 fn from(value: &OriginalRoomMessageEvent) -> Self {
687 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
688 }
689}
690
691impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
692 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
693 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
694 }
695}
696
697#[derive(Clone, Copy, Debug)]
702pub struct ReplyMetadata<'a> {
703 event_id: &'a EventId,
705 sender: &'a UserId,
707 thread: Option<&'a Thread>,
709}
710
711impl<'a> ReplyMetadata<'a> {
712 pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
714 Self { event_id, sender, thread }
715 }
716}
717
718impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
719 fn from(value: &'a OriginalRoomMessageEvent) -> Self {
720 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
721 }
722}
723
724impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
725 fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
726 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
727 }
728}
729
730#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
732#[derive(Clone, PartialEq, Eq, StringEnum)]
733#[non_exhaustive]
734pub enum MessageFormat {
735 #[ruma_enum(rename = "org.matrix.custom.html")]
737 Html,
738
739 #[doc(hidden)]
740 _Custom(PrivOwnedStr),
741}
742
743#[derive(Clone, Debug, Deserialize, Serialize)]
746#[allow(clippy::exhaustive_structs)]
747pub struct FormattedBody {
748 pub format: MessageFormat,
750
751 #[serde(rename = "formatted_body")]
753 pub body: String,
754}
755
756impl FormattedBody {
757 pub fn html(body: impl Into<String>) -> Self {
759 Self { format: MessageFormat::Html, body: body.into() }
760 }
761
762 #[cfg(feature = "markdown")]
766 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
767 parse_markdown(body.as_ref()).map(Self::html)
768 }
769
770 #[cfg(feature = "html")]
781 pub fn sanitize_html(
782 &mut self,
783 mode: HtmlSanitizerMode,
784 remove_reply_fallback: RemoveReplyFallback,
785 ) {
786 if self.format == MessageFormat::Html {
787 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
788 }
789 }
790}
791
792#[doc(hidden)]
794#[derive(Clone, Debug, Deserialize, Serialize)]
795pub struct CustomEventContent {
796 msgtype: String,
798
799 body: String,
801
802 #[serde(flatten)]
804 data: JsonObject,
805}
806
807#[cfg(feature = "markdown")]
808pub(crate) fn parse_markdown(text: &str) -> Option<String> {
809 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
810
811 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
812
813 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
814 .map(|event| match event {
815 Event::SoftBreak => Event::HardBreak,
816 _ => event,
817 })
818 .collect();
819
820 let first_event_is_paragraph_start =
823 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
824 let last_event_is_paragraph_end =
825 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
826 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
827 let mut has_markdown = !is_inline;
828
829 if !has_markdown {
830 let mut pos = 0;
833
834 for event in parser_events.iter().skip(1) {
835 match event {
836 Event::Text(s) => {
837 if text[pos..].starts_with(s.as_ref()) {
842 pos += s.len();
843 continue;
844 }
845 }
846 Event::HardBreak => {
847 if text[pos..].starts_with("\r\n") {
851 pos += 2;
852 continue;
853 } else if text[pos..].starts_with(['\r', '\n']) {
854 pos += 1;
855 continue;
856 }
857 }
858 Event::End(TagEnd::Paragraph) => continue,
861 Event::Start(tag) => {
863 is_inline &= !is_block_tag(tag);
864 }
865 _ => {}
866 }
867
868 has_markdown = true;
869
870 if !is_inline {
872 break;
873 }
874 }
875
876 has_markdown |= pos != text.len();
878 }
879
880 if !has_markdown {
882 return None;
883 }
884
885 let mut events_iter = parser_events.into_iter();
886
887 if is_inline {
889 events_iter.next();
890 events_iter.next_back();
891 }
892
893 let mut html_body = String::new();
894 pulldown_cmark::html::push_html(&mut html_body, events_iter);
895
896 Some(html_body)
897}
898
899#[cfg(feature = "markdown")]
901fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
902 use pulldown_cmark::Tag;
903
904 matches!(
905 tag,
906 Tag::Paragraph
907 | Tag::Heading { .. }
908 | Tag::BlockQuote(_)
909 | Tag::CodeBlock(_)
910 | Tag::HtmlBlock
911 | Tag::List(_)
912 | Tag::FootnoteDefinition(_)
913 | Tag::Table(_)
914 )
915}
916
917#[cfg(all(test, feature = "markdown"))]
918mod tests {
919 use super::parse_markdown;
920
921 #[test]
922 fn detect_markdown() {
923 let text = "Hello world.";
925 assert_eq!(parse_markdown(text), None);
926
927 let text = "Hello\nworld.";
929 assert_eq!(parse_markdown(text), None);
930
931 let text = "Hello\n\nworld.";
933 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
934
935 let text = "## Hello\n\nworld.";
937 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
938
939 let text = "Hello\n\n```\nworld.\n```";
941 assert_eq!(
942 parse_markdown(text).as_deref(),
943 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
944 );
945
946 let text = "Hello **world**.";
948 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
949
950 let text = r#"Hello \<world\>."#;
952 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
953
954 let text = r#"\> Hello world."#;
956 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
957
958 let text = r#"Hello <world>."#;
960 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
961
962 let text = "Hello w⊕rld.";
964 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
965 }
966
967 #[test]
968 fn detect_commonmark() {
969 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
972 assert_eq!(
973 parse_markdown(text).as_deref(),
974 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
975 );
976
977 let text = r#"\→\A\a\ \3\φ\«"#;
978 assert_eq!(parse_markdown(text).as_deref(), None);
979
980 let text = r#"\*not emphasized*"#;
981 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
982
983 let text = r#"\<br/> not a tag"#;
984 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
985
986 let text = r#"\[not a link](/foo)"#;
987 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
988
989 let text = r#"\`not code`"#;
990 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
991
992 let text = r#"1\. not a list"#;
993 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
994
995 let text = r#"\* not a list"#;
996 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
997
998 let text = r#"\# not a heading"#;
999 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
1000
1001 let text = r#"\[foo]: /url "not a reference""#;
1002 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
1003
1004 let text = r#"\ö not a character entity"#;
1005 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
1006
1007 let text = r#"\\*emphasis*"#;
1008 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
1009
1010 let text = "foo\\\nbar";
1011 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1012
1013 let text = " ***\n ***\n ***";
1014 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1015
1016 let text = "Foo\n***\nbar";
1017 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1018
1019 let text = "</div>\n*foo*";
1020 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1021
1022 let text = "<div>\n*foo*\n\n*bar*";
1023 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1024
1025 let text = "aaa\nbbb\n\nccc\nddd";
1026 assert_eq!(
1027 parse_markdown(text).as_deref(),
1028 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1029 );
1030
1031 let text = " aaa\n bbb";
1032 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1033
1034 let text = "aaa\n bbb\n ccc";
1035 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1036
1037 let text = "aaa \nbbb ";
1038 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1039 }
1040}