Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629use ratatui::style::{Color, Modifier};use yame::config::Theme;use yame::decoration::build_decoration_map;use yame::highlighting::HighlightCache;
/// Full decoration pass against the fixture file./// Confirms headings, bold, blockquotes, and word count all survive the pipeline.#[test]fn fixture_decoration_roundtrip() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, word_count) = build_decoration_map(text, &theme, true, None);
// Line 0 ("# Heading One") must have full_line_bg == theme.heading_bg. // (Checking the exact value, not just is_some(), so delete-field mutations are caught.) assert!( map[&0] .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)), "H1 on line 0 must have full_line_bg == theme.heading_bg" );
// Line 10 is the normal-paragraph line containing **bold content**. // Check that BOLD appears on that specific line, not just somewhere in the file. assert!( map[&10] .iter() .any(|s| s.style.add_modifier.contains(Modifier::BOLD)), "line 10 (**bold content**) must contain at least one BOLD span" );
// Line 46 ("> A single-line blockquote.") must have a blockquote indicator. assert!( map[&46].iter().any(|s| s.is_blockquote), "line 46 ('> A single-line blockquote.') must have an is_blockquote span" );
// The fixture has more than 100 words. assert!( word_count > 100, "word count was {word_count} — expected >100" );}
/// Strikethrough syntax (~~text~~) must produce a CROSSED_OUT span.////// Line 38 (0-indexed) of the fixture is "~~Strikethrough.~~"./// The content span "Strikethrough." at chars 2..16 must carry CROSSED_OUT.#[test]fn fixture_has_strikethrough() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
// Check the specific line rather than any-line — kills whole-function and // line-replacement mutations that emit CROSSED_OUT on the wrong line. let strike_line = map .get(&38) .expect("line 38 ('~~Strikethrough.~~') must have spans"); assert!( strike_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT)), "line 38 ('~~Strikethrough.~~') must have a CROSSED_OUT span" ); // The opening ~~ delimiters are at chars 0..2; content starts at char 2. assert!( strike_line .iter() .any(|s| s.char_start == 2 && s.style.add_modifier.contains(Modifier::CROSSED_OUT)), "strikethrough content on line 38 must start at char 2 (after ~~)" );}
/// Link text must be underlined per the decoration spec.////// Line 30 (0-indexed) is "A [link to example](https://example.com) ..."./// The link text "link to example" at chars 3..18 must be UNDERLINED with/// fg == theme.link_text.#[test]fn fixture_has_link_underline() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
let link_line = map .get(&30) .expect("line 30 ('A [link to example]...') must have spans");
// Link text must be UNDERLINED on this specific line. assert!( link_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::UNDERLINED)), "line 30 must have an UNDERLINED span for the link text" ); // Link text must also carry theme.link_text as its foreground color. assert!( link_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::UNDERLINED) && s.style.fg == Some(theme.link_text)), "link text on line 30 must be UNDERLINED with fg == theme.link_text" );}
/// Horizontal rules (`---`) must set is_rule on their span.#[test]fn fixture_has_horizontal_rule() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); assert!( map.values().flatten().any(|s| s.is_rule), "expected at least one is_rule span (---) in the fixture" );}
/// Fenced code block content lines must receive the fenced_bg full-line background.#[test]fn fixture_has_fenced_code_bg() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); assert!( map.values() .flatten() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "expected at least one span with fenced_bg full-line background" );}
/// The fixture has H1–H4; all should carry heading_bg on at least one span./// Checks that multiple distinct heading lines are decorated (not just H1).#[test]fn fixture_multiple_heading_levels_decorated() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let heading_lines = map .values() .filter(|spans| { spans .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)) }) .count(); assert!( heading_lines >= 4, "expected ≥4 heading lines with heading_bg, got {heading_lines}" );}
/// Heading background must not bleed into the paragraph that follows.////// If `headings::on_end` is not called (e.g., the `Event::End(TagEnd::Heading(_))`/// match arm in `build_decoration_map` is deleted), `s.in_heading_bg` is never/// cleared and every subsequent line inherits the heading background.#[test]fn heading_bg_does_not_bleed_past_heading_line() { let text = "# Heading\n\nNormal text\n"; let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
// Line 0 must have heading_bg (basic sanity). assert!( map[&0] .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)), "line 0 must have heading_bg" ); // Line 2 ("Normal text") must NOT carry heading_bg. let bleeds = map .get(&2) .map(|spans| { spans .iter() .any(|s| s.full_line_bg == Some(theme.heading_bg)) }) .unwrap_or(false); assert!( !bleeds, "heading_bg must not bleed into line 2 (normal paragraph after heading)" );}
// ---------------------------------------------------------------------------// Continuation indent (regression guard for the list-item clipping bug)// ---------------------------------------------------------------------------
/// Unordered bullet spans must carry continuation_indent >= 2.////// This is the integration-level guard for the list-item soft-wrap clipping/// bug: if continuation_indent is zero, wrap_line_indented degenerates to/// wrap_line and continuation rows overflow the right edge.#[test]fn fixture_unordered_list_has_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Bullet spans anchor at char_start == 0, char_end == 1 ("- "). assert!( map.values() .flatten() .any(|s| s.char_start == 0 && s.char_end == 1 && s.continuation_indent >= 2), "expected at least one unordered bullet span with continuation_indent >= 2" );}
/// Blockquote spans must carry continuation_indent == 2.////// Guards that the `> ` prefix (2 columns) is accounted for when wrapping/// long blockquote lines, preventing right-edge overflow on continuation rows.#[test]fn fixture_blockquote_has_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); assert!( map.values() .flatten() .any(|s| s.is_blockquote && s.continuation_indent == 2), "expected at least one blockquote span with continuation_indent == 2" );}
/// Task-list items must produce a maximum continuation_indent >= 6.////// The task checkbox prefix `- [ ] ` / `- [x] ` is 6 columns wide; the/// continuation indent must reach at least that far so wrapped task items/// align correctly under the text, not under the dash.#[test]fn fixture_task_list_has_max_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let max_ci = map .values() .flatten() .map(|s| s.continuation_indent) .max() .unwrap_or(0); assert!( max_ci >= 6, "expected max continuation_indent >= 6 for task-list items, got {max_ci}" );}
// ---------------------------------------------------------------------------// Bold + italic combined// ---------------------------------------------------------------------------
/// `***text***` must produce a span with both BOLD and ITALIC modifiers.////// Line 40 (0-indexed) is "***Bold and italic combined with triple asterisks.***"./// The content at chars 3..49 must carry both BOLD and ITALIC modifiers.#[test]fn fixture_has_bold_italic_combined() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
let bold_italic_line = map .get(&40) .expect("line 40 ('***Bold and italic...***') must have spans"); assert!( bold_italic_line.iter().any(|s| { s.style.add_modifier.contains(Modifier::BOLD) && s.style.add_modifier.contains(Modifier::ITALIC) }), "line 40 must have a span with both BOLD and ITALIC modifiers" ); // Content starts at char 3 (after opening ***). assert!( bold_italic_line.iter().any(|s| { s.char_start == 3 && s.style.add_modifier.contains(Modifier::BOLD) && s.style.add_modifier.contains(Modifier::ITALIC) }), "BOLD+ITALIC content on line 40 must start at char 3 (after ***)" );}
// ---------------------------------------------------------------------------// Custom TOML theme propagation (end-to-end config → theme → decoration)// ---------------------------------------------------------------------------
/// A custom palette TOML loads correctly and its accent propagates to every/// accent-derived theme token, which the full decoration pipeline then uses.////// The fixture `tests/fixtures/custom_theme.toml` overrides only `accent`./// We verify:/// 1. The file parses without warnings./// 2. `heading_bg` (derived as blend(accent, bg, 0.15)) differs from the/// default (proving the new accent reached theme derivation)./// 3. The decoration map contains at least one span whose `full_line_bg`/// equals the custom `heading_bg` (proving the theme reached rendering).#[test]fn fixture_custom_toml_theme_propagates() { let text = include_str!("fixtures/sample.md"); let toml_str = include_str!("fixtures/custom_theme.toml");
let cfg: yame::config::Config = toml::from_str(toml_str).expect("custom_theme.toml must parse as a valid Config"); let mut warnings = Vec::new(); let custom_theme = Theme::from_config(&cfg.palette, &cfg.theme, &cfg.headings, &mut warnings); assert!( warnings.is_empty(), "unexpected parse warnings from custom_theme.toml: {warnings:?}" );
// heading_bg is accent-derived; a red accent must move it away from the // default purple-tinted value. let default_theme = Theme::default_theme(); assert_ne!( custom_theme.heading_bg, default_theme.heading_bg, "custom accent (#ff0000) must change heading_bg away from the Catppuccin default" );
// Run the full decoration pipeline with the custom theme and confirm that // at least one heading span carries the custom heading_bg. let (map, _) = build_decoration_map(text, &custom_theme, true, None); assert!( map.values() .flatten() .any(|s| s.full_line_bg == Some(custom_theme.heading_bg)), "expected at least one heading span with the custom heading_bg in the decoration map" );}
// ---------------------------------------------------------------------------// Syntax highlighting integration (HighlightCache → build_decoration_map)// ---------------------------------------------------------------------------
/// With a live HighlightCache, a fenced Rust block must produce spans whose/// foreground is Color::Rgb (syntect-sourced) rather than the plain theme.text/// fallback colour.#[test]fn highlighted_rust_block_has_rgb_fg_spans() { let text = "# Doc\n\n```rust\nfn main() {}\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache));
// Content line of the fenced block is line index 3 ("fn main() {}"). // It should have at least one span whose fg is Color::Rgb(_,_,_). let content_line = map.get(&3).expect("line 3 (code content) must have spans"); assert!( content_line .iter() .any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _)))), "highlighted fenced block line must have at least one Color::Rgb fg span" );}
/// With highlighting disabled, a fenced block must still get full_line_bg/// (fenced_bg) but must NOT have any Color::Rgb fg spans beyond theme colours/// (i.e. syntect didn't run).#[test]fn disabled_highlighting_fenced_block_has_bg_no_syntect_fg() { let text = "```rust\nlet x = 1;\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(false, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache));
// Content line is line 1. let content_line = map.get(&1).expect("line 1 (code content) must have spans"); // Must have the fenced_bg full-line background. assert!( content_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "disabled-highlighting fenced line must still have fenced_bg full_line_bg" ); // Must NOT have a syntect fg span (no Color::Rgb that differs from the // plain theme colours — specifically the single span should be theme.text fg). // We verify this by checking the fg is theme.text, not an arbitrary Rgb value. assert!( content_line.iter().all(|s| { // Acceptable fgs: theme.text (the plain fence_bg_style fg) or None. s.style.fg.is_none() || s.style.fg == Some(theme.text) }), "disabled-highlighting fenced line must not have syntect-sourced fg colours" );}
/// Passing `None` for the cache produces the same fenced_bg fallback as a/// disabled cache — the two paths must be indistinguishable.#[test]fn no_cache_fenced_block_falls_back_to_fenced_bg() { let text = "```python\nprint('hello')\n```\n"; let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
let content_line = map.get(&1).expect("line 1 must have spans"); assert!( content_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "None-cache fenced block must have fenced_bg full_line_bg on content lines" );}
/// The fence delimiter lines (``` opening and closing) must always use the/// blended fence_delim_style regardless of whether highlighting is active.#[test]fn highlighted_block_fence_delimiters_still_dimmed() { let text = "```rust\nlet x = 1;\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache));
// Opening fence is line 0, closing is line 2. for fence_line in [0usize, 2] { let spans = map .get(&fence_line) .unwrap_or_else(|| panic!("fence line {fence_line} must have spans")); assert!( spans .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "fence delimiter line {fence_line} must have fenced_bg full_line_bg" ); }}
/// An unknown language tag must silently fall back to fenced_bg-only/// (no panic, no empty map) even when a cache is present.#[test]fn unknown_lang_tag_falls_back_silently() { let text = "```notareallanguage\nsome code here\n```\n"; let theme = Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache));
let content_line = map.get(&1).expect("line 1 must have spans"); assert!( content_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "unknown lang fenced block must fall back to fenced_bg on content lines" );}
// ---------------------------------------------------------------------------// Inline code// ---------------------------------------------------------------------------
/// Inline code spans (`` `code` ``) must receive fg == theme.code_color.////// Line 34 (0-indexed) is "`inline code` at the start, ..."./// The content "inline code" at chars 1..12 must have fg == theme.code_color.#[test]fn fixture_inline_code_has_code_color() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
// Line 34 starts with a backtick-delimited span — check this specific line. let code_line = map .get(&34) .expect("line 34 ('`inline code` at the start...') must have spans"); assert!( code_line .iter() .any(|s| s.style.fg == Some(theme.code_color)), "line 34 must have a span with fg == theme.code_color for inline code" ); // The inline-code content starts at char 1 (after the opening backtick). assert!( code_line .iter() .any(|s| s.char_start == 1 && s.style.fg == Some(theme.code_color)), "inline code content on line 34 must start at char 1 (after the opening `)" );}
// ---------------------------------------------------------------------------// Italic// ---------------------------------------------------------------------------
/// Line 10 must carry the ITALIC modifier on the `*italic content*` span.////// Line 10 (0-indexed) is the normal-paragraph line containing both/// `**bold content**` and `*italic content*`; italic must be emitted as a/// distinct modifier, not collapsed into the bold path.#[test]fn fixture_italic_has_italic_modifier() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None);
let para_line = map .get(&10) .expect("line 10 (normal paragraph with *italic*) must have spans"); assert!( para_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::ITALIC)), "line 10 must have an ITALIC span for *italic content*" ); // Italic must use theme.italic_color, not default text color. assert!( para_line .iter() .any(|s| s.style.add_modifier.contains(Modifier::ITALIC) && s.style.fg == Some(theme.italic_color)), "italic span on line 10 must have fg == theme.italic_color" );}
// ---------------------------------------------------------------------------// Ordered list continuation indent// ---------------------------------------------------------------------------
/// Ordered-list bullet spans must carry continuation_indent >= 3.////// The "1. " prefix is 3 columns wide; continuation rows must indent to align/// with the item text, not the digit.#[test]fn fixture_ordered_list_has_continuation_indent() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Ordered bullet spans start at char 0; continuation_indent encodes the // bullet width ("1. " = 3 cols, "10. " = 4 cols, etc.). assert!( map.values() .flatten() .any(|s| s.char_start == 0 && s.continuation_indent >= 3), "expected at least one ordered-list span with continuation_indent >= 3" );}
// ---------------------------------------------------------------------------// H1–H3 bottom border// ---------------------------------------------------------------------------
/// H1 lines must have border_bottom set on at least one span.////// The fixture's first line is "# Heading One" (H1). The heading renderer/// draws a full-width underline after H1–H3 rows using the border_bottom field.#[test]fn fixture_h1_has_border_bottom() { let text = include_str!("fixtures/sample.md"); let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); // Line 0 is "# Heading One". let h1_spans = map.get(&0).expect("line 0 (H1) must have decoration spans"); assert!( h1_spans.iter().any(|s| s.border_bottom.is_some()), "H1 (line 0) must have border_bottom set on at least one span" );}
// ---------------------------------------------------------------------------// Blank line inside fenced code block (regression #133)// ---------------------------------------------------------------------------
/// A blank line that falls inside a fenced code block must still receive the/// fenced_bg full-line background, not lose it because the line is empty.////// Regression guard for #133: the original bug stripped fenced_bg from empty/// lines within a block because the span-emission path only ran when the line/// was non-empty.#[test]fn blank_line_inside_fenced_block_keeps_fenced_bg() { // The blank line (index 2) is inside the fenced block. let text = "```rust\nlet x = 1;\n\nlet y = 2;\n```\n"; let theme = Theme::default_theme(); let (map, _) = build_decoration_map(text, &theme, true, None); let blank_line = map .get(&2) .expect("blank line inside fenced block must have spans"); assert!( blank_line .iter() .any(|s| s.full_line_bg == Some(theme.fenced_bg)), "blank line inside fenced block must retain fenced_bg full_line_bg" );}
/// Regression: syntect fg spans must not be silently dropped.////// The original bug: a full-line background span (0..N) was emitted first./// `split_into_spans` sorted by char_start and advanced char_pos to N in one/// step, then clipped every subsequent fg span to zero length and skipped it —/// producing a plain fenced_bg block with no syntax colours at all.////// The fix: emit fg spans directly (no separate background span); put/// `full_line_bg` on the first fg span so the background still fills the column.#[test]fn highlighted_block_spans_are_not_swallowed_by_background() { let text = "```rust\nlet keyword = 1;\n```\n"; let theme = crate::Theme::default_theme(); let cache = HighlightCache::new(true, "base16-ocean.dark".into(), None); let (map, _) = build_decoration_map(text, &theme, true, Some(&cache));
let content_line = map.get(&1).expect("line 1 (code content) must have spans");
// Must have MORE than one span — the bug produced exactly one wide span // (the background) and nothing else. assert!( content_line.len() > 1, "highlighted line must have multiple spans (keyword + other tokens), got: {:?}", content_line.len() );
// At least one span must have a syntect-sourced Rgb foreground. assert!( content_line .iter() .any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _)))), "highlighted line must have at least one Color::Rgb fg span — \ background span must not swallow the syntect fg spans" );
// The first span (char 0) must carry full_line_bg for background fill. let first = content_line .iter() .min_by_key(|s| s.char_start) .expect("must have at least one span"); assert_eq!( first.full_line_bg, Some(theme.fenced_bg), "first highlighted span must carry full_line_bg for background fill" );}