bezel-markdown 0.2.21

A Notion-style block document model for gpui — markdown in, markdown out
Documentation
use markdown::*;
use markdown::{parse::parse, serialize::serialize};

mod common;

fn text_of(doc: &Doc, ix: usize) -> &Text {
    doc.blocks[ix]
        .text_at(Part::Body)
        .expect("block holds text")
}

#[test]
fn typing_at_the_end_of_a_mark_joins_it() {
    let mut text = parse("**ab**").blocks[0]
        .text_at(Part::Body)
        .unwrap()
        .clone();
    text.insert(2, "c");
    assert_eq!(text.text, "abc");
    assert_eq!(text.marks[0].range, 0..3, "left-sticky: the tail is bold");
}

#[test]
fn typing_at_the_start_of_a_mark_stays_outside_it() {
    let mut text = parse("**ab**").blocks[0]
        .text_at(Part::Body)
        .unwrap()
        .clone();
    text.insert(0, "x");
    assert_eq!(text.text, "xab");
    assert_eq!(text.marks[0].range, 1..3);
}

#[test]
fn removing_text_collapses_the_marks_over_it() {
    let mut text = parse("a**bc**d").blocks[0]
        .text_at(Part::Body)
        .unwrap()
        .clone();
    text.remove(1..3);
    assert_eq!(text.text, "ad");
    assert!(
        text.marks
            .iter()
            .all(|span| span.range.end <= text.text.len())
    );
}

#[test]
fn toggling_twice_is_a_no_op() {
    let mut text = Text::plain("hello");
    let before = text.clone();
    text.toggle(0..5, Mark::Bold);
    assert!(text.covered_by(&(0..5), &Mark::Bold));
    text.toggle(0..5, Mark::Bold);
    assert_eq!(text, before);
}

#[test]
fn untoggling_the_middle_leaves_the_ends() {
    let mut text = Text::plain("abcde");
    text.toggle(0..5, Mark::Bold);
    text.toggle(1..4, Mark::Bold);
    let mut ranges: Vec<_> = text.marks.iter().map(|span| span.range.clone()).collect();
    ranges.sort_by_key(|range| range.start);
    assert_eq!(ranges, vec![0..1, 4..5]);
}

#[test]
fn abutting_marks_merge_so_they_serialize_as_one_run() {
    let mut text = Text::plain("abcd");
    text.toggle(0..2, Mark::Bold);
    text.toggle(2..4, Mark::Bold);
    assert_eq!(text.marks.len(), 1, "`**ab****cd**` is not one bold run");
    assert_eq!(text.marks[0].range, 0..4);
}

#[test]
fn enter_in_a_list_makes_another_item() {
    let mut doc = parse("- alpha");
    let new = doc.split(0, 3);
    assert_eq!(new, 1);
    assert!(matches!(doc.blocks[1].kind, BlockKind::Bullet(_)));
    assert_eq!(text_of(&doc, 0).text, "alp");
    assert_eq!(text_of(&doc, 1).text, "ha");
}

#[test]
fn enter_after_a_heading_gives_body_text() {
    let mut doc = parse("# Title");
    doc.split(0, 5);
    assert!(matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)));
}

#[test]
fn enter_at_the_start_of_a_heading_moves_it_down_whole() {
    let mut doc = parse("## abc");
    assert_eq!(doc.split(0, 0), 1, "the caret stays with the heading");
    assert!(matches!(doc.blocks[0].kind, BlockKind::Paragraph(_)));
    assert!(text_of(&doc, 0).text.is_empty());
    assert!(matches!(
        doc.blocks[1].kind,
        BlockKind::Heading { level: 2, .. }
    ));
    assert_eq!(text_of(&doc, 1).text, "abc");
}

#[test]
fn enter_in_a_heading_gives_its_tail_to_body_text() {
    let mut doc = parse("## abc");
    doc.split(0, 1);
    assert!(matches!(
        doc.blocks[0].kind,
        BlockKind::Heading { level: 2, .. }
    ));
    assert_eq!(text_of(&doc, 0).text, "a");
    assert!(matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)));
    assert_eq!(text_of(&doc, 1).text, "bc");
}

#[test]
fn backspace_walks_out_before_it_merges() {
    // Indented bullet: unmarker, then outdent, then merge.
    let mut doc = parse("- a\n    - b");
    assert_eq!(doc.blocks[1].indent, 1);

    let start = Cursor::new(1, Part::Body, 0);
    doc.merge_back(start);
    assert!(
        matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)),
        "first press drops the marker"
    );
    assert_eq!(doc.blocks[1].indent, 1);

    doc.merge_back(start);
    assert_eq!(doc.blocks[1].indent, 0, "second press outdents");

    let caret = doc.merge_back(start);
    assert_eq!(
        caret,
        Some(Cursor::new(0, Part::Body, 1)),
        "third press merges after \"a\""
    );
    assert_eq!(doc.blocks.len(), 1);
    assert_eq!(text_of(&doc, 0).text, "ab");
}

