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 thread(&self) -> Option<&Thread> {
267 self.relates_to.as_ref().and_then(as_variant!(Relation::Thread))
268 }
269
270 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
274 let RoomMessageEventContentWithoutRelation {
275 msgtype,
276 mentions,
277 #[cfg(feature = "unstable-msc4471")]
278 stream,
279 } = new_content;
280 self.msgtype = msgtype;
281 self.mentions = mentions;
282 #[cfg(feature = "unstable-msc4471")]
283 {
284 self.stream = stream;
285 }
286 }
287
288 #[cfg(feature = "html")]
301 pub fn sanitize(
302 &mut self,
303 mode: HtmlSanitizerMode,
304 remove_reply_fallback: RemoveReplyFallback,
305 ) {
306 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply(_))) {
307 remove_reply_fallback
308 } else {
309 RemoveReplyFallback::No
310 };
311
312 self.msgtype.sanitize(mode, remove_reply_fallback);
313 }
314
315 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
316 if self.relates_to.is_some() {
317 warn!("Overwriting existing relates_to value");
318 }
319
320 self.into()
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 (body, formatted) = {
632 match self {
633 MessageType::Emote(m) => (&mut m.body, m.formatted.as_mut()),
634 MessageType::Notice(m) => (&mut m.body, m.formatted.as_mut()),
635 MessageType::Text(m) => (&mut m.body, m.formatted.as_mut()),
636 MessageType::Audio(m) => (&mut m.body, None),
637 MessageType::File(m) => (&mut m.body, None),
638 #[cfg(feature = "unstable-msc4274")]
639 MessageType::Gallery(m) => (&mut m.body, None),
640 MessageType::Image(m) => (&mut m.body, None),
641 MessageType::Location(m) => (&mut m.body, None),
642 MessageType::ServerNotice(m) => (&mut m.body, None),
643 MessageType::Video(m) => (&mut m.body, None),
644 MessageType::VerificationRequest(m) => (&mut m.body, None),
645 MessageType::_Custom(m) => (&mut m.body, None),
646 }
647 };
648
649 *body = format!("* {body}");
651
652 if let Some(f) = formatted {
653 assert_eq!(
654 f.format,
655 MessageFormat::Html,
656 "make_replacement can't handle non-HTML formatted messages"
657 );
658
659 f.body = format!("* {}", f.body);
660 }
661 }
662}
663
664impl From<MessageType> for RoomMessageEventContent {
665 fn from(msgtype: MessageType) -> Self {
666 Self::new(msgtype)
667 }
668}
669
670impl From<RoomMessageEventContent> for MessageType {
671 fn from(content: RoomMessageEventContent) -> Self {
672 content.msgtype
673 }
674}
675
676#[derive(Debug)]
680pub struct ReplacementMetadata {
681 event_id: OwnedEventId,
682 mentions: Option<Mentions>,
683}
684
685impl ReplacementMetadata {
686 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
688 Self { event_id, mentions }
689 }
690}
691
692impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
693 fn from(value: &OriginalRoomMessageEvent) -> Self {
694 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
695 }
696}
697
698impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
699 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
700 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
701 }
702}
703
704#[derive(Clone, Copy, Debug)]
709pub struct ReplyMetadata<'a> {
710 event_id: &'a EventId,
712 sender: &'a UserId,
714 thread: Option<&'a Thread>,
716}
717
718impl<'a> ReplyMetadata<'a> {
719 pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
721 Self { event_id, sender, thread }
722 }
723}
724
725impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
726 fn from(value: &'a OriginalRoomMessageEvent) -> Self {
727 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
728 }
729}
730
731impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
732 fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
733 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
734 }
735}
736
737#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
739#[derive(Clone, StringEnum)]
740#[non_exhaustive]
741pub enum MessageFormat {
742 #[ruma_enum(rename = "org.matrix.custom.html")]
744 Html,
745
746 #[doc(hidden)]
747 _Custom(PrivOwnedStr),
748}
749
750#[derive(Clone, Debug, Deserialize, Serialize)]
753#[allow(clippy::exhaustive_structs)]
754pub struct FormattedBody {
755 pub format: MessageFormat,
757
758 #[serde(rename = "formatted_body")]
760 pub body: String,
761}
762
763impl FormattedBody {
764 pub fn html(body: impl Into<String>) -> Self {
766 Self { format: MessageFormat::Html, body: body.into() }
767 }
768
769 #[cfg(feature = "markdown")]
773 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
774 parse_markdown(body.as_ref()).map(Self::html)
775 }
776
777 #[cfg(feature = "html")]
788 pub fn sanitize_html(
789 &mut self,
790 mode: HtmlSanitizerMode,
791 remove_reply_fallback: RemoveReplyFallback,
792 ) {
793 if self.format == MessageFormat::Html {
794 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
795 }
796 }
797}
798
799#[doc(hidden)]
801#[derive(Clone, Debug, Serialize)]
802pub struct CustomMessageContent {
803 msgtype: String,
805
806 body: String,
808
809 #[serde(flatten)]
811 data: JsonObject,
812}
813
814#[cfg(feature = "markdown")]
815pub(crate) fn parse_markdown(text: &str) -> Option<String> {
816 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
817
818 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
819
820 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
821 .map(|event| match event {
822 Event::SoftBreak => Event::HardBreak,
823 _ => event,
824 })
825 .collect();
826
827 let first_event_is_paragraph_start =
830 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
831 let last_event_is_paragraph_end =
832 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
833 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
834 let mut has_markdown = !is_inline;
835
836 if !has_markdown {
837 let mut pos = 0;
840
841 for event in parser_events.iter().skip(1) {
842 match event {
843 Event::Text(s) if text[pos..].starts_with(s.as_ref()) => {
848 pos += s.len();
849 continue;
850 }
851 Event::HardBreak => {
852 if text[pos..].starts_with("\r\n") {
856 pos += 2;
857 continue;
858 } else if text[pos..].starts_with(['\r', '\n']) {
859 pos += 1;
860 continue;
861 }
862 }
863 Event::End(TagEnd::Paragraph) => continue,
866 Event::Start(tag) => {
868 is_inline &= !is_block_tag(tag);
869 }
870 _ => {}
871 }
872
873 has_markdown = true;
874
875 if !is_inline {
877 break;
878 }
879 }
880
881 has_markdown |= pos != text.len();
883 }
884
885 if !has_markdown {
887 return None;
888 }
889
890 let mut events_iter = parser_events.into_iter();
891
892 if is_inline {
894 events_iter.next();
895 events_iter.next_back();
896 }
897
898 let mut html_body = String::new();
899 pulldown_cmark::html::push_html(&mut html_body, events_iter);
900
901 Some(html_body)
902}
903
904#[cfg(feature = "markdown")]
906fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
907 use pulldown_cmark::Tag;
908
909 matches!(
910 tag,
911 Tag::Paragraph
912 | Tag::Heading { .. }
913 | Tag::BlockQuote(_)
914 | Tag::CodeBlock(_)
915 | Tag::HtmlBlock
916 | Tag::List(_)
917 | Tag::FootnoteDefinition(_)
918 | Tag::Table(_)
919 )
920}
921
922#[cfg(all(test, feature = "markdown"))]
923mod tests {
924 use super::parse_markdown;
925
926 #[test]
927 fn detect_markdown() {
928 let text = "Hello world.";
930 assert_eq!(parse_markdown(text), None);
931
932 let text = "Hello\nworld.";
934 assert_eq!(parse_markdown(text), None);
935
936 let text = "Hello\n\nworld.";
938 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
939
940 let text = "## Hello\n\nworld.";
942 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
943
944 let text = "Hello\n\n```\nworld.\n```";
946 assert_eq!(
947 parse_markdown(text).as_deref(),
948 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
949 );
950
951 let text = "Hello **world**.";
953 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
954
955 let text = r#"Hello \<world\>."#;
957 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
958
959 let text = r#"\> Hello world."#;
961 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
962
963 let text = r#"Hello <world>."#;
965 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
966
967 let text = "Hello w⊕rld.";
969 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
970 }
971
972 #[test]
973 fn detect_commonmark() {
974 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
977 assert_eq!(
978 parse_markdown(text).as_deref(),
979 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
980 );
981
982 let text = r#"\→\A\a\ \3\φ\«"#;
983 assert_eq!(parse_markdown(text).as_deref(), None);
984
985 let text = r#"\*not emphasized*"#;
986 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
987
988 let text = r#"\<br/> not a tag"#;
989 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
990
991 let text = r#"\[not a link](/foo)"#;
992 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
993
994 let text = r#"\`not code`"#;
995 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
996
997 let text = r#"1\. not a list"#;
998 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
999
1000 let text = r#"\* not a list"#;
1001 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
1002
1003 let text = r#"\# not a heading"#;
1004 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
1005
1006 let text = r#"\[foo]: /url "not a reference""#;
1007 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
1008
1009 let text = r#"\ö not a character entity"#;
1010 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
1011
1012 let text = r#"\\*emphasis*"#;
1013 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
1014
1015 let text = "foo\\\nbar";
1016 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1017
1018 let text = " ***\n ***\n ***";
1019 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1020
1021 let text = "Foo\n***\nbar";
1022 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1023
1024 let text = "</div>\n*foo*";
1025 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1026
1027 let text = "<div>\n*foo*\n\n*bar*";
1028 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1029
1030 let text = "aaa\nbbb\n\nccc\nddd";
1031 assert_eq!(
1032 parse_markdown(text).as_deref(),
1033 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1034 );
1035
1036 let text = " aaa\n bbb";
1037 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1038
1039 let text = "aaa\n bbb\n ccc";
1040 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1041
1042 let text = "aaa \nbbb ";
1043 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1044 }
1045}