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;
30#[cfg(feature = "unstable-msc4274")]
31mod gallery;
32mod image;
33mod key_verification_request;
34mod location;
35mod media_caption;
36mod notice;
37mod relation;
38pub(crate) mod relation_serde;
39pub mod sanitize;
40mod server_notice;
41mod text;
42#[cfg(feature = "unstable-msc4095")]
43mod url_preview;
44mod video;
45mod without_relation;
46
47#[cfg(feature = "unstable-msc3245-v1-compat")]
48pub use self::audio::{
49 UnstableAmplitude, UnstableAudioDetailsContentBlock, UnstableVoiceContentBlock,
50};
51#[cfg(feature = "unstable-msc4274")]
52pub use self::gallery::{GalleryItemType, GalleryMessageEventContent};
53#[cfg(feature = "unstable-msc4095")]
54pub use self::url_preview::{PreviewImage, PreviewImageSource, UrlPreview};
55pub use self::{
56 audio::{AudioInfo, AudioMessageEventContent},
57 emote::EmoteMessageEventContent,
58 file::{FileInfo, FileMessageEventContent},
59 image::ImageMessageEventContent,
60 key_verification_request::KeyVerificationRequestEventContent,
61 location::{LocationInfo, LocationMessageEventContent},
62 notice::NoticeMessageEventContent,
63 relation::{Relation, RelationWithoutReplacement},
64 relation_serde::deserialize_relation,
65 server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNoticeType},
66 text::TextMessageEventContent,
67 video::{VideoInfo, VideoMessageEventContent},
68 without_relation::RoomMessageEventContentWithoutRelation,
69};
70
71#[derive(Clone, Debug, Serialize, EventContent)]
77#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
78#[ruma_event(type = "m.room.message", kind = MessageLike)]
79pub struct RoomMessageEventContent {
80 #[serde(flatten)]
84 pub msgtype: MessageType,
85
86 #[serde(flatten, skip_serializing_if = "Option::is_none")]
90 pub relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
91
92 #[serde(rename = "m.mentions", skip_serializing_if = "Option::is_none")]
100 pub mentions: Option<Mentions>,
101}
102
103impl RoomMessageEventContent {
104 pub fn new(msgtype: MessageType) -> Self {
106 Self { msgtype, relates_to: None, mentions: None }
107 }
108
109 pub fn text_plain(body: impl Into<String>) -> Self {
111 Self::new(MessageType::text_plain(body))
112 }
113
114 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
116 Self::new(MessageType::text_html(body, html_body))
117 }
118
119 #[cfg(feature = "markdown")]
121 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
122 Self::new(MessageType::text_markdown(body))
123 }
124
125 pub fn notice_plain(body: impl Into<String>) -> Self {
127 Self::new(MessageType::notice_plain(body))
128 }
129
130 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
132 Self::new(MessageType::notice_html(body, html_body))
133 }
134
135 #[cfg(feature = "markdown")]
137 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
138 Self::new(MessageType::notice_markdown(body))
139 }
140
141 pub fn emote_plain(body: impl Into<String>) -> Self {
143 Self::new(MessageType::emote_plain(body))
144 }
145
146 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
148 Self::new(MessageType::emote_html(body, html_body))
149 }
150
151 #[cfg(feature = "markdown")]
153 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
154 Self::new(MessageType::emote_markdown(body))
155 }
156
157 #[track_caller]
166 pub fn make_reply_to<'a>(
167 self,
168 metadata: impl Into<ReplyMetadata<'a>>,
169 forward_thread: ForwardThread,
170 add_mentions: AddMentions,
171 ) -> Self {
172 self.without_relation().make_reply_to(metadata, forward_thread, add_mentions)
173 }
174
175 pub fn make_for_thread<'a>(
190 self,
191 metadata: impl Into<ReplyMetadata<'a>>,
192 is_reply: ReplyWithinThread,
193 add_mentions: AddMentions,
194 ) -> Self {
195 self.without_relation().make_for_thread(metadata, is_reply, add_mentions)
196 }
197
198 #[track_caller]
217 pub fn make_replacement(self, metadata: impl Into<ReplacementMetadata>) -> Self {
218 self.without_relation().make_replacement(metadata)
219 }
220
221 #[deprecated = "Call add_mentions before adding the relation instead."]
233 pub fn set_mentions(mut self, mentions: Mentions) -> Self {
234 if let Some(Relation::Replacement(replacement)) = &mut self.relates_to {
235 let old_mentions = &replacement.new_content.mentions;
236
237 let new_mentions = if let Some(old_mentions) = old_mentions {
238 let mut new_mentions = Mentions::new();
239
240 new_mentions.user_ids = mentions
241 .user_ids
242 .iter()
243 .filter(|u| !old_mentions.user_ids.contains(*u))
244 .cloned()
245 .collect();
246
247 new_mentions.room = mentions.room && !old_mentions.room;
248
249 new_mentions
250 } else {
251 mentions.clone()
252 };
253
254 replacement.new_content.mentions = Some(mentions);
255 self.mentions = Some(new_mentions);
256 } else {
257 self.mentions = Some(mentions);
258 }
259
260 self
261 }
262
263 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
274 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
275 self
276 }
277
278 pub fn msgtype(&self) -> &str {
283 self.msgtype.msgtype()
284 }
285
286 pub fn body(&self) -> &str {
288 self.msgtype.body()
289 }
290
291 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
293 let RoomMessageEventContentWithoutRelation { msgtype, mentions } = new_content;
294 self.msgtype = msgtype;
295 self.mentions = mentions;
296 }
297
298 #[cfg(feature = "html")]
311 pub fn sanitize(
312 &mut self,
313 mode: HtmlSanitizerMode,
314 remove_reply_fallback: RemoveReplyFallback,
315 ) {
316 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply { .. })) {
317 remove_reply_fallback
318 } else {
319 RemoveReplyFallback::No
320 };
321
322 self.msgtype.sanitize(mode, remove_reply_fallback);
323 }
324
325 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
326 if self.relates_to.is_some() {
327 warn!("Overwriting existing relates_to value");
328 }
329
330 self.into()
331 }
332
333 fn thread(&self) -> Option<&Thread> {
335 self.relates_to.as_ref().and_then(|relates_to| as_variant!(relates_to, Relation::Thread))
336 }
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
341#[allow(clippy::exhaustive_enums)]
342pub enum ForwardThread {
343 Yes,
350
351 No,
355}
356
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
359#[allow(clippy::exhaustive_enums)]
360pub enum AddMentions {
361 Yes,
367
368 No,
372}
373
374#[derive(Clone, Copy, Debug, PartialEq, Eq)]
376#[allow(clippy::exhaustive_enums)]
377pub enum ReplyWithinThread {
378 Yes,
384
385 No,
391}
392
393#[derive(Clone, Debug, Serialize)]
395#[serde(tag = "msgtype")]
396#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
397pub enum MessageType {
398 #[serde(rename = "m.audio")]
400 Audio(AudioMessageEventContent),
401
402 #[serde(rename = "m.emote")]
404 Emote(EmoteMessageEventContent),
405
406 #[serde(rename = "m.file")]
408 File(FileMessageEventContent),
409
410 #[cfg(feature = "unstable-msc4274")]
412 #[serde(rename = "dm.filament.gallery")]
413 Gallery(GalleryMessageEventContent),
414
415 #[serde(rename = "m.image")]
417 Image(ImageMessageEventContent),
418
419 #[serde(rename = "m.location")]
421 Location(LocationMessageEventContent),
422
423 #[serde(rename = "m.notice")]
425 Notice(NoticeMessageEventContent),
426
427 #[serde(rename = "m.server_notice")]
429 ServerNotice(ServerNoticeMessageEventContent),
430
431 #[serde(rename = "m.text")]
433 Text(TextMessageEventContent),
434
435 #[serde(rename = "m.video")]
437 Video(VideoMessageEventContent),
438
439 #[serde(rename = "m.key.verification.request")]
441 VerificationRequest(KeyVerificationRequestEventContent),
442
443 #[doc(hidden)]
445 #[serde(untagged)]
446 _Custom(CustomEventContent),
447}
448
449impl MessageType {
450 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
465 fn deserialize_variant<T: DeserializeOwned>(
466 body: String,
467 mut obj: JsonObject,
468 ) -> serde_json::Result<T> {
469 obj.insert("body".into(), body.into());
470 serde_json::from_value(JsonValue::Object(obj))
471 }
472
473 Ok(match msgtype {
474 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
475 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
476 "m.file" => Self::File(deserialize_variant(body, data)?),
477 #[cfg(feature = "unstable-msc4274")]
478 "dm.filament.gallery" => Self::Gallery(deserialize_variant(body, data)?),
479 "m.image" => Self::Image(deserialize_variant(body, data)?),
480 "m.location" => Self::Location(deserialize_variant(body, data)?),
481 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
482 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
483 "m.text" => Self::Text(deserialize_variant(body, data)?),
484 "m.video" => Self::Video(deserialize_variant(body, data)?),
485 "m.key.verification.request" => {
486 Self::VerificationRequest(deserialize_variant(body, data)?)
487 }
488 _ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
489 })
490 }
491
492 pub fn text_plain(body: impl Into<String>) -> Self {
494 Self::Text(TextMessageEventContent::plain(body))
495 }
496
497 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
499 Self::Text(TextMessageEventContent::html(body, html_body))
500 }
501
502 #[cfg(feature = "markdown")]
504 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
505 Self::Text(TextMessageEventContent::markdown(body))
506 }
507
508 pub fn notice_plain(body: impl Into<String>) -> Self {
510 Self::Notice(NoticeMessageEventContent::plain(body))
511 }
512
513 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
515 Self::Notice(NoticeMessageEventContent::html(body, html_body))
516 }
517
518 #[cfg(feature = "markdown")]
520 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
521 Self::Notice(NoticeMessageEventContent::markdown(body))
522 }
523
524 pub fn emote_plain(body: impl Into<String>) -> Self {
526 Self::Emote(EmoteMessageEventContent::plain(body))
527 }
528
529 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
531 Self::Emote(EmoteMessageEventContent::html(body, html_body))
532 }
533
534 #[cfg(feature = "markdown")]
536 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
537 Self::Emote(EmoteMessageEventContent::markdown(body))
538 }
539
540 pub fn msgtype(&self) -> &str {
542 match self {
543 Self::Audio(_) => "m.audio",
544 Self::Emote(_) => "m.emote",
545 Self::File(_) => "m.file",
546 #[cfg(feature = "unstable-msc4274")]
547 Self::Gallery(_) => "dm.filament.gallery",
548 Self::Image(_) => "m.image",
549 Self::Location(_) => "m.location",
550 Self::Notice(_) => "m.notice",
551 Self::ServerNotice(_) => "m.server_notice",
552 Self::Text(_) => "m.text",
553 Self::Video(_) => "m.video",
554 Self::VerificationRequest(_) => "m.key.verification.request",
555 Self::_Custom(c) => &c.msgtype,
556 }
557 }
558
559 pub fn body(&self) -> &str {
561 match self {
562 MessageType::Audio(m) => &m.body,
563 MessageType::Emote(m) => &m.body,
564 MessageType::File(m) => &m.body,
565 #[cfg(feature = "unstable-msc4274")]
566 MessageType::Gallery(m) => &m.body,
567 MessageType::Image(m) => &m.body,
568 MessageType::Location(m) => &m.body,
569 MessageType::Notice(m) => &m.body,
570 MessageType::ServerNotice(m) => &m.body,
571 MessageType::Text(m) => &m.body,
572 MessageType::Video(m) => &m.body,
573 MessageType::VerificationRequest(m) => &m.body,
574 MessageType::_Custom(m) => &m.body,
575 }
576 }
577
578 pub fn data(&self) -> Cow<'_, JsonObject> {
586 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
587 match serde_json::to_value(obj).expect("message type serialization to succeed") {
588 JsonValue::Object(mut obj) => {
589 obj.remove("body");
590 obj
591 }
592 _ => panic!("all message types must serialize to objects"),
593 }
594 }
595
596 match self {
597 Self::Audio(d) => Cow::Owned(serialize(d)),
598 Self::Emote(d) => Cow::Owned(serialize(d)),
599 Self::File(d) => Cow::Owned(serialize(d)),
600 #[cfg(feature = "unstable-msc4274")]
601 Self::Gallery(d) => Cow::Owned(serialize(d)),
602 Self::Image(d) => Cow::Owned(serialize(d)),
603 Self::Location(d) => Cow::Owned(serialize(d)),
604 Self::Notice(d) => Cow::Owned(serialize(d)),
605 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
606 Self::Text(d) => Cow::Owned(serialize(d)),
607 Self::Video(d) => Cow::Owned(serialize(d)),
608 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
609 Self::_Custom(c) => Cow::Borrowed(&c.data),
610 }
611 }
612
613 #[cfg(feature = "html")]
627 pub fn sanitize(
628 &mut self,
629 mode: HtmlSanitizerMode,
630 remove_reply_fallback: RemoveReplyFallback,
631 ) {
632 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
633 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
634 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
635 {
636 if let Some(formatted) = formatted {
637 formatted.sanitize_html(mode, remove_reply_fallback);
638 }
639 if remove_reply_fallback == RemoveReplyFallback::Yes {
640 *body = remove_plain_reply_fallback(body).to_owned();
641 }
642 }
643 }
644
645 fn make_replacement_body(&mut self) {
646 let empty_formatted_body = || FormattedBody::html(String::new());
647
648 let (body, formatted) = {
649 match self {
650 MessageType::Emote(m) => {
651 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
652 }
653 MessageType::Notice(m) => {
654 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
655 }
656 MessageType::Text(m) => {
657 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
658 }
659 MessageType::Audio(m) => (&mut m.body, None),
660 MessageType::File(m) => (&mut m.body, None),
661 #[cfg(feature = "unstable-msc4274")]
662 MessageType::Gallery(m) => (&mut m.body, None),
663 MessageType::Image(m) => (&mut m.body, None),
664 MessageType::Location(m) => (&mut m.body, None),
665 MessageType::ServerNotice(m) => (&mut m.body, None),
666 MessageType::Video(m) => (&mut m.body, None),
667 MessageType::VerificationRequest(m) => (&mut m.body, None),
668 MessageType::_Custom(m) => (&mut m.body, None),
669 }
670 };
671
672 *body = format!("* {body}");
674
675 if let Some(f) = formatted {
676 assert_eq!(
677 f.format,
678 MessageFormat::Html,
679 "make_replacement can't handle non-HTML formatted messages"
680 );
681
682 f.body = format!("* {}", f.body);
683 }
684 }
685}
686
687impl From<MessageType> for RoomMessageEventContent {
688 fn from(msgtype: MessageType) -> Self {
689 Self::new(msgtype)
690 }
691}
692
693impl From<RoomMessageEventContent> for MessageType {
694 fn from(content: RoomMessageEventContent) -> Self {
695 content.msgtype
696 }
697}
698
699#[derive(Debug)]
703pub struct ReplacementMetadata {
704 event_id: OwnedEventId,
705 mentions: Option<Mentions>,
706}
707
708impl ReplacementMetadata {
709 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
711 Self { event_id, mentions }
712 }
713}
714
715impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
716 fn from(value: &OriginalRoomMessageEvent) -> Self {
717 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
718 }
719}
720
721impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
722 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
723 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
724 }
725}
726
727#[derive(Clone, Copy, Debug)]
732pub struct ReplyMetadata<'a> {
733 event_id: &'a EventId,
735 sender: &'a UserId,
737 thread: Option<&'a Thread>,
739}
740
741impl<'a> ReplyMetadata<'a> {
742 pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
744 Self { event_id, sender, thread }
745 }
746}
747
748impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
749 fn from(value: &'a OriginalRoomMessageEvent) -> Self {
750 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
751 }
752}
753
754impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
755 fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
756 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
757 }
758}
759
760#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
762#[derive(Clone, PartialEq, Eq, StringEnum)]
763#[non_exhaustive]
764pub enum MessageFormat {
765 #[ruma_enum(rename = "org.matrix.custom.html")]
767 Html,
768
769 #[doc(hidden)]
770 _Custom(PrivOwnedStr),
771}
772
773#[derive(Clone, Debug, Deserialize, Serialize)]
776#[allow(clippy::exhaustive_structs)]
777pub struct FormattedBody {
778 pub format: MessageFormat,
780
781 #[serde(rename = "formatted_body")]
783 pub body: String,
784}
785
786impl FormattedBody {
787 pub fn html(body: impl Into<String>) -> Self {
789 Self { format: MessageFormat::Html, body: body.into() }
790 }
791
792 #[cfg(feature = "markdown")]
796 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
797 parse_markdown(body.as_ref()).map(Self::html)
798 }
799
800 #[cfg(feature = "html")]
811 pub fn sanitize_html(
812 &mut self,
813 mode: HtmlSanitizerMode,
814 remove_reply_fallback: RemoveReplyFallback,
815 ) {
816 if self.format == MessageFormat::Html {
817 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
818 }
819 }
820}
821
822#[doc(hidden)]
824#[derive(Clone, Debug, Deserialize, Serialize)]
825pub struct CustomEventContent {
826 msgtype: String,
828
829 body: String,
831
832 #[serde(flatten)]
834 data: JsonObject,
835}
836
837#[cfg(feature = "markdown")]
838pub(crate) fn parse_markdown(text: &str) -> Option<String> {
839 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
840
841 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
842
843 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
844 .map(|event| match event {
845 Event::SoftBreak => Event::HardBreak,
846 _ => event,
847 })
848 .collect();
849
850 let first_event_is_paragraph_start =
853 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
854 let last_event_is_paragraph_end =
855 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
856 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
857 let mut has_markdown = !is_inline;
858
859 if !has_markdown {
860 let mut pos = 0;
863
864 for event in parser_events.iter().skip(1) {
865 match event {
866 Event::Text(s) => {
867 if text[pos..].starts_with(s.as_ref()) {
872 pos += s.len();
873 continue;
874 }
875 }
876 Event::HardBreak => {
877 if text[pos..].starts_with("\r\n") {
881 pos += 2;
882 continue;
883 } else if text[pos..].starts_with(['\r', '\n']) {
884 pos += 1;
885 continue;
886 }
887 }
888 Event::End(TagEnd::Paragraph) => continue,
891 Event::Start(tag) => {
893 is_inline &= !is_block_tag(tag);
894 }
895 _ => {}
896 }
897
898 has_markdown = true;
899
900 if !is_inline {
902 break;
903 }
904 }
905
906 has_markdown |= pos != text.len();
908 }
909
910 if !has_markdown {
912 return None;
913 }
914
915 let mut events_iter = parser_events.into_iter();
916
917 if is_inline {
919 events_iter.next();
920 events_iter.next_back();
921 }
922
923 let mut html_body = String::new();
924 pulldown_cmark::html::push_html(&mut html_body, events_iter);
925
926 Some(html_body)
927}
928
929#[cfg(feature = "markdown")]
931fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
932 use pulldown_cmark::Tag;
933
934 matches!(
935 tag,
936 Tag::Paragraph
937 | Tag::Heading { .. }
938 | Tag::BlockQuote(_)
939 | Tag::CodeBlock(_)
940 | Tag::HtmlBlock
941 | Tag::List(_)
942 | Tag::FootnoteDefinition(_)
943 | Tag::Table(_)
944 )
945}
946
947#[cfg(all(test, feature = "markdown"))]
948mod tests {
949 use super::parse_markdown;
950
951 #[test]
952 fn detect_markdown() {
953 let text = "Hello world.";
955 assert_eq!(parse_markdown(text), None);
956
957 let text = "Hello\nworld.";
959 assert_eq!(parse_markdown(text), None);
960
961 let text = "Hello\n\nworld.";
963 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
964
965 let text = "## Hello\n\nworld.";
967 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
968
969 let text = "Hello\n\n```\nworld.\n```";
971 assert_eq!(
972 parse_markdown(text).as_deref(),
973 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
974 );
975
976 let text = "Hello **world**.";
978 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
979
980 let text = r#"Hello \<world\>."#;
982 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
983
984 let text = r#"\> Hello world."#;
986 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
987
988 let text = r#"Hello <world>."#;
990 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
991
992 let text = "Hello w⊕rld.";
994 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
995 }
996
997 #[test]
998 fn detect_commonmark() {
999 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
1002 assert_eq!(
1003 parse_markdown(text).as_deref(),
1004 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
1005 );
1006
1007 let text = r#"\→\A\a\ \3\φ\«"#;
1008 assert_eq!(parse_markdown(text).as_deref(), None);
1009
1010 let text = r#"\*not emphasized*"#;
1011 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
1012
1013 let text = r#"\<br/> not a tag"#;
1014 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
1015
1016 let text = r#"\[not a link](/foo)"#;
1017 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
1018
1019 let text = r#"\`not code`"#;
1020 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
1021
1022 let text = r#"1\. not a list"#;
1023 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
1024
1025 let text = r#"\* not a list"#;
1026 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
1027
1028 let text = r#"\# not a heading"#;
1029 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
1030
1031 let text = r#"\[foo]: /url "not a reference""#;
1032 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
1033
1034 let text = r#"\ö not a character entity"#;
1035 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
1036
1037 let text = r#"\\*emphasis*"#;
1038 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
1039
1040 let text = "foo\\\nbar";
1041 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1042
1043 let text = " ***\n ***\n ***";
1044 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1045
1046 let text = "Foo\n***\nbar";
1047 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1048
1049 let text = "</div>\n*foo*";
1050 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1051
1052 let text = "<div>\n*foo*\n\n*bar*";
1053 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1054
1055 let text = "aaa\nbbb\n\nccc\nddd";
1056 assert_eq!(
1057 parse_markdown(text).as_deref(),
1058 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1059 );
1060
1061 let text = " aaa\n bbb";
1062 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1063
1064 let text = "aaa\n bbb\n ccc";
1065 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1066
1067 let text = "aaa \nbbb ";
1068 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1069 }
1070}