use std::cell::RefCell; use std::rc::Rc; use gpui::{ AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_editor::highlight::Role; use gpui_native_editor::markdown::{ InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState, }; fn spans_text(spans: &[InlineSpan]) -> String { spans .iter() .map(|span| span.text.as_ref()) .collect::>() .join("") } fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec, usize, bool) { match &blocks[index] { MarkdownBlock::Paragraph { spans, indent, quote, } => (spans, *indent, *quote), other => panic!("block {index} is {other:?}, not a paragraph"), } } fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize, bool) { match &blocks[index] { MarkdownBlock::ListItem { marker, spans, indent, quote, } => (*marker, spans_text(spans), *indent, *quote), other => panic!("block {index} is {other:?}, not a list item"), } } #[test] fn inline_styles_survive_nesting_and_merge_runs() { let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done"); assert_eq!(blocks.len(), 1); let (spans, ..) = paragraph_of(&blocks, 0); let styled: Vec<(String, &InlineStyle)> = spans .iter() .map(|span| (span.text.to_string(), &span.style)) .collect(); assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong))); assert!( styled.contains(&("both".to_string(), &InlineStyle::StrongEmphasis)), "strong wrapping emphasis combines into one style" ); assert!(styled.contains(&(" b".to_string(), &InlineStyle::Strong))); assert!(styled.contains(&("code".to_string(), &InlineStyle::Code))); assert!(styled.contains(&( "link".to_string(), &InlineStyle::Link { destination: "https://example.com".into() } ))); assert!(styled.contains(&("a ".to_string(), &InlineStyle::Text))); assert!(styled.contains(&(" done".to_string(), &InlineStyle::Text))); } #[test] fn headings_headings_and_thematic_breaks_parse() { let blocks = parse_body("# Title\n\ntext\n\n---\n\n###### small"); assert_eq!(blocks.len(), 4); assert!(matches!( &blocks[0], MarkdownBlock::Heading { level: MarkdownHeading::H1, .. } )); assert!(matches!(&blocks[2], MarkdownBlock::ThematicBreak { .. })); assert!(matches!( &blocks[3], MarkdownBlock::Heading { level: MarkdownHeading::H6, .. } )); } #[test] fn lists_number_order_and_nest() { let source = "- one\n- two\n 1. inner\n 2. more\n3. third\n"; let blocks = parse_body(source); let markers: Vec = blocks .iter() .filter_map(|block| match block { MarkdownBlock::ListItem { marker, .. } => Some(*marker), _ => None, }) .collect(); assert_eq!( markers, vec![ ListMarker::Bullet, ListMarker::Bullet, ListMarker::Ordered(1), ListMarker::Ordered(2), ListMarker::Ordered(3), ], "ordered numbering continues across nested lists" ); let (_, _, outer_indent, _) = item_of(&blocks, 0); assert_eq!(outer_indent, 0); let (_, _, inner_indent, _) = item_of(&blocks, 2); assert_eq!(inner_indent, 1, "nested items indent one level deeper"); } #[test] fn tight_items_stay_tight_but_block_content_flushes_before_them() { let source = "- item text\n\n second paragraph\n- next\n"; let blocks = parse_body(source); // The first paragraph of a loose item carries its marker row; the // continuation flushes as a plain paragraph at the same indent. let (marker, text, indent, _) = item_of(&blocks, 0); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "item text"); assert_eq!(indent, 0); let (spans, indent, _) = paragraph_of(&blocks, 1); assert_eq!(spans_text(spans), "second paragraph"); assert_eq!( indent, 1, "loose item continuations indent under the item's text" ); let (marker, text, ..) = item_of(&blocks, 2); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "next"); } #[test] fn tight_item_content_after_a_nested_block_stays_in_the_item() { let blocks = parse_body( "- doc ```rust fn x() {} ``` after ", ); assert_eq!( blocks.len(), 3, "marker row, code, and one continuation row" ); let (marker, text, indent, _) = item_of(&blocks, 0); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "doc"); assert_eq!(indent, 0); assert!(matches!( &blocks[1], MarkdownBlock::Code { indent: 1, quote: false, .. } )); let (spans, indent, _) = paragraph_of(&blocks, 2); assert_eq!(spans_text(spans), "after"); assert_eq!(indent, 1, "the continuation stays under the item's text"); } #[test] fn task_markers_do_not_leak_into_following_items() { let blocks = parse_body("- [ ] todo\n- plain\n1. [x] done\n2. next\n"); assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false)); assert_eq!(item_of(&blocks, 1).0, ListMarker::Bullet); assert_eq!(item_of(&blocks, 2).0, ListMarker::Task(true)); assert_eq!(item_of(&blocks, 3).0, ListMarker::Ordered(2)); } #[test] fn quoted_text_after_an_item_flushes_the_item_first() { let blocks = parse_body("- text\n > quoted\n"); assert_eq!(blocks.len(), 2); assert_eq!(item_of(&blocks, 0).1, "text"); let (_, indent, quote) = paragraph_of(&blocks, 1); assert!(quote, "the quoted paragraph carries the quote flag"); assert_eq!(indent, 1, "quote content stays under the item's text"); } #[test] fn a_rule_after_item_text_keeps_its_order() { let blocks = parse_body("- text\n ***\n"); assert_eq!(blocks.len(), 2); assert_eq!(item_of(&blocks, 0).1, "text"); assert!(matches!( &blocks[1], MarkdownBlock::ThematicBreak { indent: 1, quote: false } )); } #[test] fn nested_code_and_tables_carry_indent_and_quote() { let blocks = parse_body("- fenced\n\n ```rust\n fn x() {}\n ```\n\n> | a |\n> |---|\n> | b |\n"); assert_eq!(blocks.len(), 3); let (marker, text, indent, _) = item_of(&blocks, 0); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "fenced"); assert_eq!(indent, 0); assert!(matches!( &blocks[1], MarkdownBlock::Code { indent: 1, quote: false, .. } )); assert!(matches!( &blocks[2], MarkdownBlock::Table { indent: 0, quote: true, .. } )); } #[test] fn task_markers_carry_their_checked_state() { let blocks = parse_body("- [ ] todo\n- [x] done\n"); assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false)); assert_eq!(item_of(&blocks, 1).0, ListMarker::Task(true)); } #[test] fn quotes_mark_their_paragraphs_and_lists() { let blocks = parse_body("> quoted\n>\n> - listed\n\nplain"); let (_, _, quote) = paragraph_of(&blocks, 0); assert!(quote, "quoted paragraphs carry the quote flag"); assert!(item_of(&blocks, 1).3, "quoted list items carry it too"); assert!(!paragraph_of(&blocks, 2).2); } #[test] fn fenced_code_highlights_known_languages_and_keeps_the_info_string() { let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```"); assert_eq!(blocks.len(), 2); let MarkdownBlock::Code { language, lines, .. } = &blocks[0] else { panic!("the first block is code"); }; assert_eq!(language.as_deref(), Some("rust")); assert!( lines .lines .iter() .flat_map(|line| &line.spans) .any(|span| span.role == Role::Keyword), "rust fences highlight through the grammar" ); let MarkdownBlock::Code { language, lines, .. } = &blocks[1] else { panic!("the second block is code"); }; assert_eq!(language.as_deref(), Some("text")); assert!( lines .lines .iter() .flat_map(|line| &line.spans) .all(|span| span.role == Role::Text), "unknown languages render plain" ); } #[test] fn tables_parse_header_first_with_column_positions() { let blocks = parse_body("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n"); let MarkdownBlock::Table { table, .. } = &blocks[0] else { panic!("the block is a table"); }; assert_eq!(table.rows.len(), 3); assert!(table.rows[0].header); assert!(!table.rows[1].header); let header: Vec = table.rows[0] .cells .iter() .map(|cell| spans_text(&cell.spans)) .collect(); assert_eq!(header, vec!["a", "b"]); assert_eq!( table.rows[0] .cells .iter() .map(|cell| cell.column) .collect::>(), vec![1, 2] ); assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4"); } #[test] fn images_render_their_alt_text_and_html_renders_nothing() { let blocks = parse_body("see ![the logo](logo.png) now\n\n
hidden
\n"); assert_eq!(blocks.len(), 1, "the html block renders no block at all"); let (spans, ..) = paragraph_of(&blocks, 0); assert_eq!(spans_text(spans), "see the logo now"); } #[test] fn set_source_reparses_and_blocks_reset() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = cx.new(|_| MarkdownViewState::new("test-md", "# old\n")); cx.update(|cx| { state.update(cx, |state, cx| state.set_source("- a\n- b\n", cx)); }); let (source, count) = cx.update(|cx| { let state = state.read(cx); (state.source().to_string(), state.blocks().len()) }); assert_eq!(source, "- a\n- b\n"); assert_eq!(count, 2, "the block list reflects the new source"); } struct Host { view: Entity, } impl Render for Host { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.view.clone()) } } fn view_window(source: &str) -> (TestAppContext, VisualTestContext, Entity) { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = MarkdownViewState::new("test-md", source); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); let window = cx.add_window(move |_, cx| { let view = cx.new(|_| state); cell.borrow_mut().replace(view.clone()); Host { view } }); let state = state_cell.borrow().clone().expect("state captured"); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, visual, state) } #[test] fn blocks_render_virtualized_and_scroll_to_a_block() { let mut source = String::new(); for paragraph in 0..200 { source.push_str(&format!("paragraph {paragraph} with some body text\n\n")); } let (_cx, mut visual, state) = view_window(&source); assert!( visual.debug_bounds("test-md-block-0").is_some(), "the first block renders" ); assert!( visual.debug_bounds("test-md-block-150").is_none(), "blocks past the viewport never render" ); state.update_in(&mut visual, |state, _window, _cx| { state.scroll_to_block(150) }); visual.update(|window, cx| { window.draw(cx).clear(cx); }); assert!( visual.debug_bounds("test-md-block-150").is_some(), "scrolling brings the target block in" ); assert!( visual.debug_bounds("test-md-block-0").is_none(), "the first block scrolled out" ); assert!( visual.debug_bounds("test-md-block-199").is_none(), "blocks past the target stay unrendered" ); } #[test] fn clicking_a_link_opens_its_destination() { let (_cx, mut visual, _state) = view_window("[click me](https://example.com/welcome)"); let block = visual .debug_bounds("test-md-block-0") .expect("the paragraph renders"); visual.simulate_event(MouseDownEvent { position: point(block.left() + px(4.), block.center().y), button: MouseButton::Left, ..Default::default() }); visual.simulate_event(MouseUpEvent { position: point(block.left() + px(4.), block.center().y), button: MouseButton::Left, ..Default::default() }); assert_eq!( visual.opened_url().as_deref(), Some("https://example.com/welcome"), "the link's destination opens through the platform" ); } /// Parses without an app context; the view reuses this path. fn parse_body(source: &str) -> Vec { let state = MarkdownViewState::new("parse-md", source); state.blocks().to_vec() }