#[test]
fn indenting_carries_the_children() {
    let mut doc = parse("- a\n- b\n    - c");
    assert!(doc.indent(1));
    assert_eq!(doc.blocks[1].indent, 1);
    assert_eq!(doc.blocks[2].indent, 2, "the child came along");
}

/// The bug: a nested run carried on from the number the item had at the level
/// it left, so tabbing the second item of a list gave a sub-list starting at 2.
#[test]
fn a_nested_run_starts_over() {
    let mut doc = parse("1. a\n2. b\n3. c");
    assert!(doc.indent(1));
    assert_eq!(
        serialize(&doc),
        "1. a\n    1. b\n2. c",
        "the sub-list starts at 1, and the outer list closes up behind it"
    );
    assert!(doc.indent(2));
    assert_eq!(
        serialize(&doc),
        "1. a\n    1. b\n    2. c",
        "the next item into the same sub-list carries it on"
    );
}

/// Starting over is the *edit's* doing, not the numbering's: markdown honours
/// the first number of a list, so a document written from 5 is still read from
/// 5. Nesting one is what makes a new list, and a new list starts at 1.
#[test]
fn parsing_leaves_a_run_that_names_its_own_start() {
    assert_eq!(serialize(&parse("5. a\n6. b")), "5. a\n6. b");
    let mut doc = parse("- x\n5. a\n6. b");
    assert!(doc.indent(1));
    // `b` is left starting the outer run, and a run keeps the number it
    // names — the same rule, now applying to it.
    assert_eq!(serialize(&doc), "- x\n    1. a\n6. b");
}

#[test]
fn a_block_cannot_indent_more_than_one_past_the_one_above() {
    let mut doc = parse("- a\n- b");
    assert!(doc.indent(1));
    assert!(
        !doc.indent(1),
        "two levels in one press would break the invariant"
    );
    assert!(!doc.indent(0), "the first block has nothing to nest under");
}

#[test]
fn shortcuts_cover_the_slash_menu_vocabulary() {
    assert_eq!(shortcut("# x"), Some((Shortcut::Heading(1), 2)));
    assert_eq!(shortcut("### x"), Some((Shortcut::Heading(3), 4)));
    assert_eq!(shortcut("- [x] x"), Some((Shortcut::Task(true), 6)));
    assert_eq!(shortcut("- [ ] x"), Some((Shortcut::Task(false), 6)));
    assert_eq!(shortcut("- x"), Some((Shortcut::Bullet, 2)));
    assert_eq!(shortcut("1. x"), Some((Shortcut::Ordered, 3)));
    assert_eq!(shortcut("> x"), Some((Shortcut::Quote, 2)));
    assert_eq!(shortcut("plain"), None);
    // A task marker must win over the bullet it starts with.
    assert_ne!(shortcut("- [ ] x").map(|hit| hit.0), Some(Shortcut::Bullet));
}

/// The property that matters: no sequence of edits can reach a document
/// the serializer cannot express. If this fails, the editor corrupts the
/// file on save.
#[test]
fn no_edit_sequence_escapes_the_round_trip() {
    const SEEDS: &[&str] = &[
        "# Title\n\nBody **bold** text",
        "- a\n- b\n    - c",
        "1. one\n2. two",
        "- [ ] task\n- [x] done",
        "> quoted\n\npara `code` tail",
        "> [!TIP]\n> alert body\n\n> [!NOTE]",
        "para with [link](u) and ![img](i)",
    ];

    let mut rng = common::Rng(0xed17);
    for seed in SEEDS {
        for case in 0..20_000 {
            let mut doc = parse(seed);

            for _ in 0..6 {
                if doc.blocks.is_empty() {
                    break;
                }
                common::edit_randomly(&mut doc, &mut rng);

                // Offsets must stay inside the text they index — every part of
                // every block, not just the ones an edit was aimed at.
                for block in &doc.blocks {
                    for part in block.parts() {
                        let Some(text) = block.text_at(part) else {
                            continue;
                        };
                        for span in &text.marks {
                            assert!(
                                span.range.end <= text.text.len()
                                    && text.text.is_char_boundary(span.range.start)
                                    && text.text.is_char_boundary(span.range.end),
                                "mark escaped its text: {span:?} in {:?}",
                                text.text
                            );
                        }
                    }
                }

                // And the document invariant the serializer leans on.
                for ix in 0..doc.blocks.len() {
                    assert!(
                        doc.blocks[ix].indent <= doc.ceiling(ix),
                        "block {ix} is deeper than anything above it can hold"
                    );
                }
            }

            doc.normalize();
            let text = serialize(&doc);
            assert_eq!(
                parse(&text),
                doc,
                "seed {seed:?} case {case}: edits left a document that does not \
                     survive its own serializer\n--- serialized ---\n{text:?}\n"
            );
        }
    }
}

