// SPDX-License-Identifier: AGPL-3.0-or-later use fluxer_markdown_parser::{EmojiContext, MarkdownParser, ParserFlags}; use proptest::prelude::*; use serde_json::json; fn parse(input: &str, flags: u32, emoji_context: &str) -> serde_json::Value { let mut parser = MarkdownParser::new(flags, EmojiContext::parse(emoji_context)); let nodes = parser.parse(input).expect("parse succeeds"); serde_json::to_value(json!({"nodes": nodes})).expect("serialize succeeds") } #[test] fn native_parser_fixtures_cover_typescript_suite_surface() { let inline_flags = ParserFlags::ALLOW_SPOILERS | ParserFlags::ALLOW_MASKED_LINKS | ParserFlags::ALLOW_AUTOLINKS | ParserFlags::ALLOW_USER_MENTIONS | ParserFlags::ALLOW_CHANNEL_MENTIONS | ParserFlags::ALLOW_EVERYONE_MENTIONS; assert_eq!(parse("", 0, ""), json!({"nodes": []})); assert_eq!( parse( "# Heading\n||secret||", ParserFlags::ALLOW_HEADINGS | ParserFlags::ALLOW_SPOILERS, "" ), json!({"nodes":[ {"type":"Heading","level":1,"children":[{"type":"Text","content":"Heading"}]}, {"type":"Spoiler","children":[{"type":"Text","content":"secret"}],"isBlock":false} ]}) ); assert_eq!( parse("**bold** *em* __under__ ~~strike~~ `code`", 0, ""), json!({"nodes":[ {"type":"Strong","children":[{"type":"Text","content":"bold"}]}, {"type":"Text","content":" "}, {"type":"Emphasis","children":[{"type":"Text","content":"em"}]}, {"type":"Text","content":" "}, {"type":"Underline","children":[{"type":"Text","content":"under"}]}, {"type":"Text","content":" "}, {"type":"Strikethrough","children":[{"type":"Text","content":"strike"}]}, {"type":"Text","content":" "}, {"type":"InlineCode","content":"code"} ]}) ); assert_eq!( parse( "Nevermind ! It is on C:\\, i thought i moved it", ParserFlags::ALL, "" ), json!({"nodes":[ {"type":"Text","content":"Nevermind ! It is on C:\\, i thought i moved it"} ]}) ); assert_eq!( parse( "C:\\. C:\\! C:\\? C:\\; C:\\/ C:\\' C:\\\" C:\\{ C:\\}", ParserFlags::ALL, "" ), json!({"nodes":[ {"type":"Text","content":"C:\\. C:\\! C:\\? C:\\; C:\\/ C:\\' C:\\\" C:\\{ C:\\}"} ]}) ); assert_eq!( parse("1\\. not a list", ParserFlags::ALLOW_LISTS, ""), json!({"nodes":[ {"type":"Text","content":"1. not a list"} ]}) ); assert_eq!( parse("@everyone <@123> <#456> ", inline_flags, ""), json!({"nodes":[ {"type":"Mention","kind":{"kind":"Everyone"}}, {"type":"Text","content":" "}, {"type":"Mention","kind":{"kind":"User","id":"123"}}, {"type":"Text","content":" "}, {"type":"Mention","kind":{"kind":"Channel","id":"456"}}, {"type":"Text","content":" "}, {"type":"Timestamp","timestamp":1234567890,"style":"RelativeTime"} ]}) ); assert_eq!( parse( "hello πŸ˜„ :smile:", 0, "S\t6\t4\tπŸ˜„\tsmile\t1f604\nC\tsmile\tπŸ˜„\t1f604\n" ), json!({"nodes":[ {"type":"Text","content":"hello "}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ˜„","codepoints":"1f604","name":"smile"}}, {"type":"Text","content":" "}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ˜„","codepoints":"1f604","name":"smile"}} ]}) ); assert_eq!( parse("Sweden πŸ‡ΈπŸ‡ͺ", 0, ""), json!({"nodes":[ {"type":"Text","content":"Sweden "}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡ΈπŸ‡ͺ","codepoints":"1f1f8-1f1ea","name":"flag_se"}} ]}) ); assert_eq!( parse("# \u{200e} \n# \u{200e}", ParserFlags::ALLOW_HEADINGS, ""), json!({"nodes":[ {"type":"Heading","level":1,"children":[{"type":"Text","content":"\u{200e} "}]}, {"type":"Heading","level":1,"children":[{"type":"Text","content":"\u{200e}"}]} ]}) ); assert_eq!( parse("> Sweden πŸ‡ΈπŸ‡ͺ", ParserFlags::ALLOW_BLOCKQUOTES, ""), json!({"nodes":[{ "type":"Blockquote", "children":[ {"type":"Text","content":"Sweden "}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡ΈπŸ‡ͺ","codepoints":"1f1f8-1f1ea","name":"flag_se"}} ] }]}) ); assert_eq!( parse("> ", ParserFlags::ALLOW_BLOCKQUOTES, ""), json!({"nodes":[{ "type":"Blockquote", "children":[], "blankLines":1 }]}) ); assert_eq!( parse("> \n> \nsome text", ParserFlags::ALLOW_BLOCKQUOTES, ""), json!({"nodes":[ { "type":"Blockquote", "children":[], "blankLines":2 }, {"type":"Text","content":"some text"} ]}) ); assert_eq!( parse("> \n>", ParserFlags::ALLOW_BLOCKQUOTES, ""), json!({"nodes":[ { "type":"Blockquote", "children":[], "blankLines":1 }, {"type":"Text","content":">"} ]}) ); } #[test] fn native_parser_pairs_regional_indicators_with_emoji_context() { assert_eq!( parse( "πŸ‡¦πŸ‡§πŸ‡ΈπŸ‡ͺ", 0, "S\t0\t4\tπŸ‡¦\tregional_indicator_a\t1f1e6\nS\t4\t4\tπŸ‡§\tregional_indicator_b\t1f1e7\nS\t8\t8\tπŸ‡ΈπŸ‡ͺ\tflag_se\t1f1f8-1f1ea\n" ), json!({"nodes":[ {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¦","codepoints":"1f1e6","name":"regional_indicator_a"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡§","codepoints":"1f1e7","name":"regional_indicator_b"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡ΈπŸ‡ͺ","codepoints":"1f1f8-1f1ea","name":"flag_se"}} ]}) ); assert_eq!( parse( "πŸ‡¦πŸ‡§πŸ‡¦πŸ‡Έ", 0, "S\t0\t4\tπŸ‡¦\tregional_indicator_a\t1f1e6\nS\t4\t4\tπŸ‡§\tregional_indicator_b\t1f1e7\nS\t8\t8\tπŸ‡¦πŸ‡Έ\tflag_as\t1f1e6-1f1f8\n" ), json!({"nodes":[ {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¦","codepoints":"1f1e6","name":"regional_indicator_a"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡§","codepoints":"1f1e7","name":"regional_indicator_b"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¦πŸ‡Έ","codepoints":"1f1e6-1f1f8","name":"flag_as"}} ]}) ); assert_eq!( parse("Sark πŸ‡¨πŸ‡Ά", 0, "S\t5\t8\tπŸ‡¨πŸ‡Ά\tflag_sark\t1f1e8-1f1f6\n"), json!({"nodes":[ {"type":"Text","content":"Sark "}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¨πŸ‡Ά","codepoints":"1f1e8-1f1f6","name":"flag_sark"}} ]}) ); assert_eq!( parse("x πŸ‡¦", 0, "S\t2\t4\tπŸ‡¦\tregional_indicator_a\t1f1e6\n"), json!({"nodes":[ {"type":"Text","content":"x "}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¦","codepoints":"1f1e6","name":"regional_indicator_a"}} ]}) ); assert_eq!( parse( "πŸ‡¦πŸ‡§πŸ‡ΈπŸ‡ͺ", 0, "S\t0\t4\tπŸ‡¦\tregional_indicator_a\t1f1e6\nS\t4\t8\tπŸ‡§πŸ‡Έ\tflag_bs\t1f1e7-1f1f8\nS\t12\t4\tπŸ‡ͺ\tregional_indicator_e\t1f1ea\n" ), json!({"nodes":[ {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¦","codepoints":"1f1e6","name":"regional_indicator_a"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡§","codepoints":"1f1e7","name":"regional_indicator_b"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡ΈπŸ‡ͺ","codepoints":"1f1f8-1f1ea","name":"flag_se"}} ]}) ); assert_eq!( parse("πŸ‡¦πŸ‡§πŸ‡ΈπŸ‡ͺ", 0, ""), json!({"nodes":[ {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡¦πŸ‡§","codepoints":"1f1e6-1f1e7","name":"flag_ab"}}, {"type":"Emoji","kind":{"kind":"Standard","raw":"πŸ‡ΈπŸ‡ͺ","codepoints":"1f1f8-1f1ea","name":"flag_se"}} ]}) ); } #[test] fn native_parser_allows_apostrophe_in_masked_link_destination() { let url = "https://docs.example.test/help/faq/#why-can't-this-link-parse%3F"; let source = format!("[Example resource]({url})"); let input = r#"A masked link should parse when a URL contains an apostrophe `'` in the fragment: [Example resource](https://docs.example.test/help/faq/#why-can't-this-link-parse%3F)"#; assert_eq!( parse(input, ParserFlags::ALLOW_MASKED_LINKS, ""), json!({"nodes":[ {"type":"Text","content":"A masked link should parse when a URL contains an apostrophe "}, {"type":"InlineCode","content":"'"}, {"type":"Text","content":" in the fragment: "}, { "type":"Link", "text":{"type":"Text","content":"Example resource"}, "url":url, "escaped":false, "rawUrl":url, "source":source } ]}) ); } #[test] fn native_parser_unescapes_numeric_dots_after_masked_link() { let input = "[`34d2f5e`](https://-/a/commit/-) 1\\.2\\.3\\.4\\.5\\.6\\.7\\.8\\.9\\.10\n \\- Amy"; assert_eq!( parse(input, ParserFlags::ALLOW_MASKED_LINKS, ""), json!({"nodes":[ { "type":"Link", "text":{"type":"InlineCode","content":"34d2f5e"}, "url":"https://-/a/commit/-", "escaped":false, "rawUrl":"https://-/a/commit/-", "source":"[`34d2f5e`](https://-/a/commit/-)" }, {"type":"Text","content":" 1.2.3.4.5.6.7.8.9.10\n - Amy"} ]}) ); } #[test] fn native_parser_unescapes_word_and_sentence_dots() { assert_eq!( parse("Four more years\\. religion\\.\\.\\.", ParserFlags::ALL, ""), json!({"nodes":[ {"type":"Text","content":"Four more years. religion..."} ]}) ); assert_eq!( parse("C:\\.", ParserFlags::ALL, ""), json!({"nodes":[ {"type":"Text","content":"C:\\."} ]}) ); } #[test] fn native_parser_allows_app_protocol_links() { let flags = ParserFlags::ALLOW_AUTOLINKS | ParserFlags::ALLOW_MASKED_LINKS; assert_eq!( parse( "fluxer://invite/abc fluxer:/channels/123/456 [Open gift](fluxer:gift/xyz)", flags, "" ), json!({"nodes":[ { "type":"Link", "url":"fluxer://invite/abc", "escaped":false, "rawUrl":"fluxer://invite/abc", "source":"fluxer://invite/abc" }, {"type":"Text","content":" "}, { "type":"Link", "url":"fluxer:/channels/123/456", "escaped":false, "rawUrl":"fluxer:/channels/123/456", "source":"fluxer:/channels/123/456" }, {"type":"Text","content":" "}, { "type":"Link", "text":{"type":"Text","content":"Open gift"}, "url":"fluxer:gift/xyz", "escaped":false, "rawUrl":"fluxer:gift/xyz", "source":"[Open gift](fluxer:gift/xyz)" } ]}) ); } #[test] fn native_parser_rejects_apostrophe_in_masked_link_authority() { let input = "[Bad](https://ex'ample.com/path)"; assert_eq!( parse(input, ParserFlags::ALLOW_MASKED_LINKS, ""), json!({"nodes":[ {"type":"Text","content":input} ]}) ); } #[test] fn native_parser_keeps_content_after_unterminated_escaped_destination() { let flags = ParserFlags::ALLOW_MASKED_LINKS | ParserFlags::ALLOW_AUTOLINKS; assert_eq!( parse( "a [x]( b [e]()", flags, "" ), json!({"nodes":[ {"type":"Text","content":"a [x]("}, {"type":"Link","url":"https://example.com/y)","escaped":true,"rawUrl":"https://example.com/y)","source":""}, {"type":"Text","content":" b "}, {"type":"Link","text":{"type":"Text","content":"e"},"url":"https://example.com/f","escaped":true,"rawUrl":"https://example.com/f","source":"[e]()"} ]}) ); assert_eq!( parse("[bad]( )", flags, ""), json!({"nodes":[ {"type":"Text","content":"[bad]("}, {"type":"Link","url":"https://example.com/a","escaped":true,"rawUrl":"https://example.com/a","source":""}, {"type":"Text","content":" )"} ]}) ); } #[test] fn native_parser_still_accepts_well_formed_escaped_destinations() { let flags = ParserFlags::ALLOW_MASKED_LINKS | ParserFlags::ALLOW_AUTOLINKS; assert_eq!( parse("[ok]()", flags, ""), json!({"nodes":[ {"type":"Link","text":{"type":"Text","content":"ok"},"url":"https://example.com/a","escaped":true,"rawUrl":"https://example.com/a","source":"[ok]()"} ]}) ); } #[test] fn native_parser_rejects_masked_links_without_visible_text() { for input in [ "[](https://duckduckgo.com)", "[ ](https://duckduckgo.com)", "[\u{200e} ]()", ] { assert_eq!( parse(input, ParserFlags::ALLOW_MASKED_LINKS, ""), json!({"nodes":[ {"type":"Text","content":input} ]}) ); } } #[test] fn native_parser_requires_visible_subtext_content() { assert_eq!( parse("-# hello", ParserFlags::ALLOW_SUBTEXT, ""), json!({"nodes":[ {"type":"Subtext","children":[{"type":"Text","content":"hello"}]} ]}) ); for input in ["-#", "-# ", "-# \t"] { assert_eq!( parse(input, ParserFlags::ALLOW_SUBTEXT, ""), json!({"nodes":[ {"type":"Text","content":input} ]}) ); } assert_eq!( parse( "> -#", ParserFlags::ALLOW_BLOCKQUOTES | ParserFlags::ALLOW_SUBTEXT, "" ), json!({"nodes":[{ "type":"Blockquote", "children":[{"type":"Text","content":"-#"}] }]}) ); } #[test] fn native_parser_rejects_formatting_without_visible_content() { for input in [ "` `", "`` ``", "** **", "__ __", "~~ ~~", "|| ||", "`\u{200e}`", "~~\u{200e} ~~", "# ", "||\n||", "|| \n||", ] { assert_eq!( parse(input, ParserFlags::ALL, ""), json!({"nodes":[ {"type":"Text","content":input} ]}) ); } } #[test] fn native_parser_rejects_inline_code_across_lines() { assert_eq!( parse("`\nHello world\n`", ParserFlags::ALL, ""), json!({"nodes":[ {"type":"Text","content":"`\nHello world\n`"} ]}) ); assert_eq!( parse( "It seems the new fixes introduced a new bug <:trolley:1435375026174558456> :\n\n`\n.\n`", ParserFlags::ALL, "" ), json!({"nodes":[ {"type":"Text","content":"It seems the new fixes introduced a new bug "}, {"type":"Emoji","kind":{"kind":"Custom","name":"trolley","id":"1435375026174558456","animated":false}}, {"type":"Text","content":" :\n\n`\n.\n`"} ]}) ); assert_eq!( parse("one `line` only", ParserFlags::ALL, ""), json!({"nodes":[ {"type":"Text","content":"one "}, {"type":"InlineCode","content":"line"}, {"type":"Text","content":" only"} ]}) ); } proptest::proptest! { #[test] fn arbitrary_text_does_not_panic(input in ".{0,4096}") { let mut parser = MarkdownParser::new(ParserFlags::ALL, EmojiContext::default()); let nodes = parser.parse(&input).expect("parse succeeds"); let serialized = serde_json::to_string(&nodes).expect("serialize succeeds"); prop_assert!(serialized.starts_with('[')); } } #[test] fn native_parser_starts_a_table_directly_after_a_text_line() { let input = "intro text\r\n| Framework | Type |\r\n| --------- | ---- |\r\n| React | UI library |"; let parsed = parse(input, ParserFlags::ALL, ""); let nodes = parsed["nodes"].as_array().expect("nodes array"); assert!( nodes.iter().any(|node| node["type"] == "Table"), "expected a Table node, got {parsed}" ); } #[test] fn native_parser_still_requires_a_delimiter_row_for_a_table() { let input = "intro text\n| not | a | table |\njust more text"; let parsed = parse(input, ParserFlags::ALL, ""); let nodes = parsed["nodes"].as_array().expect("nodes array"); assert!( !nodes.iter().any(|node| node["type"] == "Table"), "pipes alone must not form a table, got {parsed}" ); }