base-markdown 0.1.0

CommonMark compliant markdown parser in Rust with ASTs and extensions
Documentation
// Released under the MIT License.
// Copyright, 2026, by Samuel Williams.

use super::{escape_table_pipes, has_matching_ticks, write_table_pipes, Info, State};
use crate::{
    markdown::Options,
    stream::{EventKind, Tag, Text},
    Document, NodeId, NodeSyntax, ParseOptions,
};
use alloc::string::String;

fn empty() -> Document<String> {
    Document::parse(String::new(), &ParseOptions::default()).unwrap()
}
fn paragraph(document: &mut Document<String>, parent: NodeId, value: &str) -> NodeId {
    let node = document
        .append_child(parent, EventKind::Start(Tag::Paragraph { tight: false }))
        .unwrap();
    document
        .append_child(node, EventKind::Text(Text::from(String::from(value))))
        .unwrap();
    node
}

#[test]
fn serializes_inline_documents_and_footnote_subtrees() {
    let options = Options::default();
    let mut state = State::new(&options);
    let mut document = empty();
    let root = document.root();
    document
        .append_child(root, EventKind::Text(Text::Literal("inline")))
        .unwrap();
    assert_eq!(
        state.handle(&document, root, &Info::new("", "")).unwrap(),
        "inline"
    );

    let mut document = empty();
    let note = document
        .append_child(
            document.root(),
            EventKind::Start(Tag::FootnoteDefinition(Text::Literal("note"))),
        )
        .unwrap();
    paragraph(&mut document, note, "first");
    paragraph(&mut document, note, "second");
    let rendered = state.handle(&document, note, &Info::new("", "")).unwrap();
    assert!(rendered.contains("    second"), "{:?}", rendered);
}

#[test]
fn handles_adjacent_text_nodes_and_phrasing_lookahead_errors() {
    let options = Options::default();
    let mut state = State::new(&options);
    let mut document = empty();
    let root = document.root();
    let node = paragraph(&mut document, root, "first");
    document
        .append_child(node, EventKind::Text(Text::Literal("second")))
        .unwrap();
    state
        .container_phrasing(&document, node, &Info::new("", ""))
        .unwrap();
    assert_eq!(core::mem::take(&mut state.output), "firstsecond");

    document
        .append_child(node, EventKind::Start(Tag::Item { block_end: false }))
        .unwrap();
    let options = Options {
        bullet: 'x',
        ..Options::default()
    };
    let error = State::new(&options)
        .container_phrasing(&document, node, &Info::new("", ""))
        .unwrap_err();
    assert_eq!(error.rule_id, "unexpected-marker");
}

#[test]
fn literal_lookahead_matches_rendering_across_scopes_and_wrapping_modes() {
    use crate::markdown::{construct_name::ConstructName, LineWrapping};
    let mut document = empty();
    let text = document
        .append_child(document.root(), EventKind::Text(Text::Literal("")))
        .unwrap();
    assert_eq!(
        State::new(&Options::default()).peek_node(&document, text),
        None
    );
    let scopes = [
        ConstructName::Phrasing,
        ConstructName::Label,
        ConstructName::DestinationRaw,
        ConstructName::HeadingAtx,
        ConstructName::CodeFencedLangGraveAccent,
    ];
    for line_wrapping in [LineWrapping::Preserve, LineWrapping::Unwrap] {
        let options = Options {
            line_wrapping,
            ..Options::default()
        };
        for scope in scopes {
            for first in (0..=127).map(char::from).chain(['é', 'α', '\u{a0}', '世']) {
                for after in ["", "ordinary", "*", "\\!", "\r\n# two", "\n12. two"] {
                    document
                        .set_kind(
                            text,
                            EventKind::Text(Text::from(alloc::format!("{first}{after}"))),
                        )
                        .unwrap();
                    let mut state = State::new(&options);
                    state.enter(scope);
                    if let Some(prefix) = state.peek_node(&document, text) {
                        state
                            .write_node(&document, text, &Info::new("", ""))
                            .unwrap();
                        assert_eq!(state.output.chars().next(), Some(prefix));
                    }
                }
            }
        }
    }
}

#[test]
fn joins_code_and_spread_items_consistently() {
    let options = Options {
        fences: false,
        ..Options::default()
    };
    let state = State::new(&options);
    let mut document = empty();
    let root = document.root();
    let code = document
        .append_child(
            root,
            EventKind::CodeBlock {
                value: Text::Literal("indented\n"),
                language: None,
                fence: None,
                math: false,
                unclosed: false,
            },
        )
        .unwrap();
    assert!(matches!(
        state.default_separator(&document, code, code, root),
        "\n\n<!---->\n\n"
    ));
    let item = document
        .append_child(root, EventKind::Start(Tag::Item { block_end: false }))
        .unwrap();
    let left = paragraph(&mut document, item, "one");
    let right = document
        .append_child(item, EventKind::Text(Text::Literal("other")))
        .unwrap();
    assert!(matches!(
        state.default_separator(&document, left, right, item),
        "\n"
    ));
    document
        .set_syntax(item, Some(NodeSyntax::ListItem { spread: true }))
        .unwrap();
    assert!(matches!(
        state.default_separator(&document, left, right, item),
        "\n\n"
    ));
    let right = paragraph(&mut document, item, "two");
    assert!(matches!(
        state.default_separator(&document, left, right, item),
        "\n\n"
    ));
}

