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;
30mod image;
31mod key_verification_request;
32mod location;
33mod media_caption;
34mod notice;
35mod relation;
36pub(crate) mod relation_serde;
37mod reply;
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-msc4095")]
51pub use self::url_preview::{PreviewImage, PreviewImageSource, UrlPreview};
52pub use self::{
53 audio::{AudioInfo, AudioMessageEventContent},
54 emote::EmoteMessageEventContent,
55 file::{FileInfo, FileMessageEventContent},
56 image::ImageMessageEventContent,
57 key_verification_request::KeyVerificationRequestEventContent,
58 location::{LocationInfo, LocationMessageEventContent},
59 notice::NoticeMessageEventContent,
60 relation::{Relation, RelationWithoutReplacement},
61 relation_serde::deserialize_relation,
62 server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNoticeType},
63 text::TextMessageEventContent,
64 video::{VideoInfo, VideoMessageEventContent},
65 without_relation::RoomMessageEventContentWithoutRelation,
66};
67
68#[derive(Clone, Debug, Serialize, EventContent)]
74#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
75#[ruma_event(type = "m.room.message", kind = MessageLike)]
76pub struct RoomMessageEventContent {
77 #[serde(flatten)]
81 pub msgtype: MessageType,
82
83 #[serde(flatten, skip_serializing_if = "Option::is_none")]
87 pub relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
88
89 #[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
97 pub mentions: Option<Mentions>,
98}
99
100impl RoomMessageEventContent {
101 pub fn new(msgtype: MessageType) -> Self {
103 Self { msgtype, relates_to: None, mentions: None }
104 }
105
106 pub fn text_plain(body: impl Into<String>) -> Self {
108 Self::new(MessageType::text_plain(body))
109 }
110
111 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
113 Self::new(MessageType::text_html(body, html_body))
114 }
115
116 #[cfg(feature = "markdown")]
118 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
119 Self::new(MessageType::text_markdown(body))
120 }
121
122 pub fn notice_plain(body: impl Into<String>) -> Self {
124 Self::new(MessageType::notice_plain(body))
125 }
126
127 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
129 Self::new(MessageType::notice_html(body, html_body))
130 }
131
132 #[cfg(feature = "markdown")]
134 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
135 Self::new(MessageType::notice_markdown(body))
136 }
137
138 pub fn emote_plain(body: impl Into<String>) -> Self {
140 Self::new(MessageType::emote_plain(body))
141 }
142
143 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
145 Self::new(MessageType::emote_html(body, html_body))
146 }
147
148 #[cfg(feature = "markdown")]
150 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
151 Self::new(MessageType::emote_markdown(body))
152 }
153
154 #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
161 #[track_caller]
166 pub fn make_reply_to(
167 self,
168 original_message: &OriginalRoomMessageEvent,
169 forward_thread: ForwardThread,
170 add_mentions: AddMentions,
171 ) -> Self {
172 self.without_relation().make_reply_to(original_message, forward_thread, add_mentions)
173 }
174
175 #[track_caller]
193 pub fn make_reply_to_raw(
194 self,
195 original_event: &Raw<AnySyncTimelineEvent>,
196 original_event_id: OwnedEventId,
197 room_id: &RoomId,
198 forward_thread: ForwardThread,
199 add_mentions: AddMentions,
200 ) -> Self {
201 self.without_relation().make_reply_to_raw(
202 original_event,
203 original_event_id,
204 room_id,
205 forward_thread,
206 add_mentions,
207 )
208 }
209
210 #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
220 pub fn make_for_thread(
226 self,
227 previous_message: &OriginalRoomMessageEvent,
228 is_reply: ReplyWithinThread,
229 add_mentions: AddMentions,
230 ) -> Self {
231 self.without_relation().make_for_thread(previous_message, is_reply, add_mentions)
232 }
233
234 #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
248 #[track_caller]
259 pub fn make_replacement(
260 self,
261 metadata: impl Into<ReplacementMetadata>,
262 replied_to_message: Option<&OriginalRoomMessageEvent>,
263 ) -> Self {
264 self.without_relation().make_replacement(metadata, replied_to_message)
265 }
266
267 #[deprecated = "Call add_mentions before adding the relation instead."]
279 pub fn set_mentions(mut self, mentions: Mentions) -> Self {
280 if let Some(Relation::Replacement(replacement)) = &mut self.relates_to {
281 let old_mentions = &replacement.new_content.mentions;
282
283 let new_mentions = if let Some(old_mentions) = old_mentions {
284 let mut new_mentions = Mentions::new();
285
286 new_mentions.user_ids = mentions
287 .user_ids
288 .iter()
289 .filter(|u| !old_mentions.user_ids.contains(*u))
290 .cloned()
291 .collect();
292
293 new_mentions.room = mentions.room && !old_mentions.room;
294
295 new_mentions
296 } else {
297 mentions.clone()
298 };
299
300 replacement.new_content.mentions = Some(mentions);
301 self.mentions = Some(new_mentions);
302 } else {
303 self.mentions = Some(mentions);
304 }
305
306 self
307 }
308
309 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
320 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
321 self
322 }
323
324 pub fn msgtype(&self) -> &str {
329 self.msgtype.msgtype()
330 }
331
332 pub fn body(&self) -> &str {
334 self.msgtype.body()
335 }
336
337 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
339 let RoomMessageEventContentWithoutRelation { msgtype, mentions } = new_content;
340 self.msgtype = msgtype;
341 self.mentions = mentions;
342 }
343
344 #[cfg(feature = "html")]
357 pub fn sanitize(
358 &mut self,
359 mode: HtmlSanitizerMode,
360 remove_reply_fallback: RemoveReplyFallback,
361 ) {
362 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply { .. })) {
363 remove_reply_fallback
364 } else {
365 RemoveReplyFallback::No
366 };
367
368 self.msgtype.sanitize(mode, remove_reply_fallback);
369 }
370
371 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
372 if self.relates_to.is_some() {
373 warn!("Overwriting existing relates_to value");
374 }
375
376 self.into()
377 }
378}
379
380#[derive(Clone, Copy, Debug, PartialEq, Eq)]
382#[allow(clippy::exhaustive_enums)]
383pub enum ForwardThread {
384 Yes,
391
392 No,
396}
397
398#[derive(Clone, Copy, Debug, PartialEq, Eq)]
400#[allow(clippy::exhaustive_enums)]
401pub enum AddMentions {
402 Yes,
408
409 No,
413}
414
415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
417#[allow(clippy::exhaustive_enums)]
418pub enum ReplyWithinThread {
419 Yes,
425
426 No,
432}
433
434#[derive(Clone, Debug, Serialize)]
436#[serde(untagged)]
437#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
438pub enum MessageType {
439 Audio(AudioMessageEventContent),
441
442 Emote(EmoteMessageEventContent),
444
445 File(FileMessageEventContent),
447
448 Image(ImageMessageEventContent),
450
451 Location(LocationMessageEventContent),
453
454 Notice(NoticeMessageEventContent),
456
457 ServerNotice(ServerNoticeMessageEventContent),
459
460 Text(TextMessageEventContent),
462
463 Video(VideoMessageEventContent),
465
466 VerificationRequest(KeyVerificationRequestEventContent),
468
469 #[doc(hidden)]
471 _Custom(CustomEventContent),
472}
473
474impl MessageType {
475 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
490 fn deserialize_variant<T: DeserializeOwned>(
491 body: String,
492 mut obj: JsonObject,
493 ) -> serde_json::Result<T> {
494 obj.insert("body".into(), body.into());
495 serde_json::from_value(JsonValue::Object(obj))
496 }
497
498 Ok(match msgtype {
499 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
500 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
501 "m.file" => Self::File(deserialize_variant(body, data)?),
502 "m.image" => Self::Image(deserialize_variant(body, data)?),
503 "m.location" => Self::Location(deserialize_variant(body, data)?),
504 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
505 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
506 "m.text" => Self::Text(deserialize_variant(body, data)?),
507 "m.video" => Self::Video(deserialize_variant(body, data)?),
508 "m.key.verification.request" => {
509 Self::VerificationRequest(deserialize_variant(body, data)?)
510 }
511 _ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
512 })
513 }
514
515 pub fn text_plain(body: impl Into<String>) -> Self {
517 Self::Text(TextMessageEventContent::plain(body))
518 }
519
520 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
522 Self::Text(TextMessageEventContent::html(body, html_body))
523 }
524
525 #[cfg(feature = "markdown")]
527 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
528 Self::Text(TextMessageEventContent::markdown(body))
529 }
530
531 pub fn notice_plain(body: impl Into<String>) -> Self {
533 Self::Notice(NoticeMessageEventContent::plain(body))
534 }
535
536 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
538 Self::Notice(NoticeMessageEventContent::html(body, html_body))
539 }
540
541 #[cfg(feature = "markdown")]
543 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
544 Self::Notice(NoticeMessageEventContent::markdown(body))
545 }
546
547 pub fn emote_plain(body: impl Into<String>) -> Self {
549 Self::Emote(EmoteMessageEventContent::plain(body))
550 }
551
552 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
554 Self::Emote(EmoteMessageEventContent::html(body, html_body))
555 }
556
557 #[cfg(feature = "markdown")]
559 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
560 Self::Emote(EmoteMessageEventContent::markdown(body))
561 }
562
563 pub fn msgtype(&self) -> &str {
565 match self {
566 Self::Audio(_) => "m.audio",
567 Self::Emote(_) => "m.emote",
568 Self::File(_) => "m.file",
569 Self::Image(_) => "m.image",
570 Self::Location(_) => "m.location",
571 Self::Notice(_) => "m.notice",
572 Self::ServerNotice(_) => "m.server_notice",
573 Self::Text(_) => "m.text",
574 Self::Video(_) => "m.video",
575 Self::VerificationRequest(_) => "m.key.verification.request",
576 Self::_Custom(c) => &c.msgtype,
577 }
578 }
579
580 pub fn body(&self) -> &str {
582 match self {
583 MessageType::Audio(m) => &m.body,
584 MessageType::Emote(m) => &m.body,
585 MessageType::File(m) => &m.body,
586 MessageType::Image(m) => &m.body,
587 MessageType::Location(m) => &m.body,
588 MessageType::Notice(m) => &m.body,
589 MessageType::ServerNotice(m) => &m.body,
590 MessageType::Text(m) => &m.body,
591 MessageType::Video(m) => &m.body,
592 MessageType::VerificationRequest(m) => &m.body,
593 MessageType::_Custom(m) => &m.body,
594 }
595 }
596
597 pub fn data(&self) -> Cow<'_, JsonObject> {
605 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
606 match serde_json::to_value(obj).expect("message type serialization to succeed") {
607 JsonValue::Object(mut obj) => {
608 obj.remove("body");
609 obj
610 }
611 _ => panic!("all message types must serialize to objects"),
612 }
613 }
614
615 match self {
616 Self::Audio(d) => Cow::Owned(serialize(d)),
617 Self::Emote(d) => Cow::Owned(serialize(d)),
618 Self::File(d) => Cow::Owned(serialize(d)),
619 Self::Image(d) => Cow::Owned(serialize(d)),
620 Self::Location(d) => Cow::Owned(serialize(d)),
621 Self::Notice(d) => Cow::Owned(serialize(d)),
622 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
623 Self::Text(d) => Cow::Owned(serialize(d)),
624 Self::Video(d) => Cow::Owned(serialize(d)),
625 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
626 Self::_Custom(c) => Cow::Borrowed(&c.data),
627 }
628 }
629
630 #[cfg(feature = "html")]
644 pub fn sanitize(
645 &mut self,
646 mode: HtmlSanitizerMode,
647 remove_reply_fallback: RemoveReplyFallback,
648 ) {
649 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
650 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
651 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
652 {
653 if let Some(formatted) = formatted {
654 formatted.sanitize_html(mode, remove_reply_fallback);
655 }
656 if remove_reply_fallback == RemoveReplyFallback::Yes {
657 *body = remove_plain_reply_fallback(body).to_owned();
658 }
659 }
660 }
661
662 #[track_caller]
663 fn add_reply_fallback(&mut self, original_event: OriginalEventData<'_>) {
664 let empty_formatted_body = || FormattedBody::html(String::new());
665
666 let (body, formatted) = {
667 match self {
668 MessageType::Emote(m) => {
669 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
670 }
671 MessageType::Notice(m) => {
672 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
673 }
674 MessageType::Text(m) => {
675 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
676 }
677 MessageType::Audio(m) => (&mut m.body, None),
678 MessageType::File(m) => (&mut m.body, None),
679 MessageType::Image(m) => (&mut m.body, None),
680 MessageType::Location(m) => (&mut m.body, None),
681 MessageType::ServerNotice(m) => (&mut m.body, None),
682 MessageType::Video(m) => (&mut m.body, None),
683 MessageType::VerificationRequest(m) => (&mut m.body, None),
684 MessageType::_Custom(m) => (&mut m.body, None),
685 }
686 };
687
688 if let Some(f) = formatted {
689 assert_eq!(
690 f.format,
691 MessageFormat::Html,
692 "can't add reply fallback to non-HTML formatted messages"
693 );
694
695 let formatted_body = &mut f.body;
696
697 (*body, *formatted_body) = reply::plain_and_formatted_reply_body(
698 body.as_str(),
699 (!formatted_body.is_empty()).then_some(formatted_body.as_str()),
700 original_event,
701 );
702 }
703 }
704
705 fn make_replacement_body(&mut self) {
706 let empty_formatted_body = || FormattedBody::html(String::new());
707
708 let (body, formatted) = {
709 match self {
710 MessageType::Emote(m) => {
711 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
712 }
713 MessageType::Notice(m) => {
714 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
715 }
716 MessageType::Text(m) => {
717 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
718 }
719 MessageType::Audio(m) => (&mut m.body, None),
720 MessageType::File(m) => (&mut m.body, None),
721 MessageType::Image(m) => (&mut m.body, None),
722 MessageType::Location(m) => (&mut m.body, None),
723 MessageType::ServerNotice(m) => (&mut m.body, None),
724 MessageType::Video(m) => (&mut m.body, None),
725 MessageType::VerificationRequest(m) => (&mut m.body, None),
726 MessageType::_Custom(m) => (&mut m.body, None),
727 }
728 };
729
730 *body = format!("* {body}");
732
733 if let Some(f) = formatted {
734 assert_eq!(
735 f.format,
736 MessageFormat::Html,
737 "make_replacement can't handle non-HTML formatted messages"
738 );
739
740 f.body = format!("* {}", f.body);
741 }
742 }
743}
744
745impl From<MessageType> for RoomMessageEventContent {
746 fn from(msgtype: MessageType) -> Self {
747 Self::new(msgtype)
748 }
749}
750
751impl From<RoomMessageEventContent> for MessageType {
752 fn from(content: RoomMessageEventContent) -> Self {
753 content.msgtype
754 }
755}
756
757#[derive(Debug)]
761pub struct ReplacementMetadata {
762 event_id: OwnedEventId,
763 mentions: Option<Mentions>,
764}
765
766impl ReplacementMetadata {
767 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
769 Self { event_id, mentions }
770 }
771}
772
773impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
774 fn from(value: &OriginalRoomMessageEvent) -> Self {
775 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
776 }
777}
778
779impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
780 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
781 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
782 }
783}
784
785#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
787#[derive(Clone, PartialEq, Eq, StringEnum)]
788#[non_exhaustive]
789pub enum MessageFormat {
790 #[ruma_enum(rename = "org.matrix.custom.html")]
792 Html,
793
794 #[doc(hidden)]
795 _Custom(PrivOwnedStr),
796}
797
798#[derive(Clone, Debug, Deserialize, Serialize)]
801#[allow(clippy::exhaustive_structs)]
802pub struct FormattedBody {
803 pub format: MessageFormat,
805
806 #[serde(rename = "formatted_body")]
808 pub body: String,
809}
810
811impl FormattedBody {
812 pub fn html(body: impl Into<String>) -> Self {
814 Self { format: MessageFormat::Html, body: body.into() }
815 }
816
817 #[cfg(feature = "markdown")]
821 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
822 parse_markdown(body.as_ref()).map(Self::html)
823 }
824
825 #[cfg(feature = "html")]
836 pub fn sanitize_html(
837 &mut self,
838 mode: HtmlSanitizerMode,
839 remove_reply_fallback: RemoveReplyFallback,
840 ) {
841 if self.format == MessageFormat::Html {
842 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
843 }
844 }
845}
846
847#[doc(hidden)]
849#[derive(Clone, Debug, Deserialize, Serialize)]
850pub struct CustomEventContent {
851 msgtype: String,
853
854 body: String,
856
857 #[serde(flatten)]
859 data: JsonObject,
860}
861
862#[cfg(feature = "markdown")]
863pub(crate) fn parse_markdown(text: &str) -> Option<String> {
864 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
865
866 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
867
868 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
869 .map(|event| match event {
870 Event::SoftBreak => Event::HardBreak,
871 _ => event,
872 })
873 .collect();
874
875 let first_event_is_paragraph_start =
878 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
879 let last_event_is_paragraph_end =
880 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
881 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
882 let mut has_markdown = !is_inline;
883
884 if !has_markdown {
885 let mut pos = 0;
888
889 for event in parser_events.iter().skip(1) {
890 match event {
891 Event::Text(s) => {
892 if text[pos..].starts_with(s.as_ref()) {
897 pos += s.len();
898 continue;
899 }
900 }
901 Event::HardBreak => {
902 if text[pos..].starts_with("\r\n") {
906 pos += 2;
907 continue;
908 } else if text[pos..].starts_with(['\r', '\n']) {
909 pos += 1;
910 continue;
911 }
912 }
913 Event::End(TagEnd::Paragraph) => continue,
916 Event::Start(tag) => {
918 is_inline &= !is_block_tag(tag);
919 }
920 _ => {}
921 }
922
923 has_markdown = true;
924
925 if !is_inline {
927 break;
928 }
929 }
930
931 has_markdown |= pos != text.len();
933 }
934
935 if !has_markdown {
937 return None;
938 }
939
940 let mut events_iter = parser_events.into_iter();
941
942 if is_inline {
944 events_iter.next();
945 events_iter.next_back();
946 }
947
948 let mut html_body = String::new();
949 pulldown_cmark::html::push_html(&mut html_body, events_iter);
950
951 Some(html_body)
952}
953
954#[cfg(feature = "markdown")]
956fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
957 use pulldown_cmark::Tag;
958
959 matches!(
960 tag,
961 Tag::Paragraph
962 | Tag::Heading { .. }
963 | Tag::BlockQuote(_)
964 | Tag::CodeBlock(_)
965 | Tag::HtmlBlock
966 | Tag::List(_)
967 | Tag::FootnoteDefinition(_)
968 | Tag::Table(_)
969 )
970}
971
972#[cfg(all(test, feature = "markdown"))]
973mod tests {
974 use super::parse_markdown;
975
976 #[test]
977 fn detect_markdown() {
978 let text = "Hello world.";
980 assert_eq!(parse_markdown(text), None);
981
982 let text = "Hello\nworld.";
984 assert_eq!(parse_markdown(text), None);
985
986 let text = "Hello\n\nworld.";
988 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
989
990 let text = "## Hello\n\nworld.";
992 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
993
994 let text = "Hello\n\n```\nworld.\n```";
996 assert_eq!(
997 parse_markdown(text).as_deref(),
998 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
999 );
1000
1001 let text = "Hello **world**.";
1003 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
1004
1005 let text = r#"Hello \<world\>."#;
1007 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
1008
1009 let text = r#"\> Hello world."#;
1011 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
1012
1013 let text = r#"Hello <world>."#;
1015 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
1016
1017 let text = "Hello w⊕rld.";
1019 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
1020 }
1021
1022 #[test]
1023 fn detect_commonmark() {
1024 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
1027 assert_eq!(
1028 parse_markdown(text).as_deref(),
1029 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
1030 );
1031
1032 let text = r#"\→\A\a\ \3\φ\«"#;
1033 assert_eq!(parse_markdown(text).as_deref(), None);
1034
1035 let text = r#"\*not emphasized*"#;
1036 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
1037
1038 let text = r#"\<br/> not a tag"#;
1039 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
1040
1041 let text = r#"\[not a link](/foo)"#;
1042 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
1043
1044 let text = r#"\`not code`"#;
1045 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
1046
1047 let text = r#"1\. not a list"#;
1048 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
1049
1050 let text = r#"\* not a list"#;
1051 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
1052
1053 let text = r#"\# not a heading"#;
1054 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
1055
1056 let text = r#"\[foo]: /url "not a reference""#;
1057 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
1058
1059 let text = r#"\ö not a character entity"#;
1060 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
1061
1062 let text = r#"\\*emphasis*"#;
1063 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
1064
1065 let text = "foo\\\nbar";
1066 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1067
1068 let text = " ***\n ***\n ***";
1069 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1070
1071 let text = "Foo\n***\nbar";
1072 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1073
1074 let text = "</div>\n*foo*";
1075 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1076
1077 let text = "<div>\n*foo*\n\n*bar*";
1078 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1079
1080 let text = "aaa\nbbb\n\nccc\nddd";
1081 assert_eq!(
1082 parse_markdown(text).as_deref(),
1083 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1084 );
1085
1086 let text = " aaa\n bbb";
1087 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1088
1089 let text = "aaa\n bbb\n ccc";
1090 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1091
1092 let text = "aaa \nbbb ";
1093 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1094 }
1095}