use comrak::nodes::NodeValue;
use comrak::{parse_document, Arena, ComrakOptions};
use insta::assert_snapshot;
use lex_babel::format::Format;
use lex_babel::formats::markdown::MarkdownFormat;
use lex_core::lex::transforms::standard::STRING_TO_AST;
fn lex_to_comrak_ast<'a>(
lex_src: &str,
arena: &'a Arena<comrak::nodes::AstNode<'a>>,
) -> &'a comrak::nodes::AstNode<'a> {
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
let options = ComrakOptions::default();
parse_document(arena, &md, &options)
}
fn collect_node_types<'a>(
node: &'a comrak::nodes::AstNode<'a>,
types: &mut std::collections::HashSet<String>,
) {
let value = &node.data.borrow().value;
let type_name = match value {
NodeValue::Document => "Document",
NodeValue::Paragraph => "Paragraph",
NodeValue::Heading(_) => "Heading",
NodeValue::List(_) => "List",
NodeValue::Item(_) => "Item",
NodeValue::CodeBlock(_) => "CodeBlock",
NodeValue::Strong => "Strong",
NodeValue::Emph => "Emph",
NodeValue::Code(_) => "Code",
NodeValue::Link(_) => "Link",
_ => "Other",
};
types.insert(type_name.to_string());
for child in node.children() {
collect_node_types(child, types);
}
}
#[test]
fn test_paragraph_simple() {
let lex_src = "This is a simple paragraph.\n";
let arena = Arena::new();
let root = lex_to_comrak_ast(lex_src, &arena);
let mut found_paragraph = false;
for child in root.children() {
if matches!(child.data.borrow().value, NodeValue::Paragraph) {
found_paragraph = true;
}
}
assert!(found_paragraph, "Should have a paragraph node");
}
#[test]
fn test_heading_and_paragraph_separation() {
let lex_src = "1. Title\n\n Body text.\n";
let arena = Arena::new();
let root = lex_to_comrak_ast(lex_src, &arena);
let mut heading_text = String::new();
let mut paragraph_text = String::new();
for child in root.children() {
match &child.data.borrow().value {
NodeValue::Heading(_) => {
for inline in child.children() {
if let NodeValue::Text(t) = &inline.data.borrow().value {
heading_text.push_str(t);
}
}
}
NodeValue::Paragraph => {
for inline in child.children() {
if let NodeValue::Text(t) = &inline.data.borrow().value {
paragraph_text.push_str(t);
}
}
}
_ => {}
}
}
assert_eq!(heading_text.trim(), "1. Title");
assert!(paragraph_text.contains("Body text."));
}
#[test]
fn test_trifecta_010_paragraphs_sessions_flat_single() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/trifecta/010-paragraphs-sessions-flat-single.lex",
)
.expect("trifecta 010 file should exist");
let arena = Arena::new();
let root = lex_to_comrak_ast(&lex_src, &arena);
let mut paragraphs = 0;
let mut headings = 0;
let mut heading_levels = Vec::new();
for child in root.children() {
match &child.data.borrow().value {
NodeValue::Paragraph => paragraphs += 1,
NodeValue::Heading(h) => {
headings += 1;
heading_levels.push(h.level);
}
_ => {}
}
}
assert!(paragraphs > 0, "Should have paragraphs");
assert!(
headings >= 3,
"Should have at least 3 headings (1 title + 2 sessions), found {headings}"
);
assert!(
heading_levels.contains(&1),
"Should have H1 for document title"
);
let session_levels: Vec<_> = heading_levels
.iter()
.filter(|&&l| l != 1)
.copied()
.collect();
for level in &session_levels {
assert_eq!(*level, 2, "Root-level sessions should be h2");
}
println!(
"Trifecta 010: {paragraphs} paragraphs, {headings} headings (levels: {heading_levels:?})"
);
}
#[test]
fn test_trifecta_020_paragraphs_sessions_flat_multiple() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/trifecta/020-paragraphs-sessions-flat-multiple.lex",
)
.expect("trifecta 020 file should exist");
let arena = Arena::new();
let root = lex_to_comrak_ast(&lex_src, &arena);
let heading_count = root
.children()
.filter(|child| matches!(child.data.borrow().value, NodeValue::Heading(_)))
.count();
assert!(
heading_count >= 4,
"Should have at least 4 headings (sessions), found {heading_count}"
);
let mut has_h1 = false;
let mut heading_levels_vec = Vec::new();
for child in root.children() {
if let NodeValue::Heading(h) = &child.data.borrow().value {
heading_levels_vec.push(h.level);
if h.level == 2 {
has_h1 = true;
}
}
}
assert!(has_h1, "Should have at least one h2 (root session)");
println!("Trifecta 020: Heading levels: {heading_levels_vec:?}");
println!("Trifecta 020: {heading_count} headings");
}
#[test]
fn test_trifecta_060_nesting() {
let lex_src = std::fs::read_to_string("../../comms/specs/trifecta/060-trifecta-nesting.lex")
.expect("trifecta 060 file should exist");
let arena = Arena::new();
let root = lex_to_comrak_ast(&lex_src, &arena);
let mut heading_levels = Vec::new();
let mut has_paragraphs = false;
let mut has_lists = false;
for child in root.children() {
match &child.data.borrow().value {
NodeValue::Heading(h) => heading_levels.push(h.level),
NodeValue::Paragraph => has_paragraphs = true,
NodeValue::List(_) => has_lists = true,
_ => {}
}
}
let min_level = heading_levels.iter().min().copied().unwrap_or(0);
let max_level = heading_levels.iter().max().copied().unwrap_or(0);
assert!(
min_level >= 1 && max_level >= 2,
"Should have nested heading levels (h1, h2, h3...), found range {min_level}..{max_level}"
);
assert!(has_paragraphs, "Should have paragraphs");
assert!(has_lists, "Should have lists");
assert!(!heading_levels.is_empty(), "Should have headings");
assert!(
heading_levels.contains(&2),
"Should have h2 for root sessions"
);
assert!(
heading_levels.contains(&3) || heading_levels.contains(&4),
"Should have h3 or h4 for nested sessions"
);
println!(
"Trifecta 060: {} headings with levels {:?}, {} paragraphs, {} lists",
heading_levels.len(),
heading_levels,
if has_paragraphs { "some" } else { "no" },
if has_lists { "some" } else { "no" }
);
}
#[test]
fn test_kitchensink() {
let lex_src = std::fs::read_to_string("../../comms/specs/benchmark/010-kitchensink.lex")
.expect("kitchensink file should exist");
let arena = Arena::new();
let root = lex_to_comrak_ast(&lex_src, &arena);
let mut node_types = std::collections::HashSet::new();
collect_node_types(root, &mut node_types);
assert!(
node_types.len() >= 5,
"Kitchensink should have at least 5 different node types, found: {node_types:?}"
);
println!("Kitchensink node types: {node_types:?}");
}
#[test]
fn test_kitchensink_snapshot() {
let lex_src = std::fs::read_to_string("../../comms/specs/benchmark/010-kitchensink.lex")
.expect("kitchensink file should exist");
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert_snapshot!("kitchensink_markdown", md);
}
#[test]
fn test_reference_url_converted_to_link() {
let lex_src = "Visit [https://example.com] for more.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("https://example.com"),
"URL should be present in output"
);
assert!(
!md.contains("\\[https://"),
"Should not escape brackets for URLs"
);
}
#[test]
fn test_verbatim_bracket_lines_stay_literal_not_autolinks() {
let lex_src =
"Config example:\n\n [server]\n [formatting.rules]\n port = 8080\n:: toml ::\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("[server]"),
"`[server]` must be present literally: {md}"
);
assert!(
md.contains("[formatting.rules]"),
"`[formatting.rules]` must be present literally: {md}"
);
assert!(
!md.contains("[server](server)") && !md.contains("](server)"),
"`[server]` must not become an auto-link: {md}"
);
assert!(
!md.contains("](formatting.rules)"),
"`[formatting.rules]` must not become an auto-link: {md}"
);
let arena = Arena::new();
let options = ComrakOptions::default();
let root = parse_document(&arena, &md, &options);
fn walk<'a>(node: &'a comrak::nodes::AstNode<'a>, saw_brackets_in_code: &mut bool) {
match &node.data.borrow().value {
NodeValue::Link(_) => panic!("verbatim bracket line leaked out as a link"),
NodeValue::CodeBlock(cb) => {
if cb.literal.contains("[server]") && cb.literal.contains("[formatting.rules]") {
*saw_brackets_in_code = true;
}
}
NodeValue::Text(t) => {
assert!(
!t.contains("[server]") && !t.contains("[formatting.rules]"),
"bracket line leaked outside the fence as prose text: {t:?}"
);
}
_ => {}
}
for child in node.children() {
walk(child, saw_brackets_in_code);
}
}
let mut saw_brackets_in_code = false;
walk(root, &mut saw_brackets_in_code);
assert!(
saw_brackets_in_code,
"a code fence must carry both bracket lines literally: {md}"
);
}
#[test]
fn test_reference_anchor_converted_to_link() {
let lex_src = "See section [#introduction] above.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("(#introduction)"),
"Anchor should link to #introduction"
);
assert!(
md.contains("[section]"),
"Link text should be the implicit anchor word 'section'"
);
}
#[test]
fn test_citation_converted_to_ref_link() {
let lex_src = "According to [@smith2023], this is true.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("[@smith2023]"), "Citation should be a link");
assert!(
!md.contains("\\[@"),
"Should not escape brackets for citations"
);
}
#[test]
fn test_citation_anchor_excludes_locator() {
let lex_src = "See [@spec2025, pp. 45-46] for details.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("(#ref-spec2025)"),
"Anchor must be the citation key only, got: {md}"
);
assert!(
!md.contains("#ref-spec2025,") && !md.contains("pp.%20"),
"Locator must not leak into the anchor, got: {md}"
);
}
#[test]
fn test_placeholder_reference_as_text() {
let lex_src = "This needs citation [TK-ref].\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("\\[TK-ref\\]"),
"Placeholder should be visible as text with escaped brackets, got: {md}"
);
assert!(
md.contains("TK-ref"),
"Placeholder content should be present"
);
}
#[test]
fn test_multi_group_verbatim_exports_every_group_as_a_fence() {
let lex_src = "First group:\n\n cmd one\nSecond group:\n\n cmd two\nThird group:\n\n cmd three\n:: shell ::\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
for cmd in ["cmd one", "cmd two", "cmd three"] {
assert!(md.contains(cmd), "group command `{cmd}` missing: {md}");
}
assert!(
!md.contains("\\#") && !md.contains("cmd two cmd"),
"later groups must not render as escaped/joined prose: {md}"
);
let arena = Arena::new();
let options = ComrakOptions::default();
let root = parse_document(&arena, &md, &options);
fn collect_code_blocks<'a>(
node: &'a comrak::nodes::AstNode<'a>,
out: &mut Vec<(String, String)>,
) {
if let NodeValue::CodeBlock(cb) = &node.data.borrow().value {
out.push((cb.info.clone(), cb.literal.clone()));
}
for child in node.children() {
collect_code_blocks(child, out);
}
}
let mut blocks = Vec::new();
collect_code_blocks(root, &mut blocks);
assert_eq!(
blocks.len(),
3,
"all three verbatim groups must become separate code fences: {blocks:?}"
);
for (info, literal) in &blocks {
assert_eq!(info, "shell", "each group keeps the shared language hint");
assert!(
literal.contains("cmd "),
"each fence carries its group's command: {literal:?}"
);
}
}
#[test]
fn test_anchorless_file_reference_becomes_a_link_not_escaped_text() {
let lex_src = "Nav: [./editors] [./why]\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("[./why](./why)"),
"anchorless file ref must self-link, got: {md}"
);
assert!(
!md.contains("\\[./why\\]"),
"anchorless file ref must not be escaped to literal text, got: {md}"
);
}
#[test]
fn test_anchorless_link_like_references_self_link_not_escaped() {
let cases = [
(
"X: [https://a.example.com] [https://b.example.com]",
"b.example.com",
),
("X: [#sess-one] [#sess-two]", "#sess-two"),
("X: [Introduction] [Conclusion]", "Conclusion"),
];
for (lex_src, needle) in cases {
let lex_doc = STRING_TO_AST.run(format!("{lex_src}\n")).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains(needle),
"anchorless link-like ref `{needle}` must survive as a link: {md}"
);
assert!(
!md.contains(&format!("\\[{needle}")) && !md.contains("\\]"),
"anchorless link-like ref must not be escaped to literal text: {md}"
);
}
}
#[test]
fn test_marker_reference_still_escaped_not_self_linked() {
let lex_src = "This needs [TK-ref].\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("\\[TK-ref\\]"),
"marker-style placeholder must stay escaped, got: {md}"
);
assert!(
!md.contains("(TK-ref)"),
"marker-style placeholder must not self-link, got: {md}"
);
}
#[test]
fn test_anchored_file_reference_unchanged() {
let lex_src = "See Editors [./editors] for details.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("[Editors](./editors)"),
"anchored file ref must link the anchor word, got: {md}"
);
assert!(
!md.contains("[./editors](./editors)"),
"anchored ref must not additionally self-link the target, got: {md}"
);
}
#[test]
fn test_self_link_placed_after_multi_group_verbatim_not_between_groups() {
let lex_src = "First:\n\n cmd one\nSecond:\n\n cmd two\n:: shell ::\n\n[https://after.example.com]\n\n2. Tail\n\n More body.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
let after_pos = md
.find("after.example.com")
.unwrap_or_else(|| panic!("self-link missing: {md}"));
let cmd_two_pos = md
.find("cmd two")
.unwrap_or_else(|| panic!("second group missing: {md}"));
assert!(
after_pos > cmd_two_pos,
"self-link must land after all verbatim groups, not between them: {md}"
);
let tail_pos = md.find("Tail").unwrap();
assert!(
after_pos < tail_pos,
"self-link must precede the following section: {md}"
);
}
#[test]
fn test_list_with_simple_items() {
let lex_src = "- First item\n- Second item\n- Third item\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("- First item"));
assert!(md.contains("- Second item"));
assert!(md.contains("- Third item"));
}
#[test]
fn test_list_with_multi_paragraph_items() {
let lex_src = concat!(
"- Item one with first paragraph.\n",
"\n",
" Second paragraph in item one.\n",
"\n",
"- Item two.\n"
);
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("- "), "Should have list markers");
assert!(md.contains("Item one"));
assert!(md.contains("Item two"));
}
#[test]
fn test_definition_exports_as_pandoc_style() {
let lex_src = "term-a:\n Definition body for term a.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
!md.contains("**term-a**:"),
"should not emit bold-term fallback: {md}"
);
assert!(
md.contains("term-a\n"),
"term should be on its own line: {md}"
);
assert!(
md.contains(": Definition body for term a."),
"description should be prefixed with `:`: {md}"
);
}
#[test]
fn test_pandoc_definition_round_trips_lex_to_md_to_lex() {
use lex_core::lex::ast::ContentItem;
let lex_src = "term-a:\n Definition body for term a.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
let reparsed = MarkdownFormat.parse(&md).expect("re-parse markdown");
let definition_count: usize = reparsed
.root
.children
.iter()
.filter(|item| matches!(item, ContentItem::Definition(_)))
.count();
assert_eq!(
definition_count, 1,
"expected one Definition after round-trip, got {definition_count}; md was: {md}"
);
}
#[test]
fn test_multiple_definitions_pandoc_style() {
let lex_src = concat!("term-a:\n", " def a\n\n", "term-b:\n", " def b\n",);
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("term-a\n"), "term-a expected: {md}");
assert!(md.contains(": def a"), "def a body expected: {md}");
assert!(md.contains("term-b\n"), "term-b expected: {md}");
assert!(md.contains(": def b"), "def b body expected: {md}");
}
#[test]
fn test_document_title_exported_as_h1() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-01-title-explicit.lex",
)
.expect("document-01 spec file should exist");
let lex_doc = STRING_TO_AST.run(lex_src).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.starts_with("# My Document Title\n"),
"Should start with H1 title heading"
);
}
#[test]
fn test_document_first_paragraph_as_title() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-06-title-empty.lex",
)
.expect("document-06 spec file should exist");
let lex_doc = STRING_TO_AST.run(lex_src).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.starts_with("# Just a paragraph with no title.\n"),
"First paragraph should become H1 title"
);
}
#[test]
fn test_document_session_only_no_h1_title() {
let lex_src = std::fs::read_to_string(
"../../comms/specs/elements/document.docs/document-05-title-session-hoist.lex",
)
.expect("document-05 spec file should exist");
let lex_doc = STRING_TO_AST.run(lex_src).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
!md.starts_with("# "),
"Session-only doc should not have H1 title"
);
assert!(md.contains("## "), "Session should be exported as H2");
}
#[test]
fn test_numbered_session_preserves_numbering() {
let lex_src = "1. Introduction\n\n Content here.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("## 1"),
"Numbered session heading must contain '1' prefix: {md}"
);
assert!(
md.contains("Introduction"),
"Heading must contain title text"
);
}
#[test]
fn test_numeric_heading_no_backslash_escape() {
let lex_src = "1. Glossary\n\n Some content.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("## 1. Glossary"),
"numeric heading should render with unescaped period: {md}"
);
assert!(
!md.contains("## 1\\."),
"heading should not contain `1\\.` escape: {md}"
);
}
#[test]
fn test_dotted_numeric_heading_no_backslash_escape() {
let lex_src = "1. Parent\n\n 1.1. Child\n\n Content.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
!md.contains("\\."),
"no backslash-escaped period anywhere when only headings have digit-dot: {md}"
);
assert!(md.contains("## 1. Parent"), "parent heading: {md}");
assert!(md.contains("### 1.1. Child"), "child heading: {md}");
}
#[test]
fn test_paragraph_starting_with_digit_dot_still_escapes() {
let lex_src = "Intro paragraph here.\n\n1.5x is the speed.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
!md.contains("## 1.5x") && !md.contains("# 1.5x"),
"non-heading line should not have been hoisted to a heading: {md}"
);
assert!(md.contains("1.5x") || md.contains("1\\.5x"), "{md}");
}
#[test]
fn test_dotted_numbering_preserved() {
let lex_src = "1. Parent\n\n 1.1. Child\n\n Content.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("Parent"), "Parent heading must be present");
assert!(
md.contains("1.1"),
"Dotted numbering '1.1' must be preserved in nested heading: {md}"
);
assert!(md.contains("Child"), "Child heading text must be present");
}
#[test]
fn test_unnumbered_session_no_numbering_added() {
let lex_src = "My Session Title\n\n Content.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("## My Session Title"),
"Unnumbered session should export as-is: {md}"
);
}
#[test]
fn test_numbering_round_trip_lex_md_lex() {
let lex_src =
"1. First Session\n\n Paragraph one.\n\n2. Second Session\n\n Paragraph two.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("1"), "MD should contain numbering '1'");
assert!(md.contains("2"), "MD should contain numbering '2'");
let lex_doc2 = MarkdownFormat.parse(&md).unwrap();
let md2 = MarkdownFormat.serialize(&lex_doc2).unwrap();
assert!(
md2.contains("First Session"),
"First session must survive round-trip"
);
assert!(
md2.contains("Second Session"),
"Second session must survive round-trip"
);
}
#[test]
fn test_inline_math_not_escaped() {
let lex_src = "A factor of #\\log_2# bits.\n";
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(
md.contains("$\\log_2$"),
"math content must emit verbatim, got: {md:?}"
);
assert!(
!md.contains("\\\\log"),
"backslash must not be double-escaped, got: {md:?}"
);
assert!(
!md.contains("\\_"),
"underscore must not be escaped inside math, got: {md:?}"
);
}
#[test]
fn test_inline_math_complex_expressions() {
let lex_src = concat!(
"1. Math\n\n",
" Curves are #P_{out} = f(P_{in})# transfer functions.\n\n",
" The #\\Delta E_{OK}# threshold is small.\n\n",
" Dot product is #a \\cdot b# here.\n",
);
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("$P_{out} = f(P_{in})$"), "subscripts: {md:?}");
assert!(md.contains("$\\Delta E_{OK}$"), "Delta + subscript: {md:?}");
assert!(md.contains("$a \\cdot b$"), "\\cdot: {md:?}");
}
#[test]
fn test_nested_numbering_round_trip() {
let lex_src = concat!(
"1. Top Level\n\n",
" Opening paragraph.\n\n",
" 1.1. Nested\n\n",
" Nested content.\n\n",
" 1.2. Another Nested\n\n",
" More content.\n",
);
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
let md = MarkdownFormat.serialize(&lex_doc).unwrap();
assert!(md.contains("## "), "Top level should be H2");
assert!(md.contains("### "), "Nested sessions should be H3: {md}");
assert!(md.contains("Top Level"), "Top level title present");
assert!(md.contains("Nested"), "First nested title present");
assert!(md.contains("Another Nested"), "Second nested title present");
let lex_doc2 = MarkdownFormat.parse(&md).unwrap();
let md2 = MarkdownFormat.serialize(&lex_doc2).unwrap();
assert!(md2.contains("Top Level"), "Top level survives round-trip");
assert!(md2.contains("Nested"), "Nested survives round-trip");
}
fn lex_to_md(lex_src: &str) -> String {
let lex_doc = STRING_TO_AST.run(lex_src.to_string()).unwrap();
MarkdownFormat.serialize(&lex_doc).unwrap()
}
#[test]
fn test_md_inline_word_anchor_preceding() {
let md = lex_to_md("Body intro.\n\nthe project website [https://lex.ing] today\n\n");
assert!(
md.contains("the project [website](https://lex.ing) today"),
"preceding word anchor, got: {md}"
);
assert!(!md.contains("[https://lex.ing]"), "bracket not literal");
}
#[test]
fn test_md_inline_word_anchor_following() {
let md = lex_to_md("Body intro.\n\n[https://lex.ing] is the home page.\n\n");
assert!(
md.contains("[is](https://lex.ing) the home page."),
"following word anchor, got: {md}"
);
}
#[test]
fn test_md_whole_element_anchor_session_title() {
let md = lex_to_md("Getting Started\n[./readme.txt]\n\n Welcome to the docs.\n\n");
assert!(
md.contains("## [Getting Started](./readme.txt)"),
"session title wrapped, got: {md}"
);
}
#[test]
fn test_md_whole_element_anchor_list_item() {
let md = lex_to_md("Intro.\n\n- Food\n- Water\n[https://water.example]\n- Bread\n\n");
assert!(
md.contains("- [Water](https://water.example)"),
"list item wrapped, got: {md}"
);
assert!(md.contains("- Food") && md.contains("- Bread"));
}
#[test]
fn test_md_whole_element_anchor_definition_term() {
let md = lex_to_md("API Endpoint:\n[./endpoint.txt]\n A URL that provides access.\n\n");
assert!(
md.contains("[API Endpoint](./endpoint.txt)"),
"definition term wrapped (no trailing colon), got: {md}"
);
}
#[test]
fn test_md_whole_element_anchor_verbatim_subject() {
let md = lex_to_md("Example Source:\n[./example.rs]\n fn main() {}\n:: rust ::\n\n");
assert!(
md.contains("**[Example Source](./example.rs)**"),
"verbatim subject caption wraps a link, got: {md}"
);
}
#[test]
fn test_md_self_link_reference_line() {
let md = lex_to_md("See the upstream project:\n\n[https://github.com/lex-fmt/lex]\n\n");
assert!(
md.contains("<https://github.com/lex-fmt/lex>")
|| md.contains("[https://github.com/lex-fmt/lex](https://github.com/lex-fmt/lex)"),
"self-link standalone, got: {md}"
);
}
#[test]
fn test_md_marker_references_unchanged() {
let md = lex_to_md("Body.\n\nSee [42] and [@smith2023] later.\n\n");
assert!(
md.contains("\\[42\\]"),
"footnote marker unchanged, got: {md}"
);
assert!(
md.contains("[@smith2023](#ref-smith2023)"),
"citation marker unchanged, got: {md}"
);
assert!(!md.contains("[See](42)"));
assert!(!md.contains("[and](#ref-smith2023)"));
}