mod code_highlight;
mod elements;
mod inline_style;
mod parser;
mod selectable_text;
mod selection;
mod stitch;
mod style;
pub use code_highlight::normalize_language;
#[cfg(feature = "editor")]
pub use code_highlight::{
CodeHighlightTheme, DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, code_highlight_themes,
init_code_highlighting, set_code_highlight_theme,
};
pub use elements::*;
pub use inline_style::*;
pub use parser::*;
pub use selectable_text::{RunRole, SelectableText};
pub use selection::{MarkdownSelection, SelectionPosition};
pub use stitch::preprocessing_available;
pub use style::*;
use crate::a11y::{A11y, Announce};
use crate::theme::{ActiveTheme, Themeable};
use gpui::{
App, Context, ElementId, Entity, EntityId, IntoElement, ParentElement, Role, SharedString,
Styled, Task, Window, div, prelude::*, rems,
};
use pulldown_cmark::{Alignment, Event, Tag, TagEnd};
fn document_element_id(entity_id: EntityId) -> ElementId {
ElementId::NamedInteger("md-doc".into(), entity_id.as_u64())
}
fn run_element_id(index: usize) -> ElementId {
ElementId::NamedInteger("md-run".into(), index as u64)
}
pub struct Markdown {
source: SharedString,
parsed_source: SharedString,
events: Vec<MarkdownEvent>,
unclosed_code_block: Option<usize>,
selection: MarkdownSelection,
parse_state: ParseState,
parse_task: Option<Task<()>>,
preprocess_partial: bool,
}
enum ParseState {
Idle,
Parsing { dirty: bool },
}
struct ParsedDocument {
events: Vec<MarkdownEvent>,
unclosed_code_block: Option<usize>,
}
#[derive(Clone, Debug)]
pub struct MarkdownEvent {
pub event: Event<'static>,
pub source_range: std::ops::Range<usize>,
}
impl Markdown {
pub fn new(source: impl Into<SharedString>, _cx: &mut Context<Self>) -> Self {
let source: SharedString = source.into();
let preprocess_partial = true;
let parsed = Self::parse(&source, preprocess_partial);
Self {
parsed_source: source.clone(),
source,
events: parsed.events,
unclosed_code_block: parsed.unclosed_code_block,
selection: MarkdownSelection::new(),
parse_state: ParseState::Idle,
parse_task: None,
preprocess_partial,
}
}
pub fn source(&self) -> &str {
&self.source
}
pub fn parsed_source(&self) -> &str {
&self.parsed_source
}
pub fn set_source(&mut self, source: impl Into<SharedString>, cx: &mut Context<Self>) {
let source = source.into();
if source == self.source {
return;
}
self.source = source;
self.selection.clear();
self.request_parse(cx);
}
pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
if text.is_empty() {
return;
}
let mut source = String::with_capacity(self.source.len() + text.len());
source.push_str(&self.source);
source.push_str(text);
self.source = source.into();
self.request_parse(cx);
}
pub fn is_parsing(&self) -> bool {
matches!(self.parse_state, ParseState::Parsing { .. })
}
pub fn preprocess_partial(&self) -> bool {
self.preprocess_partial
}
pub fn set_preprocess_partial(&mut self, preprocess_partial: bool, cx: &mut Context<Self>) {
if self.preprocess_partial == preprocess_partial {
return;
}
self.preprocess_partial = preprocess_partial;
self.request_parse(cx);
}
pub fn selection(&self) -> MarkdownSelection {
self.selection.clone()
}
pub fn selected_text(&self) -> Option<String> {
self.selection.selected_text()
}
fn request_parse(&mut self, cx: &mut Context<Self>) {
if let ParseState::Parsing { dirty } = &mut self.parse_state {
*dirty = true;
return;
}
self.parse_state = ParseState::Parsing { dirty: false };
let mut pending = (self.source.clone(), self.preprocess_partial);
self.parse_task = Some(cx.spawn(async move |this, cx| {
loop {
let (source, preprocess_partial) = pending;
let parse = {
let source = source.clone();
cx.background_executor()
.spawn(async move { Self::parse(&source, preprocess_partial) })
};
let parsed = parse.await;
match this.update(cx, |this, cx| this.parse_landed(source, parsed, cx)) {
Ok(Some(next)) => pending = next,
Ok(None) | Err(_) => break,
}
}
}));
}
fn parse_landed(
&mut self,
parsed_source: SharedString,
parsed: ParsedDocument,
cx: &mut Context<Self>,
) -> Option<(SharedString, bool)> {
self.events = parsed.events;
self.unclosed_code_block = parsed.unclosed_code_block;
self.parsed_source = parsed_source;
cx.notify();
match self.parse_state {
ParseState::Parsing { dirty: true } => {
self.parse_state = ParseState::Parsing { dirty: false };
Some((self.source.clone(), self.preprocess_partial))
}
_ => {
self.parse_state = ParseState::Idle;
None
}
}
}
fn parse(source: &str, preprocess_partial: bool) -> ParsedDocument {
let fence_open = parser::has_open_code_fence(source);
let source = if preprocess_partial {
stitch::close_open_syntax(source)
} else {
std::borrow::Cow::Borrowed(source)
};
let parser = Parser::new_ext(&source, parser::default_options());
let events: Vec<MarkdownEvent> = parser
.into_offset_iter()
.map(|(event, range)| MarkdownEvent {
event: event.into_static(),
source_range: range,
})
.collect();
let unclosed_code_block = if fence_open {
events
.iter()
.filter(|event| matches!(event.event, Event::Start(Tag::CodeBlock(_))))
.count()
.checked_sub(1)
} else {
None
};
ParsedDocument {
events,
unclosed_code_block,
}
}
pub fn events(&self) -> &[MarkdownEvent] {
&self.events
}
}
#[derive(IntoElement)]
pub struct MarkdownElement {
markdown: Entity<Markdown>,
style: MarkdownStyle,
element_id: Option<ElementId>,
}
pub fn markdown(source: impl Into<SharedString>, cx: &mut App) -> MarkdownElement {
let entity = cx.new(|cx| Markdown::new(source, cx));
MarkdownElement::new(entity)
}
impl MarkdownElement {
pub fn new(markdown: Entity<Markdown>) -> Self {
Self {
markdown,
style: MarkdownStyle::default(),
element_id: None,
}
}
pub fn style(mut self, style: MarkdownStyle) -> Self {
self.style = style;
self
}
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.element_id = Some(id.into());
self
}
pub fn element_id(&self) -> ElementId {
self.element_id
.clone()
.unwrap_or_else(|| document_element_id(self.markdown.entity_id()))
}
}
impl RenderOnce for MarkdownElement {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let document_id = self.element_id();
let markdown = self.markdown.read(cx);
let events = markdown.events.clone();
let unclosed_code_block = markdown.unclosed_code_block;
let selection = markdown.selection.clone();
let style = self.style.clone();
selection.begin_frame();
let renderer = MarkdownRenderer::new(style, selection, unclosed_code_block);
renderer.render_events(&events, document_id, cx)
}
}
struct MarkdownRenderer {
style: MarkdownStyle,
elements: Vec<gpui::AnyElement>,
in_heading: Option<HeadingLevel>,
in_code_block: bool,
code_block_language: Option<String>,
unclosed_code_block: Option<usize>,
code_blocks_seen: usize,
code_block_is_unclosed: bool,
in_block_quote: bool,
in_image: Option<ImageContext>,
list_stack: Vec<ListContext>,
run_counter: usize,
selection: MarkdownSelection,
in_table: bool,
table_alignments: Vec<Alignment>,
table_rows: Vec<Vec<RichText>>,
current_row: Vec<RichText>,
in_table_head: bool,
current_text: RichText,
active_style: InlineStyle,
#[cfg(test)]
emitted_list_items: Vec<ListRow>,
#[cfg(test)]
emitted_code_blocks: Vec<Option<String>>,
}
#[cfg(test)]
#[derive(Clone, Debug, PartialEq, Eq)]
struct ListRow {
marker: elements::ItemMarker,
indent_level: usize,
text: String,
}
#[derive(Clone, Debug)]
struct ImageContext {
url: String,
alt: String,
}
#[derive(Clone, Debug)]
struct ListContext {
ordered: bool,
current_index: u64,
item: Option<ItemContext>,
}
#[derive(Clone, Debug, Default)]
struct ItemContext {
marker: Option<String>,
}
impl MarkdownRenderer {
fn new(
style: MarkdownStyle,
selection: MarkdownSelection,
unclosed_code_block: Option<usize>,
) -> Self {
Self {
style,
selection,
elements: Vec::new(),
in_heading: None,
in_code_block: false,
code_block_language: None,
unclosed_code_block,
code_blocks_seen: 0,
code_block_is_unclosed: false,
in_block_quote: false,
in_image: None,
list_stack: Vec::new(),
run_counter: 0,
in_table: false,
table_alignments: Vec::new(),
table_rows: Vec::new(),
current_row: Vec::new(),
in_table_head: false,
current_text: RichText::new(),
active_style: InlineStyle::default(),
#[cfg(test)]
emitted_list_items: Vec::new(),
#[cfg(test)]
emitted_code_blocks: Vec::new(),
}
}
fn render_events(
mut self,
events: &[MarkdownEvent],
document_id: ElementId,
cx: &App,
) -> impl IntoElement + use<> {
for event in events {
self.handle_event(&event.event, cx);
}
div()
.id(document_id)
.announce(A11y::new(Role::Document))
.w_full()
.flex()
.flex_col()
.gap(rems(self.style.block_spacing))
.children(self.elements)
}
fn handle_event(&mut self, event: &Event<'static>, cx: &App) {
match event {
Event::Start(tag) => self.handle_start_tag(tag, cx),
Event::End(tag) => self.handle_end_tag(tag, cx),
Event::Text(text) => self.handle_text(text),
Event::Code(code) => self.handle_inline_code(code),
Event::SoftBreak => {
if self.style.soft_break_as_hard_break {
self.current_text.push("\n", self.active_style)
} else {
self.current_text.push(" ", self.active_style)
}
}
Event::HardBreak => self.current_text.push("\n", self.active_style),
Event::Rule => self.push_divider(cx),
Event::TaskListMarker(checked) => self.handle_task_marker(*checked),
Event::Html(_) | Event::InlineHtml(_) => {}
Event::FootnoteReference(_) | Event::InlineMath(_) | Event::DisplayMath(_) => {}
}
}
fn handle_start_tag(&mut self, tag: &Tag<'static>, cx: &App) {
match tag {
Tag::Paragraph => {}
Tag::Heading { level, .. } => {
self.in_heading = Some((*level).into());
}
Tag::BlockQuote(_) => {
self.in_block_quote = true;
}
Tag::CodeBlock(kind) => {
self.in_code_block = true;
self.code_block_is_unclosed =
self.unclosed_code_block == Some(self.code_blocks_seen);
self.code_blocks_seen += 1;
self.code_block_language =
parser::code_block_language(kind).and_then(code_highlight::normalize_language);
}
Tag::List(start) => {
if !self.list_stack.is_empty() {
self.flush_list_item(cx);
}
self.list_stack.push(ListContext {
ordered: start.is_some(),
current_index: start.unwrap_or(1),
item: None,
});
}
Tag::Item => {
if let Some(list_ctx) = self.list_stack.last_mut() {
list_ctx.item = Some(ItemContext::default());
}
}
Tag::Emphasis => {
self.active_style.italic = true;
}
Tag::Strong => {
self.active_style.bold = true;
}
Tag::Strikethrough => {
self.active_style.strikethrough = true;
}
Tag::Link { dest_url, .. } => {
self.active_style.link = Some(self.current_text.add_link(dest_url.to_string()));
}
Tag::Image {
dest_url, title, ..
} => {
self.in_image = Some(ImageContext {
url: dest_url.to_string(),
alt: title.to_string(),
});
}
Tag::Table(alignments) => {
self.in_table = true;
self.table_alignments = alignments.clone();
self.table_rows.clear();
}
Tag::TableHead => {
self.in_table_head = true;
self.current_row.clear();
}
Tag::TableRow => {
self.current_row.clear();
}
Tag::TableCell => {
self.current_text.clear();
}
Tag::FootnoteDefinition(_)
| Tag::MetadataBlock(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::Superscript
| Tag::Subscript
| Tag::HtmlBlock => {}
}
}
fn handle_end_tag(&mut self, tag: &TagEnd, cx: &App) {
match tag {
TagEnd::Paragraph => {
if self.in_block_quote {
self.flush_block_quote(cx);
} else if self.in_list_item() {
self.flush_list_item(cx);
} else {
self.flush_paragraph(cx);
}
}
TagEnd::Heading(level) => {
let heading_level: elements::HeadingLevel = (*level).into();
self.in_heading = None;
self.flush_heading(heading_level, cx);
}
TagEnd::BlockQuote(_) => {
self.in_block_quote = false;
}
TagEnd::CodeBlock => {
self.in_code_block = false;
self.flush_code_block(cx);
}
TagEnd::List(_) => {
self.list_stack.pop();
}
TagEnd::Item => {
self.flush_list_item(cx);
if let Some(list_ctx) = self.list_stack.last_mut() {
list_ctx.item = None;
}
}
TagEnd::Emphasis => {
self.active_style.italic = false;
}
TagEnd::Strong => {
self.active_style.bold = false;
}
TagEnd::Strikethrough => {
self.active_style.strikethrough = false;
}
TagEnd::Link => {
self.active_style.link = None;
}
TagEnd::Image => {
self.flush_image(cx);
}
TagEnd::Table => {
self.flush_table(cx);
self.in_table = false;
}
TagEnd::TableHead => {
self.in_table_head = false;
if !self.current_row.is_empty() {
self.table_rows.push(std::mem::take(&mut self.current_row));
}
}
TagEnd::TableRow => {
if !self.current_row.is_empty() {
self.table_rows.push(std::mem::take(&mut self.current_row));
}
}
TagEnd::TableCell => {
self.current_row
.push(std::mem::take(&mut self.current_text));
}
TagEnd::FootnoteDefinition
| TagEnd::MetadataBlock(_)
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::HtmlBlock => {}
}
}
fn handle_text(&mut self, text: &str) {
if let Some(ref mut img_ctx) = self.in_image {
img_ctx.alt = text.to_string();
} else {
self.current_text.push(text, self.active_style);
}
}
fn handle_inline_code(&mut self, code: &str) {
let mut style = self.active_style;
style.code = true;
self.current_text.push(code, style);
}
fn handle_task_marker(&mut self, checked: bool) {
let marker = if checked { "☑ " } else { "☐ " };
self.current_text.push(marker, self.active_style);
}
fn palette(&self, cx: &App) -> InlinePalette {
let theme = cx.theme();
InlinePalette {
code_background: Some(self.style.inline_code_bg.unwrap_or(theme.surface())),
link_color: Some(self.style.link_color.unwrap_or(theme.accent())),
}
}
fn next_run(&mut self, cx: &App) -> (ElementId, elements::RunContext) {
let run = self.run_counter;
self.run_counter += 1;
let theme = cx.theme();
let run_cx = elements::RunContext {
selection: self.selection.clone(),
run,
selection_background: self
.style
.selection_background
.unwrap_or_else(|| theme.accent().opacity(0.25)),
};
(run_element_id(run), run_cx)
}
fn flush_paragraph(&mut self, cx: &App) {
if self.current_text.is_empty() {
return;
}
let rich_text = std::mem::take(&mut self.current_text);
let palette = self.palette(cx);
let (id, run_cx) = self.next_run(cx);
let element =
elements::rich_paragraph(id, &rich_text, &self.style.body, &palette, run_cx, cx);
self.elements.push(element.into_any_element());
}
fn flush_heading(&mut self, level: HeadingLevel, cx: &App) {
if self.current_text.is_empty() {
return;
}
let rich_text = std::mem::take(&mut self.current_text);
let heading_style = match level {
elements::HeadingLevel::H1 => &self.style.h1,
elements::HeadingLevel::H2 => &self.style.h2,
elements::HeadingLevel::H3 => &self.style.h3,
elements::HeadingLevel::H4 => &self.style.h4,
elements::HeadingLevel::H5 => &self.style.h5,
elements::HeadingLevel::H6 => &self.style.h6,
}
.clone();
let palette = self.palette(cx);
let (id, run_cx) = self.next_run(cx);
let element =
elements::rich_heading(id, &rich_text, level, &heading_style, &palette, run_cx, cx);
self.elements.push(element.into_any_element());
}
fn flush_block_quote(&mut self, cx: &App) {
if self.current_text.is_empty() {
return;
}
let rich_text = std::mem::take(&mut self.current_text);
let palette = self.palette(cx);
let (id, run_cx) = self.next_run(cx);
let element = elements::rich_block_quote(
id,
&rich_text,
&self.style.body,
self.style.block_quote_border,
self.style.block_quote_text,
&palette,
run_cx,
cx,
);
self.elements.push(element.into_any_element());
}
fn flush_code_block(&mut self, cx: &App) {
let is_unclosed = std::mem::take(&mut self.code_block_is_unclosed);
let language = self.code_block_language.take().filter(|_| !is_unclosed);
#[cfg(test)]
self.emitted_code_blocks.push(language.clone());
if self.current_text.is_empty() {
return;
}
let text = self.current_text.to_plain_text();
self.current_text.clear();
let (id, run_cx) = self.next_run(cx);
let element = elements::code_block(
id,
text,
language.as_deref(),
&self.style.code,
&self.style.code_font_family,
self.style.code_block_bg,
self.style.code_block_border,
run_cx,
cx,
);
self.elements.push(element.into_any_element());
}
fn in_list_item(&self) -> bool {
self.list_stack
.last()
.is_some_and(|list_ctx| list_ctx.item.is_some())
}
fn take_item_marker(&mut self) -> elements::ItemMarker {
let Some(list_ctx) = self.list_stack.last_mut() else {
return elements::ItemMarker::Shown(elements::unordered_marker());
};
if let Some(marker) = list_ctx.item.as_ref().and_then(|item| item.marker.clone()) {
return elements::ItemMarker::Hidden(marker);
}
let marker = if list_ctx.ordered {
let marker = elements::ordered_marker(list_ctx.current_index);
list_ctx.current_index += 1;
marker
} else {
elements::unordered_marker()
};
if let Some(item) = list_ctx.item.as_mut() {
item.marker = Some(marker.clone());
}
elements::ItemMarker::Shown(marker)
}
fn flush_list_item(&mut self, cx: &App) {
if self.current_text.is_empty() {
return;
}
let rich_text = std::mem::take(&mut self.current_text);
let marker = self.take_item_marker();
let indent_level = self.list_stack.len().saturating_sub(1);
#[cfg(test)]
self.emitted_list_items.push(ListRow {
marker: marker.clone(),
indent_level,
text: rich_text.to_plain_text(),
});
let palette = self.palette(cx);
let (id, run_cx) = self.next_run(cx);
let element = elements::rich_list_item(
id,
&rich_text,
marker,
indent_level,
&self.style.body,
&palette,
run_cx,
cx,
);
self.elements.push(element.into_any_element());
}
fn flush_image(&mut self, cx: &App) {
let img_ctx = match self.in_image.take() {
Some(ctx) => ctx,
None => return,
};
self.current_text.clear();
let alt = if img_ctx.alt.is_empty() {
None
} else {
Some(img_ctx.alt.as_str())
};
let element = elements::image(img_ctx.url, alt, cx);
self.elements.push(element.into_any_element());
}
fn flush_table(&mut self, cx: &App) {
if self.table_rows.is_empty() {
return;
}
let rows = std::mem::take(&mut self.table_rows);
let alignments = std::mem::take(&mut self.table_alignments);
let element = self.render_table(rows, alignments, cx);
self.elements.push(element.into_any_element());
}
fn render_table(
&self,
rows: Vec<Vec<RichText>>,
alignments: Vec<Alignment>,
cx: &App,
) -> impl IntoElement + use<> {
let theme = cx.theme();
let border_color = theme.border();
div()
.flex()
.flex_col()
.border_1()
.border_color(border_color)
.rounded_sm()
.overflow_hidden()
.children(rows.into_iter().enumerate().map(|(row_idx, row)| {
let is_header = row_idx == 0;
let bg = if is_header {
theme.surface()
} else if row_idx % 2 == 0 {
theme.bg()
} else {
theme.surface().opacity(0.5)
};
div()
.flex()
.flex_row()
.bg(bg)
.when(row_idx > 0, |el| el.border_t_1().border_color(border_color))
.children(row.into_iter().enumerate().map(|(col_idx, cell)| {
let alignment = alignments.get(col_idx).copied().unwrap_or(Alignment::None);
let (text, highlights) = cell.to_highlights_with(&self.palette(cx));
let styled_text: SharedString = text.into();
div()
.flex_1()
.min_w_0()
.px_2()
.py_1()
.text_size(rems(self.style.body.size))
.when(col_idx > 0, |el| el.border_l_1().border_color(border_color))
.when(is_header, |el| el.font_weight(gpui::FontWeight::SEMIBOLD))
.map(|el| match alignment {
Alignment::Left | Alignment::None => el,
Alignment::Center => el.text_center(),
Alignment::Right => el.text_right(),
})
.child(gpui::StyledText::new(styled_text).with_highlights(highlights))
}))
}))
}
fn push_divider(&mut self, cx: &App) {
let element = elements::divider(self.style.rule_color, cx);
self.elements.push(element.into_any_element());
}
}
#[cfg(test)]
mod tests {
use super::selectable_text::recorder::{self, RecordedRun};
use super::*;
use gpui::{AnyElement, Pixels, Render, TestAppContext, VisualTestContext, point, px, size};
use std::cell::Cell;
use std::collections::HashSet;
use std::rc::Rc;
const WIDTH: Pixels = px(240.);
const LONG: &str = "This one is deliberately long enough that it has to wrap onto several lines inside a narrow container.";
struct TestView {
source: SharedString,
}
impl Render for TestView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().flex().flex_col().child(
div()
.w(WIDTH)
.debug_selector(|| "measured".into())
.child(markdown(self.source.clone(), cx)),
)
}
}
fn height(cx: &mut TestAppContext, source: &str) -> Pixels {
cx.update(crate::theme::init);
let source = SharedString::from(source.to_string());
let (_view, cx) = cx.add_window_view(move |_window, _cx| TestView { source });
cx.debug_bounds("measured")
.expect("the measured container was never drawn")
.size
.height
}
fn line(cx: &mut TestAppContext) -> Pixels {
height(cx, "x")
}
#[track_caller]
fn assert_wrapped_like_a_paragraph(item: Pixels, paragraph: Pixels, line: Pixels) {
assert!(
paragraph > line,
"the baseline paragraph did not wrap ({paragraph:?}), so this measures nothing"
);
assert!(
item > line,
"the text stayed on one line ({item:?}) instead of wrapping"
);
assert!(
item <= paragraph + line * 2.,
"{item:?} is much taller than the same text as a paragraph ({paragraph:?})"
);
}
const EVERY_RUN_KIND: &str = concat!(
"# Title\n",
"\n",
"A paragraph.\n",
"\n",
"> A quote.\n",
"\n",
"- An item\n",
"\n",
"```\n",
"let x = 1;\n",
"```\n",
);
fn draw(
cx: &mut VisualTestContext,
build: impl FnOnce() -> Vec<MarkdownElement>,
) -> Vec<RecordedRun> {
recorder::clear();
cx.draw(
point(px(0.), px(0.)),
size(px(800.), px(600.)),
|_window, _cx| -> AnyElement { div().children(build()).into_any_element() },
);
recorder::take()
}
fn doc_and_run(run: &RecordedRun) -> (&str, &str) {
match run.id_segments.as_slice() {
[.., doc, this] => (doc.as_str(), this.as_str()),
other => panic!("a run's id path should have at least two segments: {other:?}"),
}
}
fn id_paths(runs: &[RecordedRun]) -> Vec<String> {
runs.iter().map(|run| run.id_path.clone()).collect()
}
#[gpui::test]
fn a_fence_carries_its_language_without_leaking_it(cx: &mut TestAppContext) {
use pulldown_cmark::CodeBlockKind;
cx.update(crate::theme::init);
cx.update(|cx| {
let mut renderer =
MarkdownRenderer::new(MarkdownStyle::default(), MarkdownSelection::new(), None);
let fence = |info: &'static str| Tag::CodeBlock(CodeBlockKind::Fenced(info.into()));
renderer.handle_start_tag(&fence("rust,ignore"), cx);
assert_eq!(
renderer.code_block_language.as_deref(),
Some("rust"),
"the info string should reach the renderer, normalized"
);
renderer.flush_code_block(cx);
assert_eq!(renderer.code_block_language, None);
for no_language in [
fence(""),
fence("text"),
Tag::CodeBlock(CodeBlockKind::Indented),
] {
renderer.handle_start_tag(&no_language, cx);
assert_eq!(
renderer.code_block_language, None,
"{no_language:?} names no language"
);
}
});
}
#[gpui::test]
fn two_documents_in_one_frame_get_disjoint_run_ids(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let (first, second) = (document(cx, EVERY_RUN_KIND), document(cx, EVERY_RUN_KIND));
let cx = cx.add_empty_window();
let runs = draw(cx, || {
vec![
MarkdownElement::new(first.clone()),
MarkdownElement::new(second.clone()),
]
});
assert_eq!(runs.len(), 10, "five runs per document, twice over");
let paths = id_paths(&runs);
let distinct: HashSet<&String> = paths.iter().collect();
assert_eq!(distinct.len(), 10, "colliding id paths: {paths:?}");
let mut documents = HashSet::new();
for run in &runs {
let (doc, this) = doc_and_run(run);
assert!(doc.starts_with("md-doc-"), "unscoped run: {}", run.id_path);
assert!(this.starts_with("md-run-"), "odd run id: {}", run.id_path);
documents.insert(doc.to_string());
}
assert_eq!(
documents.len(),
2,
"two documents, two scopes: {documents:?}"
);
}
#[gpui::test]
fn a_long_unordered_list_item_wraps(cx: &mut TestAppContext) {
let line = line(cx);
let paragraph = height(cx, LONG);
let item = height(cx, &format!("- {LONG}"));
assert_wrapped_like_a_paragraph(item, paragraph, line);
}
#[gpui::test]
fn a_long_ordered_list_item_wraps(cx: &mut TestAppContext) {
let line = line(cx);
let paragraph = height(cx, LONG);
let item = height(cx, &format!("1. {LONG}"));
assert_wrapped_like_a_paragraph(item, paragraph, line);
}
#[gpui::test]
fn a_long_list_item_with_inline_styles_wraps(cx: &mut TestAppContext) {
let styled = format!("**Bold** and `code` and a [link](#) — {LONG}");
let line = line(cx);
let paragraph = height(cx, &styled);
let item = height(cx, &format!("- {styled}"));
assert_wrapped_like_a_paragraph(item, paragraph, line);
}
#[gpui::test]
fn a_long_nested_list_item_wraps(cx: &mut TestAppContext) {
let line = line(cx);
let paragraph = height(cx, LONG);
let parent = height(cx, "- parent");
let item = height(cx, &format!("- parent\n - {LONG}")) - parent;
assert_wrapped_like_a_paragraph(item, paragraph, line);
}
fn emitted_rows(cx: &mut TestAppContext, source: &str) -> Vec<(ItemMarker, usize, String)> {
cx.update(crate::theme::init);
let events = Markdown::parse(source, false).events;
cx.update(|cx: &mut App| {
let mut renderer =
MarkdownRenderer::new(MarkdownStyle::default(), MarkdownSelection::new(), None);
for event in &events {
renderer.handle_event(&event.event, cx);
}
renderer
.emitted_list_items
.iter()
.map(|row| (row.marker.clone(), row.indent_level, row.text.clone()))
.collect()
})
}
fn list_rows(cx: &mut TestAppContext, source: &str) -> Vec<(String, usize, String)> {
emitted_rows(cx, source)
.into_iter()
.map(|(marker, indent_level, text)| {
let marker = match marker {
ItemMarker::Shown(marker) => marker,
ItemMarker::Hidden(_) => String::new(),
};
(marker, indent_level, text)
})
.collect()
}
fn run_roles(cx: &mut TestAppContext, source: &str) -> Vec<Option<Role>> {
cx.update(crate::theme::init);
let doc = document(cx, source);
let cx = cx.add_empty_window();
draw(cx, || vec![MarkdownElement::new(doc.clone())])
.iter()
.map(|run| run.role)
.collect()
}
#[track_caller]
fn assert_same_row_count(cx: &mut TestAppContext, nested: &str, flat: &str) {
let nested_height = height(cx, nested);
let flat_height = height(cx, flat);
assert_eq!(
nested_height, flat_height,
"{nested:?} laid out to {nested_height:?}, but the same items flat \
({flat:?}) came to {flat_height:?}"
);
}
#[gpui::test]
fn a_nested_list_does_not_swallow_its_parents_text(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- x\n - y"),
vec![
("•".to_string(), 0, "x".to_string()),
("•".to_string(), 1, "y".to_string()),
]
);
}
#[gpui::test]
fn an_ordered_list_nested_in_an_unordered_one(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- x\n 1. y\n 2. z"),
vec![
("•".to_string(), 0, "x".to_string()),
("1.".to_string(), 1, "y".to_string()),
("2.".to_string(), 1, "z".to_string()),
]
);
}
#[gpui::test]
fn an_unordered_list_nested_in_an_ordered_one(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "1. x\n - y"),
vec![
("1.".to_string(), 0, "x".to_string()),
("•".to_string(), 1, "y".to_string()),
]
);
}
#[gpui::test]
fn three_levels_of_nesting_each_keep_their_own_row(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- a\n - b\n - c"),
vec![
("•".to_string(), 0, "a".to_string()),
("•".to_string(), 1, "b".to_string()),
("•".to_string(), 2, "c".to_string()),
]
);
}
#[gpui::test]
fn a_parent_with_inline_styles_keeps_its_own_row(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- **bold** and `code` and a [link](#)\n - child"),
vec![
("•".to_string(), 0, "bold and code and a link".to_string()),
("•".to_string(), 1, "child".to_string()),
]
);
}
#[gpui::test]
fn a_nested_list_does_not_renumber_its_parents_siblings(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "1. x\n - y\n2. z"),
vec![
("1.".to_string(), 0, "x".to_string()),
("•".to_string(), 1, "y".to_string()),
("2.".to_string(), 0, "z".to_string()),
]
);
}
#[gpui::test]
fn nested_task_items_keep_their_own_checkboxes(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- [ ] parent\n - [x] child"),
vec![
("•".to_string(), 0, "☐ parent".to_string()),
("•".to_string(), 1, "☑ child".to_string()),
]
);
}
#[gpui::test]
fn a_parent_with_no_text_of_its_own_emits_no_row(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "-\n - child"),
vec![("•".to_string(), 1, "child".to_string())]
);
}
#[gpui::test]
fn a_list_that_follows_a_paragraph_does_not_absorb_it(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "A paragraph.\n\n- item"),
vec![("•".to_string(), 0, "item".to_string())]
);
}
#[gpui::test]
fn a_nested_list_lays_out_as_many_rows_as_a_flat_one(cx: &mut TestAppContext) {
assert_same_row_count(cx, "- x\n - y", "- x\n- y");
}
#[gpui::test]
fn a_deeply_nested_list_lays_out_as_many_rows_as_a_flat_one(cx: &mut TestAppContext) {
assert_same_row_count(
cx,
"1. a\n - b\n - c\n2. d",
"1. a\n- b\n- c\n2. d",
);
}
#[track_caller]
fn assert_loose_matches_tight(cx: &mut TestAppContext, loose: &str, tight: &str) {
let loose_rows = list_rows(cx, loose);
let tight_rows = list_rows(cx, tight);
assert!(
!tight_rows.is_empty(),
"{tight:?} emitted no rows at all, so this measures nothing"
);
assert_eq!(
loose_rows, tight_rows,
"{loose:?} emitted {loose_rows:?}, but the same items tight ({tight:?}) \
emitted {tight_rows:?}"
);
}
#[gpui::test]
fn a_loose_list_keeps_its_markers(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- one\n\n- two\n"),
vec![
("•".to_string(), 0, "one".to_string()),
("•".to_string(), 0, "two".to_string()),
]
);
}
#[gpui::test]
fn a_loose_ordered_list_keeps_its_numbers(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "1. one\n\n2. two\n\n3. three\n"),
vec![
("1.".to_string(), 0, "one".to_string()),
("2.".to_string(), 0, "two".to_string()),
("3.".to_string(), 0, "three".to_string()),
]
);
}
#[gpui::test]
fn loose_and_tight_lists_emit_the_same_rows(cx: &mut TestAppContext) {
for (loose, tight) in [
("- one\n\n- two\n", "- one\n- two\n"),
("1. one\n\n2. two\n", "1. one\n2. two\n"),
(
"- **bold** and `code` and a [link](#)\n\n- plain\n",
"- **bold** and `code` and a [link](#)\n- plain\n",
),
("- [ ] todo\n\n- [x] done\n", "- [ ] todo\n- [x] done\n"),
("- parent\n\n - child\n", "- parent\n - child\n"),
] {
assert_loose_matches_tight(cx, loose, tight);
}
}
#[gpui::test]
fn a_nested_loose_list_still_indents(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "- parent\n\n - child\n"),
vec![
("•".to_string(), 0, "parent".to_string()),
("•".to_string(), 1, "child".to_string()),
]
);
}
#[gpui::test]
fn a_loose_item_is_announced_as_a_list_item(cx: &mut TestAppContext) {
assert_eq!(
run_roles(cx, "- one\n\n- two\n"),
vec![Some(Role::ListItem), Some(Role::ListItem)],
"a loose list used to be announced as a sequence of paragraphs"
);
}
#[gpui::test]
fn an_item_with_two_blocks_draws_one_marker(cx: &mut TestAppContext) {
assert_eq!(
emitted_rows(cx, "- first block\n\n second block\n"),
vec![
(
ItemMarker::Shown("•".to_string()),
0,
"first block".to_string()
),
(
ItemMarker::Hidden("•".to_string()),
0,
"second block".to_string()
),
]
);
}
#[gpui::test]
fn an_items_second_paragraph_does_not_burn_a_number(cx: &mut TestAppContext) {
assert_eq!(
list_rows(cx, "1. one\n\n still one\n\n2. two\n"),
vec![
("1.".to_string(), 0, "one".to_string()),
(String::new(), 0, "still one".to_string()),
("2.".to_string(), 0, "two".to_string()),
]
);
}
#[gpui::test]
fn a_continuation_block_is_announced_as_a_paragraph(cx: &mut TestAppContext) {
assert_eq!(
run_roles(cx, "- first block\n\n second block\n"),
vec![Some(Role::ListItem), Some(Role::Paragraph)]
);
}
#[gpui::test]
fn a_paragraph_after_a_list_is_still_a_paragraph(cx: &mut TestAppContext) {
assert_eq!(
run_roles(cx, "- one\n\n- two\n\nAfter the list.\n"),
vec![
Some(Role::ListItem),
Some(Role::ListItem),
Some(Role::Paragraph)
]
);
}
#[gpui::test]
fn a_block_after_a_nested_list_belongs_to_the_item_that_held_it(cx: &mut TestAppContext) {
assert_eq!(
emitted_rows(cx, "- parent\n\n - child\n\n after the child\n"),
vec![
(ItemMarker::Shown("•".to_string()), 0, "parent".to_string()),
(ItemMarker::Shown("•".to_string()), 1, "child".to_string()),
(
ItemMarker::Hidden("•".to_string()),
0,
"after the child".to_string()
),
]
);
}
#[gpui::test]
fn a_loose_list_lays_out_like_a_tight_one(cx: &mut TestAppContext) {
assert_eq!(height(cx, "- one\n\n- two\n"), height(cx, "- one\n- two\n"));
}
#[gpui::test]
fn a_continuation_block_starts_in_the_items_text_column(cx: &mut TestAppContext) {
let two_blocks = height(cx, &format!("- {LONG}\n\n {LONG}"));
let two_items = height(cx, &format!("- {LONG}\n- {LONG}"));
assert_eq!(
two_blocks, two_items,
"an item's second block ({two_blocks:?}) did not wrap like a second \
item ({two_items:?})"
);
}
#[gpui::test]
fn a_long_table_cell_wraps(cx: &mut TestAppContext) {
let line = line(cx);
let short = height(cx, "| A | B |\n| --- | --- |\n| one | two |");
let long = height(cx, &format!("| A | B |\n| --- | --- |\n| {LONG} | two |"));
assert!(
long >= short + line,
"the cell stayed one line tall ({long:?} against {short:?}) instead of wrapping"
);
}
#[gpui::test]
fn a_document_keeps_its_run_ids_across_frames(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let doc = document(cx, EVERY_RUN_KIND);
let cx = cx.add_empty_window();
let first = draw(cx, || vec![MarkdownElement::new(doc.clone())]);
let second = draw(cx, || vec![MarkdownElement::new(doc.clone())]);
assert_eq!(id_paths(&first), id_paths(&second));
assert!(!first.is_empty());
}
#[gpui::test]
fn an_explicit_id_separates_two_elements_over_one_entity(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let doc = document(cx, EVERY_RUN_KIND);
let cx = cx.add_empty_window();
let runs = draw(cx, || {
vec![
MarkdownElement::new(doc.clone()).id("left"),
MarkdownElement::new(doc.clone()).id("right"),
]
});
let paths = id_paths(&runs);
let distinct: HashSet<&String> = paths.iter().collect();
assert_eq!(distinct.len(), 10, "colliding id paths: {paths:?}");
let documents: HashSet<&str> = runs.iter().map(|run| doc_and_run(run).0).collect();
assert_eq!(
documents,
HashSet::from(["left", "right"]),
"the override should replace the entity-derived scope"
);
}
fn rendered_text(markdown: &Markdown) -> String {
markdown
.events()
.iter()
.filter_map(|event| match &event.event {
Event::Text(text) | Event::Code(text) => Some(text.to_string()),
_ => None,
})
.collect()
}
fn document(cx: &mut TestAppContext, source: &str) -> Entity<Markdown> {
let source = source.to_string();
cx.new(|cx| Markdown::new(source, cx))
}
fn count_parses(cx: &mut TestAppContext, markdown: &Entity<Markdown>) -> Rc<Cell<usize>> {
let parses = Rc::new(Cell::new(0));
let counter = parses.clone();
cx.update(|cx| {
cx.observe(markdown, move |_, _| counter.set(counter.get() + 1))
.detach()
});
parses
}
#[gpui::test]
fn the_first_parse_is_synchronous(cx: &mut TestAppContext) {
let markdown = document(cx, "# Hello");
markdown.read_with(cx, |markdown, _| {
assert_eq!(rendered_text(markdown), "Hello");
assert_eq!(markdown.parsed_source(), "# Hello");
assert!(!markdown.is_parsing());
});
}
#[gpui::test]
fn append_extends_the_document(cx: &mut TestAppContext) {
let markdown = document(cx, "Hello");
markdown.update(cx, |markdown, cx| markdown.append(", world", cx));
cx.run_until_parked();
markdown.read_with(cx, |markdown, _| {
assert_eq!(markdown.source(), "Hello, world");
assert_eq!(markdown.parsed_source(), "Hello, world");
assert_eq!(rendered_text(markdown), "Hello, world");
});
}
#[gpui::test]
fn appending_nothing_is_inert(cx: &mut TestAppContext) {
let markdown = document(cx, "Hello");
let parses = count_parses(cx, &markdown);
markdown.update(cx, |markdown, cx| markdown.append("", cx));
cx.run_until_parked();
assert_eq!(parses.get(), 0, "an empty delta scheduled a parse");
markdown.read_with(cx, |markdown, _| assert_eq!(markdown.source(), "Hello"));
}
#[gpui::test]
fn the_old_parse_keeps_rendering_until_the_new_one_lands(cx: &mut TestAppContext) {
let markdown = document(cx, "before");
markdown.update(cx, |markdown, cx| markdown.append(" and after", cx));
markdown.read_with(cx, |markdown, _| {
assert!(markdown.is_parsing());
assert_eq!(markdown.source(), "before and after");
assert_eq!(markdown.parsed_source(), "before");
assert_eq!(rendered_text(markdown), "before");
});
cx.run_until_parked();
markdown.read_with(cx, |markdown, _| {
assert!(!markdown.is_parsing());
assert_eq!(rendered_text(markdown), "before and after");
});
}
#[gpui::test]
fn deltas_arriving_during_a_parse_coalesce(cx: &mut TestAppContext) {
let markdown = document(cx, "one");
let parses = count_parses(cx, &markdown);
markdown.update(cx, |markdown, cx| {
for delta in [" two", " three", " four", " five", " six"] {
markdown.append(delta, cx);
}
});
cx.run_until_parked();
assert_eq!(
parses.get(),
2,
"five deltas during one parse should cost one extra parse, not four"
);
markdown.read_with(cx, |markdown, _| {
assert_eq!(rendered_text(markdown), "one two three four five six")
});
}
#[gpui::test]
fn a_long_stream_lands_on_the_final_source(cx: &mut TestAppContext) {
let markdown = document(cx, "");
let mut expected = String::new();
for i in 0..200 {
let delta = format!("{i} ");
expected.push_str(&delta);
markdown.update(cx, |markdown, cx| markdown.append(&delta, cx));
if i % 7 == 0 {
cx.run_until_parked();
}
}
cx.run_until_parked();
markdown.read_with(cx, |markdown, _| {
assert_eq!(markdown.source(), expected);
assert_eq!(markdown.parsed_source(), expected);
assert!(!markdown.is_parsing());
assert_eq!(rendered_text(markdown), expected.trim_end());
});
}
#[gpui::test]
fn setting_the_same_source_does_not_reparse(cx: &mut TestAppContext) {
let markdown = document(cx, "# Same");
let parses = count_parses(cx, &markdown);
markdown.update(cx, |markdown, cx| markdown.set_source("# Same", cx));
cx.run_until_parked();
assert_eq!(parses.get(), 0, "an unchanged source scheduled a parse");
markdown.update(cx, |markdown, cx| markdown.set_source("# Different", cx));
cx.run_until_parked();
assert_eq!(parses.get(), 1);
}
#[gpui::test]
fn append_keeps_the_selection(cx: &mut TestAppContext) {
let markdown = document(cx, "First block\n\nSecond block");
let selection = markdown.read_with(cx, |markdown, _| markdown.selection());
selection.select_in_run(0, 0..5);
markdown.update(cx, |markdown, cx| markdown.append("\n\nThird block", cx));
cx.run_until_parked();
assert!(!selection.is_empty(), "append dropped the selection");
let (start, end) = selection.range().expect("the selection went away");
assert_eq!((start.run, start.offset), (0, 0));
assert_eq!((end.run, end.offset), (0, 5));
}
#[gpui::test]
fn set_source_drops_the_selection(cx: &mut TestAppContext) {
let markdown = document(cx, "First block\n\nSecond block");
let selection = markdown.read_with(cx, |markdown, _| markdown.selection());
selection.select_in_run(0, 0..5);
markdown.update(cx, |markdown, cx| markdown.set_source("Something else", cx));
assert!(
selection.is_empty(),
"the selection survived a source it no longer indexes"
);
}
#[gpui::test]
fn the_default_document_id_follows_the_entity(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let doc = document(cx, "Hello.");
let expected = document_element_id(doc.entity_id());
assert_eq!(MarkdownElement::new(doc.clone()).element_id(), expected);
assert_eq!(
MarkdownElement::new(doc).id("mine").element_id(),
ElementId::Name("mine".into())
);
}
#[gpui::test]
fn every_run_kind_reports_a_role_and_its_text(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let doc = document(cx, EVERY_RUN_KIND);
let cx = cx.add_empty_window();
let runs = draw(cx, || vec![MarkdownElement::new(doc.clone())]);
let reported: Vec<(Option<Role>, Option<&str>, Option<usize>)> = runs
.iter()
.map(|run| (run.role, run.label.as_deref(), run.level))
.collect();
assert_eq!(
reported,
vec![
(Some(Role::Heading), Some("Title"), Some(1)),
(Some(Role::Paragraph), Some("A paragraph."), None),
(Some(Role::Blockquote), Some("A quote."), None),
(Some(Role::ListItem), Some("An item"), None),
(Some(Role::Code), Some("let x = 1;\n"), None),
]
);
}
#[gpui::test]
fn headings_report_their_level(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let doc = document(cx, "# One\n\n### Three\n");
let cx = cx.add_empty_window();
let runs = draw(cx, || vec![MarkdownElement::new(doc.clone())]);
let levels: Vec<_> = runs.iter().map(|run| run.level).collect();
assert_eq!(levels, vec![Some(1), Some(3)]);
}
#[test]
fn heading_levels_are_numbered_one_through_six() {
let levels: Vec<u8> = [
HeadingLevel::H1,
HeadingLevel::H2,
HeadingLevel::H3,
HeadingLevel::H4,
HeadingLevel::H5,
HeadingLevel::H6,
]
.into_iter()
.map(HeadingLevel::level)
.collect();
assert_eq!(levels, vec![1, 2, 3, 4, 5, 6]);
}
#[gpui::test]
fn dropping_the_document_mid_parse_is_harmless(cx: &mut TestAppContext) {
let markdown = document(cx, "start");
markdown.update(cx, |markdown, cx| markdown.append(" more", cx));
drop(markdown);
cx.run_until_parked();
}
#[cfg(feature = "stitch")]
#[gpui::test]
fn partial_emphasis_renders_as_emphasis(cx: &mut TestAppContext) {
let markdown = document(cx, "A **partially written");
markdown.read_with(cx, |markdown, _| {
assert_eq!(rendered_text(markdown), "A partially written");
assert!(
markdown
.events()
.iter()
.any(|event| matches!(event.event, Event::Start(Tag::Strong)))
);
});
}
#[gpui::test]
#[cfg(feature = "stitch")]
fn a_partial_link_is_not_a_link_yet(cx: &mut TestAppContext) {
let markdown = document(cx, "See [the docs](htt");
markdown.read_with(cx, |markdown, _| {
assert_eq!(rendered_text(markdown), "See the docs");
assert!(
!markdown
.events()
.iter()
.any(|event| matches!(event.event, Event::Start(Tag::Link { .. }))),
"an incomplete URL became a clickable link"
);
});
}
#[cfg(feature = "stitch")]
#[gpui::test]
fn a_complete_document_parses_the_same_either_way(cx: &mut TestAppContext) {
let source = "# Title\n\nA **bold** word, `code`, and a [link](https://example.com).\n";
let markdown = document(cx, source);
let with_preprocessing = markdown.read_with(cx, |markdown, _| rendered_text(markdown));
markdown.update(cx, |markdown, cx| {
markdown.set_preprocess_partial(false, cx)
});
cx.run_until_parked();
markdown.read_with(cx, |markdown, _| {
assert_eq!(rendered_text(markdown), with_preprocessing)
});
}
#[cfg(feature = "stitch")]
#[gpui::test]
fn preprocessing_can_be_turned_off(cx: &mut TestAppContext) {
let markdown = document(cx, "A **partially written");
markdown.update(cx, |markdown, cx| {
assert!(markdown.preprocess_partial());
markdown.set_preprocess_partial(false, cx);
});
cx.run_until_parked();
markdown.read_with(cx, |markdown, _| {
assert!(!markdown.preprocess_partial());
assert_eq!(rendered_text(markdown), "A **partially written");
});
}
fn emitted_code_block_languages(cx: &mut TestAppContext, source: &str) -> Vec<Option<String>> {
cx.update(crate::theme::init);
let parsed = Markdown::parse(source, false);
cx.update(|cx: &mut App| {
let mut renderer = MarkdownRenderer::new(
MarkdownStyle::default(),
MarkdownSelection::new(),
parsed.unclosed_code_block,
);
for event in &parsed.events {
renderer.handle_event(&event.event, cx);
}
renderer.emitted_code_blocks.clone()
})
}
#[gpui::test]
fn an_unclosed_fence_is_drawn_plain(cx: &mut TestAppContext) {
assert_eq!(
emitted_code_block_languages(cx, "```rust\nfn main() {\n"),
vec![None],
"a fence still arriving must not be highlighted"
);
}
#[gpui::test]
fn a_closed_fence_keeps_its_language(cx: &mut TestAppContext) {
assert_eq!(
emitted_code_block_languages(cx, "```rust\nfn main() {}\n```\n"),
vec![Some("rust".to_string())],
"a settled block highlights, once, forever"
);
}
#[gpui::test]
fn only_the_open_block_loses_its_language(cx: &mut TestAppContext) {
let source = "```rust\nfn a() {}\n```\n\nThen:\n\n```python\ndef b():\n";
assert_eq!(
emitted_code_block_languages(cx, source),
vec![Some("rust".to_string()), None],
);
}
#[gpui::test]
fn an_open_inline_marker_does_not_disturb_a_settled_fence(cx: &mut TestAppContext) {
let source = "```rust\nfn a() {}\n```\n\n**bold";
assert_eq!(
emitted_code_block_languages(cx, source),
vec![Some("rust".to_string())],
"the block closed; what happens after it is not its business"
);
}
#[gpui::test]
fn a_streamed_fence_highlights_when_it_finishes(cx: &mut TestAppContext) {
let deltas = [
"Here you go:\n\n",
"```ru",
"st\n",
"fn main() {\n",
" println!(\"hi\");\n",
"}\n",
"``",
"`\n",
"\nDone.",
];
let markdown = document(cx, "");
for (index, delta) in deltas.iter().enumerate() {
markdown.update(cx, |markdown, cx| markdown.append(delta, cx));
cx.run_until_parked();
let source = markdown.read_with(cx, |markdown, _| markdown.source().to_string());
let languages = emitted_code_block_languages(cx, &source);
let closed = index >= 7;
if index < 1 {
assert!(languages.is_empty(), "no code block yet: {source:?}");
} else if closed {
assert_eq!(
languages,
vec![Some("rust".to_string())],
"after delta {index} the fence has closed: {source:?}"
);
} else {
assert_eq!(
languages,
vec![None],
"after delta {index} the fence is still open: {source:?}"
);
}
}
}
}