#[test]
fn inline_literals_normalize_block_openers_and_preserve_other_line_endings() {
    let options = Options::default();
    let mut state = State::new(&options);
    state.inline_code("one\r\n# two", None).unwrap();
    assert_eq!(core::mem::take(&mut state.output), "`one # two`");
    state.inline_math("one\r\n> two").unwrap();
    assert_eq!(core::mem::take(&mut state.output), "$one > two$");
    state.inline_code("one\r\ntwo", None).unwrap();
    assert_eq!(core::mem::take(&mut state.output), "`one\r\ntwo`");
    state.inline_code("é ` α", None).unwrap();
    assert_eq!(core::mem::take(&mut state.output), "``é ` α``");
    state.inline_math(" $α$ ").unwrap();
    assert_eq!(core::mem::take(&mut state.output), "$$  $α$  $$");
    state.inline_code("one\n12. two", None).unwrap();
    assert_eq!(core::mem::take(&mut state.output), "`one 12. two`");
}

#[test]
fn detects_matching_and_nonmatching_code_fences() {
    assert!(has_matching_ticks(b"`code`", 1, 1));
    assert!(!has_matching_ticks(b"plain", 0, 1));
    assert!(!has_matching_ticks(b"``", 0, 1));
}

#[test]
fn escapes_pipes_outside_unmatched_code_spans_and_after_even_backslashes() {
    assert_eq!(escape_table_pipes(r"``code`| value"), r"``code`\| value");
    assert_eq!(escape_table_pipes(r"two \\|"), r"two \\\|");
    assert_eq!(escape_table_pipes(r"one \|"), r"one \|");
    assert_eq!(escape_table_pipes("α|🙂||ω"), "α\\|🙂\\|\\|ω");
    assert_eq!(
        escape_table_pipes(r"```α|🙂```|`x|y`|``z|w``"),
        r"```α|🙂```\|`x|y`\|``z|w``"
    );
    assert_eq!(escape_table_pipes(r"`α|🙂``|ω"), r"`α\|🙂``\|ω");
}

#[test]
fn table_pipe_runs_preserve_the_scalar_writer() {
    // Keep the former per-character writer as an independent control for source-run copying and backslash parity.
    fn scalar(value: &str, result: &mut String) {
        let bytes = value.as_bytes();
        let start_output = result.len();
        let mut index = 0;
        let mut code_ticks = None;
        while index < bytes.len() {
            if bytes[index] == b'`' {
                let start = index;
                index = super::super::util::scan::marker_run_end(bytes, index + 1, b'`');
                let ticks = index - start;
                if code_ticks == Some(ticks) {
                    code_ticks = None;
                } else if code_ticks.is_none() && has_matching_ticks(bytes, index, ticks) {
                    code_ticks = Some(ticks);
                }
                result.push_str(&value[start..index]);
            } else if bytes[index] == b'|' && code_ticks.is_none() {
                let slashes = result[start_output..]
                    .chars()
                    .rev()
                    .take_while(|character| *character == '\\')
                    .count();
                if slashes % 2 == 0 {
                    result.push('\\');
                }
                result.push('|');
                index += 1;
            } else {
                let character = value[index..].chars().next().unwrap();
                result.push(character);
                index += character.len_utf8();
            }
        }
    }
    const TOKENS: [&str; 6] = ["a", "é", "🙂", "`", "|", "\\"];
    for length in 0..=6 {
        for mut variant in 0..TOKENS.len().pow(length) {
            let mut value = String::new();
            for _ in 0..length {
                value.push_str(TOKENS[variant % TOKENS.len()]);
                variant /= TOKENS.len();
            }
            for prefix in ["", "previous|\\"] {
                let mut expected = String::from(prefix);
                let mut actual = String::from(prefix);
                scalar(&value, &mut expected);
                write_table_pipes(&value, &mut actual);
                assert_eq!(actual, expected, "{value:?}, prefix {prefix:?}");
            }
        }
    }
}

#[test]
fn serializes_table_cells_with_and_without_pipe_rewrites() {
    let mut document = empty();
    let row = document
        .append_child(document.root(), EventKind::Start(Tag::TableRow))
        .unwrap();
    for kind in [
        EventKind::Text(Text::Literal("plain α")),
        EventKind::Code {
            value: Text::Literal("a`b"),
            language: None,
        },
        EventKind::Text(Text::Literal("a|b")),
        EventKind::Code {
            value: Text::Literal("a|b"),
            language: None,
        },
        EventKind::Text(Text::Literal("")),
    ] {
        let cell = document
            .append_child(
                row,
                EventKind::Start(Tag::TableCell {
                    alignment: crate::AlignKind::None,
                    header: false,
                }),
            )
            .unwrap();
        document.append_child(cell, kind).unwrap();
    }
    let options = Options::default();
    let mut state = State::new(&options);
    state.render_table_row(&document, row).unwrap();
    assert_eq!(state.output, "| plain α | ``a`b`` | a\\|b | `a|b` |  |");
}