mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
430 lines
14 KiB
Rust
430 lines
14 KiB
Rust
// 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> <t:1234567890:R>", 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_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](<https://example.com/y)> b [e](<https://example.com/f>)",
|
|
flags,
|
|
""
|
|
),
|
|
json!({"nodes":[
|
|
{"type":"Text","content":"a [x]("},
|
|
{"type":"Link","url":"https://example.com/y)","escaped":true,"rawUrl":"https://example.com/y)","source":"<https://example.com/y)>"},
|
|
{"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](<https://example.com/f>)"}
|
|
]})
|
|
);
|
|
assert_eq!(
|
|
parse("[bad](<https://example.com/a> )", flags, ""),
|
|
json!({"nodes":[
|
|
{"type":"Text","content":"[bad]("},
|
|
{"type":"Link","url":"https://example.com/a","escaped":true,"rawUrl":"https://example.com/a","source":"<https://example.com/a>"},
|
|
{"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](<https://example.com/a>)", flags, ""),
|
|
json!({"nodes":[
|
|
{"type":"Link","text":{"type":"Text","content":"ok"},"url":"https://example.com/a","escaped":true,"rawUrl":"https://example.com/a","source":"[ok](<https://example.com/a>)"}
|
|
]})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_parser_rejects_masked_links_without_visible_text() {
|
|
for input in [
|
|
"[](https://duckduckgo.com)",
|
|
"[ ](https://duckduckgo.com)",
|
|
"[\u{200e} ](<https://duckduckgo.com>)",
|
|
] {
|
|
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}"
|
|
);
|
|
}
|