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;
21use crate::{Mentions, PrivOwnedStr, relation::Thread};
22
23mod audio;
24mod content_serde;
25mod emote;
26mod file;
27#[cfg(feature = "unstable-msc4274")]
28mod gallery;
29mod image;
30mod key_verification_request;
31mod location;
32mod media_caption;
33mod notice;
34mod relation;
35pub(crate) mod relation_serde;
36pub mod sanitize;
37mod server_notice;
38mod text;
39#[cfg(feature = "unstable-msc4095")]
40mod url_preview;
41mod video;
42mod without_relation;
43
44#[cfg(feature = "unstable-msc3245-v1-compat")]
45pub use self::audio::{
46 UnstableAmplitude, UnstableAudioDetailsContentBlock, UnstableVoiceContentBlock,
47};
48#[cfg(feature = "unstable-msc4274")]
49pub use self::gallery::{GalleryItemType, GalleryMessageEventContent};
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")]
99 pub mentions: Option<Mentions>,
100}
101
102impl RoomMessageEventContent {
103 pub fn new(msgtype: MessageType) -> Self {
105 Self { msgtype, relates_to: None, mentions: None }
106 }
107
108 pub fn text_plain(body: impl Into<String>) -> Self {
110 Self::new(MessageType::text_plain(body))
111 }
112
113 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
115 Self::new(MessageType::text_html(body, html_body))
116 }
117
118 #[cfg(feature = "markdown")]
120 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
121 Self::new(MessageType::text_markdown(body))
122 }
123
124 pub fn notice_plain(body: impl Into<String>) -> Self {
126 Self::new(MessageType::notice_plain(body))
127 }
128
129 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
131 Self::new(MessageType::notice_html(body, html_body))
132 }
133
134 #[cfg(feature = "markdown")]
136 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
137 Self::new(MessageType::notice_markdown(body))
138 }
139
140 pub fn emote_plain(body: impl Into<String>) -> Self {
142 Self::new(MessageType::emote_plain(body))
143 }
144
145 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
147 Self::new(MessageType::emote_html(body, html_body))
148 }
149
150 #[cfg(feature = "markdown")]
152 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
153 Self::new(MessageType::emote_markdown(body))
154 }
155
156 #[track_caller]
165 pub fn make_reply_to<'a>(
166 self,
167 metadata: impl Into<ReplyMetadata<'a>>,
168 forward_thread: ForwardThread,
169 add_mentions: AddMentions,
170 ) -> Self {
171 self.without_relation().make_reply_to(metadata, forward_thread, add_mentions)
172 }
173
174 pub fn make_for_thread<'a>(
189 self,
190 metadata: impl Into<ReplyMetadata<'a>>,
191 is_reply: ReplyWithinThread,
192 add_mentions: AddMentions,
193 ) -> Self {
194 self.without_relation().make_for_thread(metadata, is_reply, add_mentions)
195 }
196
197 #[track_caller]
216 pub fn make_replacement(self, metadata: impl Into<ReplacementMetadata>) -> Self {
217 self.without_relation().make_replacement(metadata)
218 }
219
220 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
231 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
232 self
233 }
234
235 pub fn msgtype(&self) -> &str {
240 self.msgtype.msgtype()
241 }
242
243 pub fn body(&self) -> &str {
245 self.msgtype.body()
246 }
247
248 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
252 let RoomMessageEventContentWithoutRelation { msgtype, mentions } = new_content;
253 self.msgtype = msgtype;
254 self.mentions = mentions;
255 }
256
257 #[cfg(feature = "html")]
270 pub fn sanitize(
271 &mut self,
272 mode: HtmlSanitizerMode,
273 remove_reply_fallback: RemoveReplyFallback,
274 ) {
275 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply(_))) {
276 remove_reply_fallback
277 } else {
278 RemoveReplyFallback::No
279 };
280
281 self.msgtype.sanitize(mode, remove_reply_fallback);
282 }
283
284 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
285 if self.relates_to.is_some() {
286 warn!("Overwriting existing relates_to value");
287 }
288
289 self.into()
290 }
291
292 fn thread(&self) -> Option<&Thread> {
294 self.relates_to.as_ref().and_then(|relates_to| as_variant!(relates_to, Relation::Thread))
295 }
296}
297
298#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300#[allow(clippy::exhaustive_enums)]
301pub enum ForwardThread {
302 Yes,
309
310 No,
314}
315
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
318#[allow(clippy::exhaustive_enums)]
319pub enum AddMentions {
320 Yes,
326
327 No,
331}
332
333#[derive(Clone, Copy, Debug, PartialEq, Eq)]
335#[allow(clippy::exhaustive_enums)]
336pub enum ReplyWithinThread {
337 Yes,
343
344 No,
350}
351
352#[derive(Clone, Debug, Serialize)]
354#[serde(tag = "msgtype")]
355#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
356pub enum MessageType {
357 #[serde(rename = "m.audio")]
359 Audio(AudioMessageEventContent),
360
361 #[serde(rename = "m.emote")]
363 Emote(EmoteMessageEventContent),
364
365 #[serde(rename = "m.file")]
367 File(FileMessageEventContent),
368
369 #[cfg(feature = "unstable-msc4274")]
371 #[serde(rename = "dm.filament.gallery")]
372 Gallery(GalleryMessageEventContent),
373
374 #[serde(rename = "m.image")]
376 Image(ImageMessageEventContent),
377
378 #[serde(rename = "m.location")]
380 Location(LocationMessageEventContent),
381
382 #[serde(rename = "m.notice")]
384 Notice(NoticeMessageEventContent),
385
386 #[serde(rename = "m.server_notice")]
388 ServerNotice(ServerNoticeMessageEventContent),
389
390 #[serde(rename = "m.text")]
392 Text(TextMessageEventContent),
393
394 #[serde(rename = "m.video")]
396 Video(VideoMessageEventContent),
397
398 #[serde(rename = "m.key.verification.request")]
400 VerificationRequest(KeyVerificationRequestEventContent),
401
402 #[doc(hidden)]
404 #[serde(untagged)]
405 _Custom(CustomEventContent),
406}
407
408impl MessageType {
409 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
424 fn deserialize_variant<T: DeserializeOwned>(
425 body: String,
426 mut obj: JsonObject,
427 ) -> serde_json::Result<T> {
428 obj.insert("body".into(), body.into());
429 serde_json::from_value(JsonValue::Object(obj))
430 }
431
432 Ok(match msgtype {
433 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
434 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
435 "m.file" => Self::File(deserialize_variant(body, data)?),
436 #[cfg(feature = "unstable-msc4274")]
437 "dm.filament.gallery" => Self::Gallery(deserialize_variant(body, data)?),
438 "m.image" => Self::Image(deserialize_variant(body, data)?),
439 "m.location" => Self::Location(deserialize_variant(body, data)?),
440 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
441 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
442 "m.text" => Self::Text(deserialize_variant(body, data)?),
443 "m.video" => Self::Video(deserialize_variant(body, data)?),
444 "m.key.verification.request" => {
445 Self::VerificationRequest(deserialize_variant(body, data)?)
446 }
447 _ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
448 })
449 }
450
451 pub fn text_plain(body: impl Into<String>) -> Self {
453 Self::Text(TextMessageEventContent::plain(body))
454 }
455
456 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
458 Self::Text(TextMessageEventContent::html(body, html_body))
459 }
460
461 #[cfg(feature = "markdown")]
463 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
464 Self::Text(TextMessageEventContent::markdown(body))
465 }
466
467 pub fn notice_plain(body: impl Into<String>) -> Self {
469 Self::Notice(NoticeMessageEventContent::plain(body))
470 }
471
472 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
474 Self::Notice(NoticeMessageEventContent::html(body, html_body))
475 }
476
477 #[cfg(feature = "markdown")]
479 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
480 Self::Notice(NoticeMessageEventContent::markdown(body))
481 }
482
483 pub fn emote_plain(body: impl Into<String>) -> Self {
485 Self::Emote(EmoteMessageEventContent::plain(body))
486 }
487
488 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
490 Self::Emote(EmoteMessageEventContent::html(body, html_body))
491 }
492
493 #[cfg(feature = "markdown")]
495 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
496 Self::Emote(EmoteMessageEventContent::markdown(body))
497 }
498
499 pub fn msgtype(&self) -> &str {
501 match self {
502 Self::Audio(_) => "m.audio",
503 Self::Emote(_) => "m.emote",
504 Self::File(_) => "m.file",
505 #[cfg(feature = "unstable-msc4274")]
506 Self::Gallery(_) => "dm.filament.gallery",
507 Self::Image(_) => "m.image",
508 Self::Location(_) => "m.location",
509 Self::Notice(_) => "m.notice",
510 Self::ServerNotice(_) => "m.server_notice",
511 Self::Text(_) => "m.text",
512 Self::Video(_) => "m.video",
513 Self::VerificationRequest(_) => "m.key.verification.request",
514 Self::_Custom(c) => &c.msgtype,
515 }
516 }
517
518 pub fn body(&self) -> &str {
520 match self {
521 MessageType::Audio(m) => &m.body,
522 MessageType::Emote(m) => &m.body,
523 MessageType::File(m) => &m.body,
524 #[cfg(feature = "unstable-msc4274")]
525 MessageType::Gallery(m) => &m.body,
526 MessageType::Image(m) => &m.body,
527 MessageType::Location(m) => &m.body,
528 MessageType::Notice(m) => &m.body,
529 MessageType::ServerNotice(m) => &m.body,
530 MessageType::Text(m) => &m.body,
531 MessageType::Video(m) => &m.body,
532 MessageType::VerificationRequest(m) => &m.body,
533 MessageType::_Custom(m) => &m.body,
534 }
535 }
536
537 pub fn data(&self) -> Cow<'_, JsonObject> {
545 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
546 match serde_json::to_value(obj).expect("message type serialization to succeed") {
547 JsonValue::Object(mut obj) => {
548 obj.remove("body");
549 obj
550 }
551 _ => panic!("all message types must serialize to objects"),
552 }
553 }
554
555 match self {
556 Self::Audio(d) => Cow::Owned(serialize(d)),
557 Self::Emote(d) => Cow::Owned(serialize(d)),
558 Self::File(d) => Cow::Owned(serialize(d)),
559 #[cfg(feature = "unstable-msc4274")]
560 Self::Gallery(d) => Cow::Owned(serialize(d)),
561 Self::Image(d) => Cow::Owned(serialize(d)),
562 Self::Location(d) => Cow::Owned(serialize(d)),
563 Self::Notice(d) => Cow::Owned(serialize(d)),
564 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
565 Self::Text(d) => Cow::Owned(serialize(d)),
566 Self::Video(d) => Cow::Owned(serialize(d)),
567 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
568 Self::_Custom(c) => Cow::Borrowed(&c.data),
569 }
570 }
571
572 #[cfg(feature = "html")]
586 pub fn sanitize(
587 &mut self,
588 mode: HtmlSanitizerMode,
589 remove_reply_fallback: RemoveReplyFallback,
590 ) {
591 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
592 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
593 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
594 {
595 if let Some(formatted) = formatted {
596 formatted.sanitize_html(mode, remove_reply_fallback);
597 }
598 if remove_reply_fallback == RemoveReplyFallback::Yes {
599 *body = remove_plain_reply_fallback(body).to_owned();
600 }
601 }
602 }
603
604 fn make_replacement_body(&mut self) {
605 let empty_formatted_body = || FormattedBody::html(String::new());
606
607 let (body, formatted) = {
608 match self {
609 MessageType::Emote(m) => {
610 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
611 }
612 MessageType::Notice(m) => {
613 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
614 }
615 MessageType::Text(m) => {
616 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
617 }
618 MessageType::Audio(m) => (&mut m.body, None),
619 MessageType::File(m) => (&mut m.body, None),
620 #[cfg(feature = "unstable-msc4274")]
621 MessageType::Gallery(m) => (&mut m.body, None),
622 MessageType::Image(m) => (&mut m.body, None),
623 MessageType::Location(m) => (&mut m.body, None),
624 MessageType::ServerNotice(m) => (&mut m.body, None),
625 MessageType::Video(m) => (&mut m.body, None),
626 MessageType::VerificationRequest(m) => (&mut m.body, None),
627 MessageType::_Custom(m) => (&mut m.body, None),
628 }
629 };
630
631 *body = format!("* {body}");
633
634 if let Some(f) = formatted {
635 assert_eq!(
636 f.format,
637 MessageFormat::Html,
638 "make_replacement can't handle non-HTML formatted messages"
639 );
640
641 f.body = format!("* {}", f.body);
642 }
643 }
644}
645
646impl From<MessageType> for RoomMessageEventContent {
647 fn from(msgtype: MessageType) -> Self {
648 Self::new(msgtype)
649 }
650}
651
652impl From<RoomMessageEventContent> for MessageType {
653 fn from(content: RoomMessageEventContent) -> Self {
654 content.msgtype
655 }
656}
657
658#[derive(Debug)]
662pub struct ReplacementMetadata {
663 event_id: OwnedEventId,
664 mentions: Option<Mentions>,
665}
666
667impl ReplacementMetadata {
668 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
670 Self { event_id, mentions }
671 }
672}
673
674impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
675 fn from(value: &OriginalRoomMessageEvent) -> Self {
676 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
677 }
678}
679
680impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
681 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
682 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
683 }
684}
685
686#[derive(Clone, Copy, Debug)]
691pub struct ReplyMetadata<'a> {
692 event_id: &'a EventId,
694 sender: &'a UserId,
696 thread: Option<&'a Thread>,
698}
699
700impl<'a> ReplyMetadata<'a> {
701 pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
703 Self { event_id, sender, thread }
704 }
705}
706
707impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
708 fn from(value: &'a OriginalRoomMessageEvent) -> Self {
709 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
710 }
711}
712
713impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
714 fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
715 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
716 }
717}
718
719#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
721#[derive(Clone, StringEnum)]
722#[non_exhaustive]
723pub enum MessageFormat {
724 #[ruma_enum(rename = "org.matrix.custom.html")]
726 Html,
727
728 #[doc(hidden)]
729 _Custom(PrivOwnedStr),
730}
731
732#[derive(Clone, Debug, Deserialize, Serialize)]
735#[allow(clippy::exhaustive_structs)]
736pub struct FormattedBody {
737 pub format: MessageFormat,
739
740 #[serde(rename = "formatted_body")]
742 pub body: String,
743}
744
745impl FormattedBody {
746 pub fn html(body: impl Into<String>) -> Self {
748 Self { format: MessageFormat::Html, body: body.into() }
749 }
750
751 #[cfg(feature = "markdown")]
755 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
756 parse_markdown(body.as_ref()).map(Self::html)
757 }
758
759 #[cfg(feature = "html")]
770 pub fn sanitize_html(
771 &mut self,
772 mode: HtmlSanitizerMode,
773 remove_reply_fallback: RemoveReplyFallback,
774 ) {
775 if self.format == MessageFormat::Html {
776 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
777 }
778 }
779}
780
781#[doc(hidden)]
783#[derive(Clone, Debug, Deserialize, Serialize)]
784pub struct CustomEventContent {
785 msgtype: String,
787
788 body: String,
790
791 #[serde(flatten)]
793 data: JsonObject,
794}
795
796#[cfg(feature = "markdown")]
797pub(crate) fn parse_markdown(text: &str) -> Option<String> {
798 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
799
800 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
801
802 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
803 .map(|event| match event {
804 Event::SoftBreak => Event::HardBreak,
805 _ => event,
806 })
807 .collect();
808
809 let first_event_is_paragraph_start =
812 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
813 let last_event_is_paragraph_end =
814 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
815 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
816 let mut has_markdown = !is_inline;
817
818 if !has_markdown {
819 let mut pos = 0;
822
823 for event in parser_events.iter().skip(1) {
824 match event {
825 Event::Text(s) if text[pos..].starts_with(s.as_ref()) => {
830 pos += s.len();
831 continue;
832 }
833 Event::HardBreak => {
834 if text[pos..].starts_with("\r\n") {
838 pos += 2;
839 continue;
840 } else if text[pos..].starts_with(['\r', '\n']) {
841 pos += 1;
842 continue;
843 }
844 }
845 Event::End(TagEnd::Paragraph) => continue,
848 Event::Start(tag) => {
850 is_inline &= !is_block_tag(tag);
851 }
852 _ => {}
853 }
854
855 has_markdown = true;
856
857 if !is_inline {
859 break;
860 }
861 }
862
863 has_markdown |= pos != text.len();
865 }
866
867 if !has_markdown {
869 return None;
870 }
871
872 let mut events_iter = parser_events.into_iter();
873
874 if is_inline {
876 events_iter.next();
877 events_iter.next_back();
878 }
879
880 let mut html_body = String::new();
881 pulldown_cmark::html::push_html(&mut html_body, events_iter);
882
883 Some(html_body)
884}
885
886#[cfg(feature = "markdown")]
888fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
889 use pulldown_cmark::Tag;
890
891 matches!(
892 tag,
893 Tag::Paragraph
894 | Tag::Heading { .. }
895 | Tag::BlockQuote(_)
896 | Tag::CodeBlock(_)
897 | Tag::HtmlBlock
898 | Tag::List(_)
899 | Tag::FootnoteDefinition(_)
900 | Tag::Table(_)
901 )
902}
903
904#[cfg(all(test, feature = "markdown"))]
905mod tests {
906 use super::parse_markdown;
907
908 #[test]
909 fn detect_markdown() {
910 let text = "Hello world.";
912 assert_eq!(parse_markdown(text), None);
913
914 let text = "Hello\nworld.";
916 assert_eq!(parse_markdown(text), None);
917
918 let text = "Hello\n\nworld.";
920 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
921
922 let text = "## Hello\n\nworld.";
924 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
925
926 let text = "Hello\n\n```\nworld.\n```";
928 assert_eq!(
929 parse_markdown(text).as_deref(),
930 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
931 );
932
933 let text = "Hello **world**.";
935 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
936
937 let text = r#"Hello \<world\>."#;
939 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
940
941 let text = r#"\> Hello world."#;
943 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
944
945 let text = r#"Hello <world>."#;
947 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
948
949 let text = "Hello w⊕rld.";
951 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
952 }
953
954 #[test]
955 fn detect_commonmark() {
956 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
959 assert_eq!(
960 parse_markdown(text).as_deref(),
961 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
962 );
963
964 let text = r#"\→\A\a\ \3\φ\«"#;
965 assert_eq!(parse_markdown(text).as_deref(), None);
966
967 let text = r#"\*not emphasized*"#;
968 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
969
970 let text = r#"\<br/> not a tag"#;
971 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
972
973 let text = r#"\[not a link](/foo)"#;
974 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
975
976 let text = r#"\`not code`"#;
977 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
978
979 let text = r#"1\. not a list"#;
980 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
981
982 let text = r#"\* not a list"#;
983 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
984
985 let text = r#"\# not a heading"#;
986 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
987
988 let text = r#"\[foo]: /url "not a reference""#;
989 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
990
991 let text = r#"\ö not a character entity"#;
992 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
993
994 let text = r#"\\*emphasis*"#;
995 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
996
997 let text = "foo\\\nbar";
998 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
999
1000 let text = " ***\n ***\n ***";
1001 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1002
1003 let text = "Foo\n***\nbar";
1004 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1005
1006 let text = "</div>\n*foo*";
1007 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1008
1009 let text = "<div>\n*foo*\n\n*bar*";
1010 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1011
1012 let text = "aaa\nbbb\n\nccc\nddd";
1013 assert_eq!(
1014 parse_markdown(text).as_deref(),
1015 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1016 );
1017
1018 let text = " aaa\n bbb";
1019 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1020
1021 let text = "aaa\n bbb\n ccc";
1022 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1023
1024 let text = "aaa \nbbb ";
1025 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1026 }
1027}