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")]
102 pub mentions: Option<Mentions>,
103}
104
105impl RoomMessageEventContent {
106 pub fn new(msgtype: MessageType) -> Self {
108 Self { msgtype, relates_to: None, mentions: None }
109 }
110
111 pub fn text_plain(body: impl Into<String>) -> Self {
113 Self::new(MessageType::text_plain(body))
114 }
115
116 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
118 Self::new(MessageType::text_html(body, html_body))
119 }
120
121 #[cfg(feature = "markdown")]
123 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
124 Self::new(MessageType::text_markdown(body))
125 }
126
127 pub fn notice_plain(body: impl Into<String>) -> Self {
129 Self::new(MessageType::notice_plain(body))
130 }
131
132 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
134 Self::new(MessageType::notice_html(body, html_body))
135 }
136
137 #[cfg(feature = "markdown")]
139 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
140 Self::new(MessageType::notice_markdown(body))
141 }
142
143 pub fn emote_plain(body: impl Into<String>) -> Self {
145 Self::new(MessageType::emote_plain(body))
146 }
147
148 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
150 Self::new(MessageType::emote_html(body, html_body))
151 }
152
153 #[cfg(feature = "markdown")]
155 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
156 Self::new(MessageType::emote_markdown(body))
157 }
158
159 #[track_caller]
168 pub fn make_reply_to<'a>(
169 self,
170 metadata: impl Into<ReplyMetadata<'a>>,
171 forward_thread: ForwardThread,
172 add_mentions: AddMentions,
173 ) -> Self {
174 self.without_relation().make_reply_to(metadata, forward_thread, add_mentions)
175 }
176
177 pub fn make_for_thread<'a>(
192 self,
193 metadata: impl Into<ReplyMetadata<'a>>,
194 is_reply: ReplyWithinThread,
195 add_mentions: AddMentions,
196 ) -> Self {
197 self.without_relation().make_for_thread(metadata, is_reply, add_mentions)
198 }
199
200 #[track_caller]
219 pub fn make_replacement(self, metadata: impl Into<ReplacementMetadata>) -> Self {
220 self.without_relation().make_replacement(metadata)
221 }
222
223 pub fn add_mentions(mut self, mentions: Mentions) -> Self {
234 self.mentions.get_or_insert_with(Mentions::new).add(mentions);
235 self
236 }
237
238 pub fn msgtype(&self) -> &str {
243 self.msgtype.msgtype()
244 }
245
246 pub fn body(&self) -> &str {
248 self.msgtype.body()
249 }
250
251 pub fn apply_replacement(&mut self, new_content: RoomMessageEventContentWithoutRelation) {
253 let RoomMessageEventContentWithoutRelation { msgtype, mentions } = new_content;
254 self.msgtype = msgtype;
255 self.mentions = mentions;
256 }
257
258 #[cfg(feature = "html")]
271 pub fn sanitize(
272 &mut self,
273 mode: HtmlSanitizerMode,
274 remove_reply_fallback: RemoveReplyFallback,
275 ) {
276 let remove_reply_fallback = if matches!(self.relates_to, Some(Relation::Reply { .. })) {
277 remove_reply_fallback
278 } else {
279 RemoveReplyFallback::No
280 };
281
282 self.msgtype.sanitize(mode, remove_reply_fallback);
283 }
284
285 fn without_relation(self) -> RoomMessageEventContentWithoutRelation {
286 if self.relates_to.is_some() {
287 warn!("Overwriting existing relates_to value");
288 }
289
290 self.into()
291 }
292
293 fn thread(&self) -> Option<&Thread> {
295 self.relates_to.as_ref().and_then(|relates_to| as_variant!(relates_to, Relation::Thread))
296 }
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301#[allow(clippy::exhaustive_enums)]
302pub enum ForwardThread {
303 Yes,
310
311 No,
315}
316
317#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319#[allow(clippy::exhaustive_enums)]
320pub enum AddMentions {
321 Yes,
327
328 No,
332}
333
334#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336#[allow(clippy::exhaustive_enums)]
337pub enum ReplyWithinThread {
338 Yes,
344
345 No,
351}
352
353#[derive(Clone, Debug, Serialize)]
355#[serde(tag = "msgtype")]
356#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
357pub enum MessageType {
358 #[serde(rename = "m.audio")]
360 Audio(AudioMessageEventContent),
361
362 #[serde(rename = "m.emote")]
364 Emote(EmoteMessageEventContent),
365
366 #[serde(rename = "m.file")]
368 File(FileMessageEventContent),
369
370 #[cfg(feature = "unstable-msc4274")]
372 #[serde(rename = "dm.filament.gallery")]
373 Gallery(GalleryMessageEventContent),
374
375 #[serde(rename = "m.image")]
377 Image(ImageMessageEventContent),
378
379 #[serde(rename = "m.location")]
381 Location(LocationMessageEventContent),
382
383 #[serde(rename = "m.notice")]
385 Notice(NoticeMessageEventContent),
386
387 #[serde(rename = "m.server_notice")]
389 ServerNotice(ServerNoticeMessageEventContent),
390
391 #[serde(rename = "m.text")]
393 Text(TextMessageEventContent),
394
395 #[serde(rename = "m.video")]
397 Video(VideoMessageEventContent),
398
399 #[serde(rename = "m.key.verification.request")]
401 VerificationRequest(KeyVerificationRequestEventContent),
402
403 #[doc(hidden)]
405 #[serde(untagged)]
406 _Custom(CustomEventContent),
407}
408
409impl MessageType {
410 pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
425 fn deserialize_variant<T: DeserializeOwned>(
426 body: String,
427 mut obj: JsonObject,
428 ) -> serde_json::Result<T> {
429 obj.insert("body".into(), body.into());
430 serde_json::from_value(JsonValue::Object(obj))
431 }
432
433 Ok(match msgtype {
434 "m.audio" => Self::Audio(deserialize_variant(body, data)?),
435 "m.emote" => Self::Emote(deserialize_variant(body, data)?),
436 "m.file" => Self::File(deserialize_variant(body, data)?),
437 #[cfg(feature = "unstable-msc4274")]
438 "dm.filament.gallery" => Self::Gallery(deserialize_variant(body, data)?),
439 "m.image" => Self::Image(deserialize_variant(body, data)?),
440 "m.location" => Self::Location(deserialize_variant(body, data)?),
441 "m.notice" => Self::Notice(deserialize_variant(body, data)?),
442 "m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
443 "m.text" => Self::Text(deserialize_variant(body, data)?),
444 "m.video" => Self::Video(deserialize_variant(body, data)?),
445 "m.key.verification.request" => {
446 Self::VerificationRequest(deserialize_variant(body, data)?)
447 }
448 _ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
449 })
450 }
451
452 pub fn text_plain(body: impl Into<String>) -> Self {
454 Self::Text(TextMessageEventContent::plain(body))
455 }
456
457 pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
459 Self::Text(TextMessageEventContent::html(body, html_body))
460 }
461
462 #[cfg(feature = "markdown")]
464 pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
465 Self::Text(TextMessageEventContent::markdown(body))
466 }
467
468 pub fn notice_plain(body: impl Into<String>) -> Self {
470 Self::Notice(NoticeMessageEventContent::plain(body))
471 }
472
473 pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
475 Self::Notice(NoticeMessageEventContent::html(body, html_body))
476 }
477
478 #[cfg(feature = "markdown")]
480 pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
481 Self::Notice(NoticeMessageEventContent::markdown(body))
482 }
483
484 pub fn emote_plain(body: impl Into<String>) -> Self {
486 Self::Emote(EmoteMessageEventContent::plain(body))
487 }
488
489 pub fn emote_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
491 Self::Emote(EmoteMessageEventContent::html(body, html_body))
492 }
493
494 #[cfg(feature = "markdown")]
496 pub fn emote_markdown(body: impl AsRef<str> + Into<String>) -> Self {
497 Self::Emote(EmoteMessageEventContent::markdown(body))
498 }
499
500 pub fn msgtype(&self) -> &str {
502 match self {
503 Self::Audio(_) => "m.audio",
504 Self::Emote(_) => "m.emote",
505 Self::File(_) => "m.file",
506 #[cfg(feature = "unstable-msc4274")]
507 Self::Gallery(_) => "dm.filament.gallery",
508 Self::Image(_) => "m.image",
509 Self::Location(_) => "m.location",
510 Self::Notice(_) => "m.notice",
511 Self::ServerNotice(_) => "m.server_notice",
512 Self::Text(_) => "m.text",
513 Self::Video(_) => "m.video",
514 Self::VerificationRequest(_) => "m.key.verification.request",
515 Self::_Custom(c) => &c.msgtype,
516 }
517 }
518
519 pub fn body(&self) -> &str {
521 match self {
522 MessageType::Audio(m) => &m.body,
523 MessageType::Emote(m) => &m.body,
524 MessageType::File(m) => &m.body,
525 #[cfg(feature = "unstable-msc4274")]
526 MessageType::Gallery(m) => &m.body,
527 MessageType::Image(m) => &m.body,
528 MessageType::Location(m) => &m.body,
529 MessageType::Notice(m) => &m.body,
530 MessageType::ServerNotice(m) => &m.body,
531 MessageType::Text(m) => &m.body,
532 MessageType::Video(m) => &m.body,
533 MessageType::VerificationRequest(m) => &m.body,
534 MessageType::_Custom(m) => &m.body,
535 }
536 }
537
538 pub fn data(&self) -> Cow<'_, JsonObject> {
546 fn serialize<T: Serialize>(obj: &T) -> JsonObject {
547 match serde_json::to_value(obj).expect("message type serialization to succeed") {
548 JsonValue::Object(mut obj) => {
549 obj.remove("body");
550 obj
551 }
552 _ => panic!("all message types must serialize to objects"),
553 }
554 }
555
556 match self {
557 Self::Audio(d) => Cow::Owned(serialize(d)),
558 Self::Emote(d) => Cow::Owned(serialize(d)),
559 Self::File(d) => Cow::Owned(serialize(d)),
560 #[cfg(feature = "unstable-msc4274")]
561 Self::Gallery(d) => Cow::Owned(serialize(d)),
562 Self::Image(d) => Cow::Owned(serialize(d)),
563 Self::Location(d) => Cow::Owned(serialize(d)),
564 Self::Notice(d) => Cow::Owned(serialize(d)),
565 Self::ServerNotice(d) => Cow::Owned(serialize(d)),
566 Self::Text(d) => Cow::Owned(serialize(d)),
567 Self::Video(d) => Cow::Owned(serialize(d)),
568 Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
569 Self::_Custom(c) => Cow::Borrowed(&c.data),
570 }
571 }
572
573 #[cfg(feature = "html")]
587 pub fn sanitize(
588 &mut self,
589 mode: HtmlSanitizerMode,
590 remove_reply_fallback: RemoveReplyFallback,
591 ) {
592 if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
593 | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
594 | MessageType::Text(TextMessageEventContent { body, formatted, .. }) = self
595 {
596 if let Some(formatted) = formatted {
597 formatted.sanitize_html(mode, remove_reply_fallback);
598 }
599 if remove_reply_fallback == RemoveReplyFallback::Yes {
600 *body = remove_plain_reply_fallback(body).to_owned();
601 }
602 }
603 }
604
605 fn make_replacement_body(&mut self) {
606 let empty_formatted_body = || FormattedBody::html(String::new());
607
608 let (body, formatted) = {
609 match self {
610 MessageType::Emote(m) => {
611 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
612 }
613 MessageType::Notice(m) => {
614 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
615 }
616 MessageType::Text(m) => {
617 (&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
618 }
619 MessageType::Audio(m) => (&mut m.body, None),
620 MessageType::File(m) => (&mut m.body, None),
621 #[cfg(feature = "unstable-msc4274")]
622 MessageType::Gallery(m) => (&mut m.body, None),
623 MessageType::Image(m) => (&mut m.body, None),
624 MessageType::Location(m) => (&mut m.body, None),
625 MessageType::ServerNotice(m) => (&mut m.body, None),
626 MessageType::Video(m) => (&mut m.body, None),
627 MessageType::VerificationRequest(m) => (&mut m.body, None),
628 MessageType::_Custom(m) => (&mut m.body, None),
629 }
630 };
631
632 *body = format!("* {body}");
634
635 if let Some(f) = formatted {
636 assert_eq!(
637 f.format,
638 MessageFormat::Html,
639 "make_replacement can't handle non-HTML formatted messages"
640 );
641
642 f.body = format!("* {}", f.body);
643 }
644 }
645}
646
647impl From<MessageType> for RoomMessageEventContent {
648 fn from(msgtype: MessageType) -> Self {
649 Self::new(msgtype)
650 }
651}
652
653impl From<RoomMessageEventContent> for MessageType {
654 fn from(content: RoomMessageEventContent) -> Self {
655 content.msgtype
656 }
657}
658
659#[derive(Debug)]
663pub struct ReplacementMetadata {
664 event_id: OwnedEventId,
665 mentions: Option<Mentions>,
666}
667
668impl ReplacementMetadata {
669 pub fn new(event_id: OwnedEventId, mentions: Option<Mentions>) -> Self {
671 Self { event_id, mentions }
672 }
673}
674
675impl From<&OriginalRoomMessageEvent> for ReplacementMetadata {
676 fn from(value: &OriginalRoomMessageEvent) -> Self {
677 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
678 }
679}
680
681impl From<&OriginalSyncRoomMessageEvent> for ReplacementMetadata {
682 fn from(value: &OriginalSyncRoomMessageEvent) -> Self {
683 ReplacementMetadata::new(value.event_id.to_owned(), value.content.mentions.clone())
684 }
685}
686
687#[derive(Clone, Copy, Debug)]
692pub struct ReplyMetadata<'a> {
693 event_id: &'a EventId,
695 sender: &'a UserId,
697 thread: Option<&'a Thread>,
699}
700
701impl<'a> ReplyMetadata<'a> {
702 pub fn new(event_id: &'a EventId, sender: &'a UserId, thread: Option<&'a Thread>) -> Self {
704 Self { event_id, sender, thread }
705 }
706}
707
708impl<'a> From<&'a OriginalRoomMessageEvent> for ReplyMetadata<'a> {
709 fn from(value: &'a OriginalRoomMessageEvent) -> Self {
710 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
711 }
712}
713
714impl<'a> From<&'a OriginalSyncRoomMessageEvent> for ReplyMetadata<'a> {
715 fn from(value: &'a OriginalSyncRoomMessageEvent) -> Self {
716 ReplyMetadata::new(&value.event_id, &value.sender, value.content.thread())
717 }
718}
719
720#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
722#[derive(Clone, StringEnum)]
723#[non_exhaustive]
724pub enum MessageFormat {
725 #[ruma_enum(rename = "org.matrix.custom.html")]
727 Html,
728
729 #[doc(hidden)]
730 _Custom(PrivOwnedStr),
731}
732
733#[derive(Clone, Debug, Deserialize, Serialize)]
736#[allow(clippy::exhaustive_structs)]
737pub struct FormattedBody {
738 pub format: MessageFormat,
740
741 #[serde(rename = "formatted_body")]
743 pub body: String,
744}
745
746impl FormattedBody {
747 pub fn html(body: impl Into<String>) -> Self {
749 Self { format: MessageFormat::Html, body: body.into() }
750 }
751
752 #[cfg(feature = "markdown")]
756 pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
757 parse_markdown(body.as_ref()).map(Self::html)
758 }
759
760 #[cfg(feature = "html")]
771 pub fn sanitize_html(
772 &mut self,
773 mode: HtmlSanitizerMode,
774 remove_reply_fallback: RemoveReplyFallback,
775 ) {
776 if self.format == MessageFormat::Html {
777 self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
778 }
779 }
780}
781
782#[doc(hidden)]
784#[derive(Clone, Debug, Deserialize, Serialize)]
785pub struct CustomEventContent {
786 msgtype: String,
788
789 body: String,
791
792 #[serde(flatten)]
794 data: JsonObject,
795}
796
797#[cfg(feature = "markdown")]
798pub(crate) fn parse_markdown(text: &str) -> Option<String> {
799 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
800
801 const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH);
802
803 let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS)
804 .map(|event| match event {
805 Event::SoftBreak => Event::HardBreak,
806 _ => event,
807 })
808 .collect();
809
810 let first_event_is_paragraph_start =
813 parser_events.first().is_some_and(|event| matches!(event, Event::Start(Tag::Paragraph)));
814 let last_event_is_paragraph_end =
815 parser_events.last().is_some_and(|event| matches!(event, Event::End(TagEnd::Paragraph)));
816 let mut is_inline = first_event_is_paragraph_start && last_event_is_paragraph_end;
817 let mut has_markdown = !is_inline;
818
819 if !has_markdown {
820 let mut pos = 0;
823
824 for event in parser_events.iter().skip(1) {
825 match event {
826 Event::Text(s) => {
827 if text[pos..].starts_with(s.as_ref()) {
832 pos += s.len();
833 continue;
834 }
835 }
836 Event::HardBreak => {
837 if text[pos..].starts_with("\r\n") {
841 pos += 2;
842 continue;
843 } else if text[pos..].starts_with(['\r', '\n']) {
844 pos += 1;
845 continue;
846 }
847 }
848 Event::End(TagEnd::Paragraph) => continue,
851 Event::Start(tag) => {
853 is_inline &= !is_block_tag(tag);
854 }
855 _ => {}
856 }
857
858 has_markdown = true;
859
860 if !is_inline {
862 break;
863 }
864 }
865
866 has_markdown |= pos != text.len();
868 }
869
870 if !has_markdown {
872 return None;
873 }
874
875 let mut events_iter = parser_events.into_iter();
876
877 if is_inline {
879 events_iter.next();
880 events_iter.next_back();
881 }
882
883 let mut html_body = String::new();
884 pulldown_cmark::html::push_html(&mut html_body, events_iter);
885
886 Some(html_body)
887}
888
889#[cfg(feature = "markdown")]
891fn is_block_tag(tag: &pulldown_cmark::Tag<'_>) -> bool {
892 use pulldown_cmark::Tag;
893
894 matches!(
895 tag,
896 Tag::Paragraph
897 | Tag::Heading { .. }
898 | Tag::BlockQuote(_)
899 | Tag::CodeBlock(_)
900 | Tag::HtmlBlock
901 | Tag::List(_)
902 | Tag::FootnoteDefinition(_)
903 | Tag::Table(_)
904 )
905}
906
907#[cfg(all(test, feature = "markdown"))]
908mod tests {
909 use super::parse_markdown;
910
911 #[test]
912 fn detect_markdown() {
913 let text = "Hello world.";
915 assert_eq!(parse_markdown(text), None);
916
917 let text = "Hello\nworld.";
919 assert_eq!(parse_markdown(text), None);
920
921 let text = "Hello\n\nworld.";
923 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Hello</p>\n<p>world.</p>\n"));
924
925 let text = "## Hello\n\nworld.";
927 assert_eq!(parse_markdown(text).as_deref(), Some("<h2>Hello</h2>\n<p>world.</p>\n"));
928
929 let text = "Hello\n\n```\nworld.\n```";
931 assert_eq!(
932 parse_markdown(text).as_deref(),
933 Some("<p>Hello</p>\n<pre><code>world.\n</code></pre>\n")
934 );
935
936 let text = "Hello **world**.";
938 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <strong>world</strong>."));
939
940 let text = r#"Hello \<world\>."#;
942 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
943
944 let text = r#"\> Hello world."#;
946 assert_eq!(parse_markdown(text).as_deref(), Some("> Hello world."));
947
948 let text = r#"Hello <world>."#;
950 assert_eq!(parse_markdown(text).as_deref(), Some("Hello <world>."));
951
952 let text = "Hello w⊕rld.";
954 assert_eq!(parse_markdown(text).as_deref(), Some("Hello w⊕rld."));
955 }
956
957 #[test]
958 fn detect_commonmark() {
959 let text = r#"\!\"\#\$\%\&\'\(\)\*\+\,\-\.\/\:\;\<\=\>\?\@\[\\\]\^\_\`\{\|\}\~"#;
962 assert_eq!(
963 parse_markdown(text).as_deref(),
964 Some(r##"!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"##)
965 );
966
967 let text = r#"\→\A\a\ \3\φ\«"#;
968 assert_eq!(parse_markdown(text).as_deref(), None);
969
970 let text = r#"\*not emphasized*"#;
971 assert_eq!(parse_markdown(text).as_deref(), Some("*not emphasized*"));
972
973 let text = r#"\<br/> not a tag"#;
974 assert_eq!(parse_markdown(text).as_deref(), Some("<br/> not a tag"));
975
976 let text = r#"\[not a link](/foo)"#;
977 assert_eq!(parse_markdown(text).as_deref(), Some("[not a link](/foo)"));
978
979 let text = r#"\`not code`"#;
980 assert_eq!(parse_markdown(text).as_deref(), Some("`not code`"));
981
982 let text = r#"1\. not a list"#;
983 assert_eq!(parse_markdown(text).as_deref(), Some("1. not a list"));
984
985 let text = r#"\* not a list"#;
986 assert_eq!(parse_markdown(text).as_deref(), Some("* not a list"));
987
988 let text = r#"\# not a heading"#;
989 assert_eq!(parse_markdown(text).as_deref(), Some("# not a heading"));
990
991 let text = r#"\[foo]: /url "not a reference""#;
992 assert_eq!(parse_markdown(text).as_deref(), Some(r#"[foo]: /url "not a reference""#));
993
994 let text = r#"\ö not a character entity"#;
995 assert_eq!(parse_markdown(text).as_deref(), Some("&ouml; not a character entity"));
996
997 let text = r#"\\*emphasis*"#;
998 assert_eq!(parse_markdown(text).as_deref(), Some(r#"\<em>emphasis</em>"#));
999
1000 let text = "foo\\\nbar";
1001 assert_eq!(parse_markdown(text).as_deref(), Some("foo<br />\nbar"));
1002
1003 let text = " ***\n ***\n ***";
1004 assert_eq!(parse_markdown(text).as_deref(), Some("<hr />\n<hr />\n<hr />\n"));
1005
1006 let text = "Foo\n***\nbar";
1007 assert_eq!(parse_markdown(text).as_deref(), Some("<p>Foo</p>\n<hr />\n<p>bar</p>\n"));
1008
1009 let text = "</div>\n*foo*";
1010 assert_eq!(parse_markdown(text).as_deref(), Some("</div>\n*foo*"));
1011
1012 let text = "<div>\n*foo*\n\n*bar*";
1013 assert_eq!(parse_markdown(text).as_deref(), Some("<div>\n*foo*\n<p><em>bar</em></p>\n"));
1014
1015 let text = "aaa\nbbb\n\nccc\nddd";
1016 assert_eq!(
1017 parse_markdown(text).as_deref(),
1018 Some("<p>aaa<br />\nbbb</p>\n<p>ccc<br />\nddd</p>\n")
1019 );
1020
1021 let text = " aaa\n bbb";
1022 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1023
1024 let text = "aaa\n bbb\n ccc";
1025 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb<br />\nccc"));
1026
1027 let text = "aaa \nbbb ";
1028 assert_eq!(parse_markdown(text).as_deref(), Some("aaa<br />\nbbb"));
1029 }
1030}