#[test]
fn marks_over_a_selection_are_the_ones_it_carries_throughout() {
    let doc = parse("**bold** and plain");
    let all = Selection::new(
        Cursor::new(0, Part::Body, 0),
        Cursor::new(0, Part::Body, 18),
    );
    let inside = Selection::new(Cursor::new(0, Part::Body, 1), Cursor::new(0, Part::Body, 3));

    assert_eq!(doc.marks(inside), vec![Mark::Bold], "all of it is bold");
    assert!(
        doc.marks(all).is_empty(),
        "a selection reaching past the run carries nothing throughout"
    );
}

#[test]
fn marks_at_a_caret_are_the_ones_the_next_character_would_join() {
    let doc = parse("**bold** tail");
    let at = |offset| Selection::at(Cursor::new(0, Part::Body, offset));

    assert_eq!(doc.marks(at(4)), vec![Mark::Bold], "the end of the run");
    assert!(doc.marks(at(0)).is_empty(), "and never the start of one");
    assert!(doc.marks(at(8)).is_empty(), "nor past it");
}

#[test]
fn enter_in_a_quote_stays_in_the_quote() {
    let note = Some(Quoted {
        alert: Some(QuoteKind::Note),
    });
    let mut doc = parse("> [!NOTE]\n> - one");
    let new = doc.split(0, 3);
    assert_eq!(doc.blocks[new].quote, note);
    assert!(matches!(doc.blocks[new].kind, BlockKind::Bullet(_)));

    // At the start, the empty block that opens above is in the quote too.
    let mut doc = parse("> [!NOTE]\n> body");
    doc.split(0, 0);
    assert_eq!(doc.blocks[0].quote, note);
    assert_eq!(text_of(&doc, 1).text, "body");
    doc.normalize();
    assert_eq!(serialize(&doc), "> [!NOTE]\n> body");
}

#[test]
fn peel_takes_the_marker_then_the_quote_then_the_indent() {
    let mut doc = parse("- x\n\n    > - a");
    assert!(doc.peel(1));
    assert!(matches!(doc.blocks[1].kind, BlockKind::Paragraph(_)));
    assert!(doc.blocks[1].quote.is_some());
    assert_eq!(doc.blocks[1].indent, 1);
    assert!(doc.peel(1));
    assert_eq!(doc.blocks[1].quote, None);
    assert_eq!(doc.blocks[1].indent, 1);
    assert!(doc.peel(1));
    assert_eq!(doc.blocks[1].indent, 0);
    assert!(!doc.peel(1));
}

#[test]
fn backspace_at_the_start_of_a_quoted_bullet_peels_before_it_merges() {
    let mut doc = parse("a\n\n> - b");
    let at = Cursor::new(1, Part::Body, 0);
    assert_eq!(doc.merge_back(at), Some(at));
    assert_eq!(serialize(&doc), "a\n\n> b");
    assert_eq!(doc.merge_back(at), Some(at));
    assert_eq!(serialize(&doc), "a\n\nb");
    assert_eq!(doc.merge_back(at), Some(Cursor::new(0, Part::Body, 1)));
    assert_eq!(serialize(&doc), "ab");
}

#[test]
fn toggling_a_quote() {
    let mut doc = parse("> a\n>\n> b\n\nc");

    // Picking another alert recolours the whole quote.
    doc.toggle_quote(1, Some(QuoteKind::Tip));
    assert_eq!(serialize(&doc), "> [!TIP]\n> a\n>\n> b\n\nc");

    // Picking the one it has takes the block out.
    doc.toggle_quote(1, Some(QuoteKind::Tip));
    assert_eq!(serialize(&doc), "> [!TIP]\n> a\n\nb\n\nc");

    // And a block outside a quote goes in.
    doc.toggle_quote(2, Some(QuoteKind::Tip));
    assert_eq!(serialize(&doc), "> [!TIP]\n> a\n\nb\n\n> [!TIP]\n> c");
}

#[test]
fn a_block_under_a_quoted_item_cannot_nest_outside_the_quote() {
    let mut doc = parse("> - a\n>\n>     b");
    assert_eq!(doc.blocks[1].indent, 1);
    doc.set_quote(1, None);
    assert_eq!(doc.blocks[1].indent, 0, "nothing could write it under `a`");
    assert_eq!(parse(&serialize(&doc)), doc);
}

#[test]
fn the_quote_shortcut_quotes_without_turning() {
    let mut doc = parse("- item");
    doc.apply(0, Shortcut::Quote);
    assert_eq!(serialize(&doc), "> - item");
}