
Release Notes: - Added support for toggling a checkbox in markdown preview by clicking on it (cmd+click) ([#5226](https://github.com/zed-industries/zed/issues/5226)). --------- Co-authored-by: Remco Smits <62463826+RemcoSmitsDev@users.noreply.github.com>
1352 lines
42 KiB
Rust
1352 lines
42 KiB
Rust
use crate::markdown_elements::*;
|
|
use async_recursion::async_recursion;
|
|
use gpui::FontWeight;
|
|
use language::LanguageRegistry;
|
|
use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd};
|
|
use std::{ops::Range, path::PathBuf, sync::Arc};
|
|
|
|
pub async fn parse_markdown(
|
|
markdown_input: &str,
|
|
file_location_directory: Option<PathBuf>,
|
|
language_registry: Option<Arc<LanguageRegistry>>,
|
|
) -> ParsedMarkdown {
|
|
let options = Options::all();
|
|
let parser = Parser::new_ext(markdown_input, options);
|
|
let parser = MarkdownParser::new(
|
|
parser.into_offset_iter().collect(),
|
|
file_location_directory,
|
|
language_registry,
|
|
);
|
|
let renderer = parser.parse_document().await;
|
|
ParsedMarkdown {
|
|
children: renderer.parsed,
|
|
}
|
|
}
|
|
|
|
struct MarkdownParser<'a> {
|
|
tokens: Vec<(Event<'a>, Range<usize>)>,
|
|
/// The current index in the tokens array
|
|
cursor: usize,
|
|
/// The blocks that we have successfully parsed so far
|
|
parsed: Vec<ParsedMarkdownElement>,
|
|
file_location_directory: Option<PathBuf>,
|
|
language_registry: Option<Arc<LanguageRegistry>>,
|
|
}
|
|
|
|
impl<'a> MarkdownParser<'a> {
|
|
fn new(
|
|
tokens: Vec<(Event<'a>, Range<usize>)>,
|
|
file_location_directory: Option<PathBuf>,
|
|
language_registry: Option<Arc<LanguageRegistry>>,
|
|
) -> Self {
|
|
Self {
|
|
tokens,
|
|
file_location_directory,
|
|
language_registry,
|
|
cursor: 0,
|
|
parsed: vec![],
|
|
}
|
|
}
|
|
|
|
fn eof(&self) -> bool {
|
|
if self.tokens.is_empty() {
|
|
return true;
|
|
}
|
|
self.cursor >= self.tokens.len() - 1
|
|
}
|
|
|
|
fn peek(&self, steps: usize) -> Option<&(Event, Range<usize>)> {
|
|
if self.eof() || (steps + self.cursor) >= self.tokens.len() {
|
|
return self.tokens.last();
|
|
}
|
|
return self.tokens.get(self.cursor + steps);
|
|
}
|
|
|
|
fn previous(&self) -> Option<&(Event, Range<usize>)> {
|
|
if self.cursor == 0 || self.cursor > self.tokens.len() {
|
|
return None;
|
|
}
|
|
return self.tokens.get(self.cursor - 1);
|
|
}
|
|
|
|
fn current(&self) -> Option<&(Event, Range<usize>)> {
|
|
return self.peek(0);
|
|
}
|
|
|
|
fn current_event(&self) -> Option<&Event> {
|
|
return self.current().map(|(event, _)| event);
|
|
}
|
|
|
|
fn is_text_like(event: &Event) -> bool {
|
|
match event {
|
|
Event::Text(_)
|
|
// Represent an inline code block
|
|
| Event::Code(_)
|
|
| Event::Html(_)
|
|
| Event::FootnoteReference(_)
|
|
| Event::Start(Tag::Link { link_type: _, dest_url: _, title: _, id: _ })
|
|
| Event::Start(Tag::Emphasis)
|
|
| Event::Start(Tag::Strong)
|
|
| Event::Start(Tag::Strikethrough)
|
|
| Event::Start(Tag::Image { link_type: _, dest_url: _, title: _, id: _ }) => {
|
|
return true;
|
|
}
|
|
_ => return false,
|
|
}
|
|
}
|
|
|
|
async fn parse_document(mut self) -> Self {
|
|
while !self.eof() {
|
|
if let Some(block) = self.parse_block().await {
|
|
self.parsed.push(block);
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
async fn parse_block(&mut self) -> Option<ParsedMarkdownElement> {
|
|
let (current, source_range) = self.current().unwrap();
|
|
match current {
|
|
Event::Start(tag) => match tag {
|
|
Tag::Paragraph => {
|
|
self.cursor += 1;
|
|
let text = self.parse_text(false);
|
|
Some(ParsedMarkdownElement::Paragraph(text))
|
|
}
|
|
Tag::Heading {
|
|
level,
|
|
id: _,
|
|
classes: _,
|
|
attrs: _,
|
|
} => {
|
|
let level = *level;
|
|
self.cursor += 1;
|
|
let heading = self.parse_heading(level);
|
|
Some(ParsedMarkdownElement::Heading(heading))
|
|
}
|
|
Tag::Table(alignment) => {
|
|
let alignment = alignment.clone();
|
|
self.cursor += 1;
|
|
let table = self.parse_table(alignment);
|
|
Some(ParsedMarkdownElement::Table(table))
|
|
}
|
|
Tag::List(order) => {
|
|
let order = *order;
|
|
self.cursor += 1;
|
|
let list = self.parse_list(1, order).await;
|
|
Some(ParsedMarkdownElement::List(list))
|
|
}
|
|
Tag::BlockQuote => {
|
|
self.cursor += 1;
|
|
let block_quote = self.parse_block_quote().await;
|
|
Some(ParsedMarkdownElement::BlockQuote(block_quote))
|
|
}
|
|
Tag::CodeBlock(kind) => {
|
|
let language = match kind {
|
|
pulldown_cmark::CodeBlockKind::Indented => None,
|
|
pulldown_cmark::CodeBlockKind::Fenced(language) => {
|
|
if language.is_empty() {
|
|
None
|
|
} else {
|
|
Some(language.to_string())
|
|
}
|
|
}
|
|
};
|
|
|
|
self.cursor += 1;
|
|
|
|
let code_block = self.parse_code_block(language).await;
|
|
Some(ParsedMarkdownElement::CodeBlock(code_block))
|
|
}
|
|
_ => {
|
|
self.cursor += 1;
|
|
None
|
|
}
|
|
},
|
|
Event::Rule => {
|
|
let source_range = source_range.clone();
|
|
self.cursor += 1;
|
|
Some(ParsedMarkdownElement::HorizontalRule(source_range))
|
|
}
|
|
_ => {
|
|
self.cursor += 1;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_text(&mut self, should_complete_on_soft_break: bool) -> ParsedMarkdownText {
|
|
let (_current, source_range) = self.previous().unwrap();
|
|
let source_range = source_range.clone();
|
|
|
|
let mut text = String::new();
|
|
let mut bold_depth = 0;
|
|
let mut italic_depth = 0;
|
|
let mut strikethrough_depth = 0;
|
|
let mut link: Option<Link> = None;
|
|
let mut region_ranges: Vec<Range<usize>> = vec![];
|
|
let mut regions: Vec<ParsedRegion> = vec![];
|
|
let mut highlights: Vec<(Range<usize>, MarkdownHighlight)> = vec![];
|
|
|
|
let mut link_urls: Vec<String> = vec![];
|
|
let mut link_ranges: Vec<Range<usize>> = vec![];
|
|
|
|
loop {
|
|
if self.eof() {
|
|
break;
|
|
}
|
|
|
|
let (current, _source_range) = self.current().unwrap();
|
|
let prev_len = text.len();
|
|
match current {
|
|
Event::SoftBreak => {
|
|
if should_complete_on_soft_break {
|
|
break;
|
|
}
|
|
|
|
// `Some text\nSome more text` should be treated as a single line.
|
|
text.push(' ');
|
|
}
|
|
|
|
Event::HardBreak => {
|
|
text.push('\n');
|
|
}
|
|
|
|
Event::Text(t) => {
|
|
text.push_str(t.as_ref());
|
|
|
|
let mut style = MarkdownHighlightStyle::default();
|
|
|
|
if bold_depth > 0 {
|
|
style.weight = FontWeight::BOLD;
|
|
}
|
|
|
|
if italic_depth > 0 {
|
|
style.italic = true;
|
|
}
|
|
|
|
if strikethrough_depth > 0 {
|
|
style.strikethrough = true;
|
|
}
|
|
|
|
let last_run_len = if let Some(link) = link.clone() {
|
|
region_ranges.push(prev_len..text.len());
|
|
regions.push(ParsedRegion {
|
|
code: false,
|
|
link: Some(link),
|
|
});
|
|
style.underline = true;
|
|
prev_len
|
|
} else {
|
|
// Manually scan for links
|
|
let mut finder = linkify::LinkFinder::new();
|
|
finder.kinds(&[linkify::LinkKind::Url]);
|
|
let mut last_link_len = prev_len;
|
|
for link in finder.links(&t) {
|
|
let start = link.start();
|
|
let end = link.end();
|
|
let range = (prev_len + start)..(prev_len + end);
|
|
link_ranges.push(range.clone());
|
|
link_urls.push(link.as_str().to_string());
|
|
|
|
// If there is a style before we match a link, we have to add this to the highlighted ranges
|
|
if style != MarkdownHighlightStyle::default()
|
|
&& last_link_len < link.start()
|
|
{
|
|
highlights.push((
|
|
last_link_len..link.start(),
|
|
MarkdownHighlight::Style(style.clone()),
|
|
));
|
|
}
|
|
|
|
highlights.push((
|
|
range.clone(),
|
|
MarkdownHighlight::Style(MarkdownHighlightStyle {
|
|
underline: true,
|
|
..style
|
|
}),
|
|
));
|
|
region_ranges.push(range.clone());
|
|
regions.push(ParsedRegion {
|
|
code: false,
|
|
link: Some(Link::Web {
|
|
url: link.as_str().to_string(),
|
|
}),
|
|
});
|
|
|
|
last_link_len = end;
|
|
}
|
|
last_link_len
|
|
};
|
|
|
|
if style != MarkdownHighlightStyle::default() && last_run_len < text.len() {
|
|
let mut new_highlight = true;
|
|
if let Some((last_range, last_style)) = highlights.last_mut() {
|
|
if last_range.end == last_run_len
|
|
&& last_style == &MarkdownHighlight::Style(style.clone())
|
|
{
|
|
last_range.end = text.len();
|
|
new_highlight = false;
|
|
}
|
|
}
|
|
if new_highlight {
|
|
highlights
|
|
.push((last_run_len..text.len(), MarkdownHighlight::Style(style)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Note: This event means "inline code" and not "code block"
|
|
Event::Code(t) => {
|
|
text.push_str(t.as_ref());
|
|
region_ranges.push(prev_len..text.len());
|
|
|
|
if link.is_some() {
|
|
highlights.push((
|
|
prev_len..text.len(),
|
|
MarkdownHighlight::Style(MarkdownHighlightStyle {
|
|
underline: true,
|
|
..Default::default()
|
|
}),
|
|
));
|
|
}
|
|
|
|
regions.push(ParsedRegion {
|
|
code: true,
|
|
link: link.clone(),
|
|
});
|
|
}
|
|
|
|
Event::Start(tag) => match tag {
|
|
Tag::Emphasis => italic_depth += 1,
|
|
Tag::Strong => bold_depth += 1,
|
|
Tag::Strikethrough => strikethrough_depth += 1,
|
|
Tag::Link {
|
|
link_type: _,
|
|
dest_url,
|
|
title: _,
|
|
id: _,
|
|
} => {
|
|
link = Link::identify(
|
|
self.file_location_directory.clone(),
|
|
dest_url.to_string(),
|
|
);
|
|
}
|
|
_ => {
|
|
break;
|
|
}
|
|
},
|
|
|
|
Event::End(tag) => match tag {
|
|
TagEnd::Emphasis => {
|
|
italic_depth -= 1;
|
|
}
|
|
TagEnd::Strong => {
|
|
bold_depth -= 1;
|
|
}
|
|
TagEnd::Strikethrough => {
|
|
strikethrough_depth -= 1;
|
|
}
|
|
TagEnd::Link => {
|
|
link = None;
|
|
}
|
|
TagEnd::Paragraph => {
|
|
self.cursor += 1;
|
|
break;
|
|
}
|
|
_ => {
|
|
break;
|
|
}
|
|
},
|
|
|
|
_ => {
|
|
break;
|
|
}
|
|
}
|
|
|
|
self.cursor += 1;
|
|
}
|
|
|
|
ParsedMarkdownText {
|
|
source_range,
|
|
contents: text,
|
|
highlights,
|
|
regions,
|
|
region_ranges,
|
|
}
|
|
}
|
|
|
|
fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading {
|
|
let (_event, source_range) = self.previous().unwrap();
|
|
let source_range = source_range.clone();
|
|
let text = self.parse_text(true);
|
|
|
|
// Advance past the heading end tag
|
|
self.cursor += 1;
|
|
|
|
ParsedMarkdownHeading {
|
|
source_range: source_range.clone(),
|
|
level: match level {
|
|
pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
|
|
pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
|
|
pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
|
|
pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
|
|
pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
|
|
pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
|
|
},
|
|
contents: text,
|
|
}
|
|
}
|
|
|
|
fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
|
|
let (_event, source_range) = self.previous().unwrap();
|
|
let source_range = source_range.clone();
|
|
let mut header = ParsedMarkdownTableRow::new();
|
|
let mut body = vec![];
|
|
let mut current_row = vec![];
|
|
let mut in_header = true;
|
|
let column_alignments = alignment
|
|
.iter()
|
|
.map(|a| Self::convert_alignment(a))
|
|
.collect();
|
|
|
|
loop {
|
|
if self.eof() {
|
|
break;
|
|
}
|
|
|
|
let (current, _source_range) = self.current().unwrap();
|
|
match current {
|
|
Event::Start(Tag::TableHead)
|
|
| Event::Start(Tag::TableRow)
|
|
| Event::End(TagEnd::TableCell) => {
|
|
self.cursor += 1;
|
|
}
|
|
Event::Start(Tag::TableCell) => {
|
|
self.cursor += 1;
|
|
let cell_contents = self.parse_text(false);
|
|
current_row.push(cell_contents);
|
|
}
|
|
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
|
|
self.cursor += 1;
|
|
let new_row = std::mem::replace(&mut current_row, vec![]);
|
|
if in_header {
|
|
header.children = new_row;
|
|
in_header = false;
|
|
} else {
|
|
let row = ParsedMarkdownTableRow::with_children(new_row);
|
|
body.push(row);
|
|
}
|
|
}
|
|
Event::End(TagEnd::Table) => {
|
|
self.cursor += 1;
|
|
break;
|
|
}
|
|
_ => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
ParsedMarkdownTable {
|
|
source_range,
|
|
header,
|
|
body,
|
|
column_alignments,
|
|
}
|
|
}
|
|
|
|
fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment {
|
|
match alignment {
|
|
Alignment::None => ParsedMarkdownTableAlignment::None,
|
|
Alignment::Left => ParsedMarkdownTableAlignment::Left,
|
|
Alignment::Center => ParsedMarkdownTableAlignment::Center,
|
|
Alignment::Right => ParsedMarkdownTableAlignment::Right,
|
|
}
|
|
}
|
|
|
|
#[async_recursion]
|
|
async fn parse_list(&mut self, depth: u16, order: Option<u64>) -> ParsedMarkdownList {
|
|
let (_event, source_range) = self.previous().unwrap();
|
|
let source_range = source_range.clone();
|
|
let mut children = vec![];
|
|
let mut inside_list_item = false;
|
|
let mut order = order;
|
|
let mut task_item = None;
|
|
|
|
let mut current_list_items: Vec<Box<ParsedMarkdownElement>> = vec![];
|
|
|
|
while !self.eof() {
|
|
let (current, _source_range) = self.current().unwrap();
|
|
match current {
|
|
Event::Start(Tag::List(order)) => {
|
|
let order = *order;
|
|
self.cursor += 1;
|
|
|
|
let inner_list = self.parse_list(depth + 1, order).await;
|
|
let block = ParsedMarkdownElement::List(inner_list);
|
|
current_list_items.push(Box::new(block));
|
|
}
|
|
Event::End(TagEnd::List(_)) => {
|
|
self.cursor += 1;
|
|
break;
|
|
}
|
|
Event::Start(Tag::Item) => {
|
|
self.cursor += 1;
|
|
inside_list_item = true;
|
|
|
|
// Check for task list marker (`- [ ]` or `- [x]`)
|
|
if let Some(event) = self.current_event() {
|
|
// If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
|
|
if event == &Event::Start(Tag::Paragraph) {
|
|
self.cursor += 1;
|
|
}
|
|
|
|
if let Some((Event::TaskListMarker(checked), range)) = self.current() {
|
|
task_item = Some((*checked, range.clone()));
|
|
self.cursor += 1;
|
|
}
|
|
}
|
|
|
|
if let Some(event) = self.current_event() {
|
|
// This is a plain list item.
|
|
// For example `- some text` or `1. [Docs](./docs.md)`
|
|
if MarkdownParser::is_text_like(event) {
|
|
let text = self.parse_text(false);
|
|
let block = ParsedMarkdownElement::Paragraph(text);
|
|
current_list_items.push(Box::new(block));
|
|
} else {
|
|
let block = self.parse_block().await;
|
|
if let Some(block) = block {
|
|
current_list_items.push(Box::new(block));
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph
|
|
if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) {
|
|
self.cursor += 1;
|
|
}
|
|
}
|
|
Event::End(TagEnd::Item) => {
|
|
self.cursor += 1;
|
|
|
|
let item_type = if let Some((checked, range)) = task_item {
|
|
ParsedMarkdownListItemType::Task(checked, range)
|
|
} else if let Some(order) = order {
|
|
ParsedMarkdownListItemType::Ordered(order)
|
|
} else {
|
|
ParsedMarkdownListItemType::Unordered
|
|
};
|
|
|
|
if let Some(current) = order {
|
|
order = Some(current + 1);
|
|
}
|
|
|
|
let contents = std::mem::replace(&mut current_list_items, vec![]);
|
|
|
|
children.push(ParsedMarkdownListItem {
|
|
contents,
|
|
depth,
|
|
item_type,
|
|
});
|
|
|
|
inside_list_item = false;
|
|
task_item = None;
|
|
}
|
|
_ => {
|
|
if !inside_list_item {
|
|
break;
|
|
}
|
|
|
|
let block = self.parse_block().await;
|
|
if let Some(block) = block {
|
|
current_list_items.push(Box::new(block));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ParsedMarkdownList {
|
|
source_range,
|
|
children,
|
|
}
|
|
}
|
|
|
|
#[async_recursion]
|
|
async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote {
|
|
let (_event, source_range) = self.previous().unwrap();
|
|
let source_range = source_range.clone();
|
|
let mut nested_depth = 1;
|
|
|
|
let mut children: Vec<Box<ParsedMarkdownElement>> = vec![];
|
|
|
|
while !self.eof() {
|
|
let block = self.parse_block().await;
|
|
|
|
if let Some(block) = block {
|
|
children.push(Box::new(block));
|
|
} else {
|
|
break;
|
|
}
|
|
|
|
if self.eof() {
|
|
break;
|
|
}
|
|
|
|
let (current, _source_range) = self.current().unwrap();
|
|
match current {
|
|
// This is a nested block quote.
|
|
// Record that we're in a nested block quote and continue parsing.
|
|
// We don't need to advance the cursor since the next
|
|
// call to `parse_block` will handle it.
|
|
Event::Start(Tag::BlockQuote) => {
|
|
nested_depth += 1;
|
|
}
|
|
Event::End(TagEnd::BlockQuote) => {
|
|
nested_depth -= 1;
|
|
if nested_depth == 0 {
|
|
self.cursor += 1;
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
};
|
|
}
|
|
|
|
ParsedMarkdownBlockQuote {
|
|
source_range,
|
|
children,
|
|
}
|
|
}
|
|
|
|
async fn parse_code_block(&mut self, language: Option<String>) -> ParsedMarkdownCodeBlock {
|
|
let (_event, source_range) = self.previous().unwrap();
|
|
let source_range = source_range.clone();
|
|
let mut code = String::new();
|
|
|
|
while !self.eof() {
|
|
let (current, _source_range) = self.current().unwrap();
|
|
match current {
|
|
Event::Text(text) => {
|
|
code.push_str(&text);
|
|
self.cursor += 1;
|
|
}
|
|
Event::End(TagEnd::CodeBlock) => {
|
|
self.cursor += 1;
|
|
break;
|
|
}
|
|
_ => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let highlights = if let Some(language) = &language {
|
|
if let Some(registry) = &self.language_registry {
|
|
let rope: language::Rope = code.as_str().into();
|
|
registry
|
|
.language_for_name_or_extension(language)
|
|
.await
|
|
.map(|l| l.highlight_text(&rope, 0..code.len()))
|
|
.ok()
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
ParsedMarkdownCodeBlock {
|
|
source_range,
|
|
contents: code.trim().to_string().into(),
|
|
language,
|
|
highlights,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use gpui::BackgroundExecutor;
|
|
use language::{tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher};
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use ParsedMarkdownElement::*;
|
|
use ParsedMarkdownListItemType::*;
|
|
|
|
async fn parse(input: &str) -> ParsedMarkdown {
|
|
parse_markdown(input, None, None).await
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_headings() {
|
|
let parsed = parse("# Heading one\n## Heading two\n### Heading three").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![
|
|
h1(text("Heading one", 0..14), 0..14),
|
|
h2(text("Heading two", 14..29), 14..29),
|
|
h3(text("Heading three", 29..46), 29..46),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_newlines_dont_new_paragraphs() {
|
|
let parsed = parse("Some text **that is bolded**\n and *italicized*").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![p("Some text that is bolded and italicized", 0..46)]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_heading_with_paragraph() {
|
|
let parsed = parse("# Zed\nThe editor").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![h1(text("Zed", 0..6), 0..6), p("The editor", 6..16),]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_double_newlines_do_new_paragraphs() {
|
|
let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![
|
|
p("Some text that is bolded", 0..29),
|
|
p("and italicized", 31..47),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_bold_italic_text() {
|
|
let parsed = parse("Some text **that is bolded** and *italicized*").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![p("Some text that is bolded and italicized", 0..45)]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_nested_bold_strikethrough_text() {
|
|
let parsed = parse("Some **bo~~strikethrough~~ld** text").await;
|
|
|
|
assert_eq!(parsed.children.len(), 1);
|
|
assert_eq!(
|
|
parsed.children[0],
|
|
ParsedMarkdownElement::Paragraph(ParsedMarkdownText {
|
|
source_range: 0..35,
|
|
contents: "Some bostrikethroughld text".to_string(),
|
|
highlights: Vec::new(),
|
|
region_ranges: Vec::new(),
|
|
regions: Vec::new(),
|
|
})
|
|
);
|
|
|
|
let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
|
|
text
|
|
} else {
|
|
panic!("Expected a paragraph");
|
|
};
|
|
assert_eq!(
|
|
paragraph.highlights,
|
|
vec![
|
|
(
|
|
5..7,
|
|
MarkdownHighlight::Style(MarkdownHighlightStyle {
|
|
weight: FontWeight::BOLD,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
(
|
|
7..20,
|
|
MarkdownHighlight::Style(MarkdownHighlightStyle {
|
|
weight: FontWeight::BOLD,
|
|
strikethrough: true,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
(
|
|
20..22,
|
|
MarkdownHighlight::Style(MarkdownHighlightStyle {
|
|
weight: FontWeight::BOLD,
|
|
..Default::default()
|
|
}),
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_raw_links_detection() {
|
|
let parsed = parse("Checkout this https://zed.dev link").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![p("Checkout this https://zed.dev link", 0..34)]
|
|
);
|
|
|
|
let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] {
|
|
text
|
|
} else {
|
|
panic!("Expected a paragraph");
|
|
};
|
|
assert_eq!(
|
|
paragraph.highlights,
|
|
vec![(
|
|
14..29,
|
|
MarkdownHighlight::Style(MarkdownHighlightStyle {
|
|
underline: true,
|
|
..Default::default()
|
|
}),
|
|
)]
|
|
);
|
|
assert_eq!(
|
|
paragraph.regions,
|
|
vec![ParsedRegion {
|
|
code: false,
|
|
link: Some(Link::Web {
|
|
url: "https://zed.dev".to_string()
|
|
}),
|
|
}]
|
|
);
|
|
assert_eq!(paragraph.region_ranges, vec![14..29]);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_header_only_table() {
|
|
let markdown = "\
|
|
| Header 1 | Header 2 |
|
|
|----------|----------|
|
|
|
|
Some other content
|
|
";
|
|
|
|
let expected_table = table(
|
|
0..48,
|
|
row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
|
|
vec![],
|
|
);
|
|
|
|
assert_eq!(
|
|
parse(markdown).await.children[0],
|
|
ParsedMarkdownElement::Table(expected_table)
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_basic_table() {
|
|
let markdown = "\
|
|
| Header 1 | Header 2 |
|
|
|----------|----------|
|
|
| Cell 1 | Cell 2 |
|
|
| Cell 3 | Cell 4 |";
|
|
|
|
let expected_table = table(
|
|
0..95,
|
|
row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
|
|
vec![
|
|
row(vec![text("Cell 1", 49..59), text("Cell 2", 60..70)]),
|
|
row(vec![text("Cell 3", 73..83), text("Cell 4", 84..94)]),
|
|
],
|
|
);
|
|
|
|
assert_eq!(
|
|
parse(markdown).await.children[0],
|
|
ParsedMarkdownElement::Table(expected_table)
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_basic() {
|
|
let parsed = parse(
|
|
"\
|
|
* Item 1
|
|
* Item 2
|
|
* Item 3
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![list(
|
|
vec![
|
|
list_item(1, Unordered, vec![p("Item 1", 0..9)]),
|
|
list_item(1, Unordered, vec![p("Item 2", 9..18)]),
|
|
list_item(1, Unordered, vec![p("Item 3", 18..27)]),
|
|
],
|
|
0..27
|
|
),]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_with_tasks() {
|
|
let parsed = parse(
|
|
"\
|
|
- [ ] TODO
|
|
- [x] Checked
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![list(
|
|
vec![
|
|
list_item(1, Task(false, 2..5), vec![p("TODO", 2..5)]),
|
|
list_item(1, Task(true, 13..16), vec![p("Checked", 13..16)]),
|
|
],
|
|
0..25
|
|
),]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_with_linebreak_is_handled_correctly() {
|
|
let parsed = parse(
|
|
"\
|
|
- [ ] Task 1
|
|
|
|
- [x] Task 2
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![list(
|
|
vec![
|
|
list_item(1, Task(false, 2..5), vec![p("Task 1", 2..5)]),
|
|
list_item(1, Task(true, 16..19), vec![p("Task 2", 16..19)]),
|
|
],
|
|
0..27
|
|
),]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_nested() {
|
|
let parsed = parse(
|
|
"\
|
|
* Item 1
|
|
* Item 2
|
|
* Item 3
|
|
|
|
1. Hello
|
|
1. Two
|
|
1. Three
|
|
2. Four
|
|
3. Five
|
|
|
|
* First
|
|
1. Hello
|
|
1. Goodbyte
|
|
- Inner
|
|
- Inner
|
|
2. Goodbyte
|
|
* Last
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![
|
|
list(
|
|
vec![
|
|
list_item(1, Unordered, vec![p("Item 1", 0..9)]),
|
|
list_item(1, Unordered, vec![p("Item 2", 9..18)]),
|
|
list_item(1, Unordered, vec![p("Item 3", 18..28)]),
|
|
],
|
|
0..28
|
|
),
|
|
list(
|
|
vec![
|
|
list_item(1, Ordered(1), vec![p("Hello", 28..37)]),
|
|
list_item(
|
|
1,
|
|
Ordered(2),
|
|
vec![
|
|
p("Two", 37..56),
|
|
list(
|
|
vec![list_item(2, Ordered(1), vec![p("Three", 47..56)]),],
|
|
47..56
|
|
),
|
|
]
|
|
),
|
|
list_item(1, Ordered(3), vec![p("Four", 56..64)]),
|
|
list_item(1, Ordered(4), vec![p("Five", 64..73)]),
|
|
],
|
|
28..73
|
|
),
|
|
list(
|
|
vec![
|
|
list_item(
|
|
1,
|
|
Unordered,
|
|
vec![
|
|
p("First", 73..155),
|
|
list(
|
|
vec![
|
|
list_item(
|
|
2,
|
|
Ordered(1),
|
|
vec![
|
|
p("Hello", 83..141),
|
|
list(
|
|
vec![list_item(
|
|
3,
|
|
Ordered(1),
|
|
vec![
|
|
p("Goodbyte", 97..141),
|
|
list(
|
|
vec![
|
|
list_item(
|
|
4,
|
|
Unordered,
|
|
vec![p("Inner", 117..125)]
|
|
),
|
|
list_item(
|
|
4,
|
|
Unordered,
|
|
vec![p("Inner", 133..141)]
|
|
),
|
|
],
|
|
117..141
|
|
)
|
|
]
|
|
),],
|
|
97..141
|
|
)
|
|
]
|
|
),
|
|
list_item(2, Ordered(2), vec![p("Goodbyte", 143..155)]),
|
|
],
|
|
83..155
|
|
)
|
|
]
|
|
),
|
|
list_item(1, Unordered, vec![p("Last", 155..162)]),
|
|
],
|
|
73..162
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_with_nested_content() {
|
|
let parsed = parse(
|
|
"\
|
|
* This is a list item with two paragraphs.
|
|
|
|
This is the second paragraph in the list item.",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![list(
|
|
vec![list_item(
|
|
1,
|
|
Unordered,
|
|
vec![
|
|
p("This is a list item with two paragraphs.", 4..45),
|
|
p("This is the second paragraph in the list item.", 50..96)
|
|
],
|
|
),],
|
|
0..96,
|
|
),]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_list_with_leading_text() {
|
|
let parsed = parse(
|
|
"\
|
|
* `code`
|
|
* **bold**
|
|
* [link](https://example.com)
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![list(
|
|
vec![
|
|
list_item(1, Unordered, vec![p("code", 0..9)],),
|
|
list_item(1, Unordered, vec![p("bold", 9..20)]),
|
|
list_item(1, Unordered, vec![p("link", 20..50)],)
|
|
],
|
|
0..50,
|
|
),]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_simple_block_quote() {
|
|
let parsed = parse("> Simple block quote with **styled text**").await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![block_quote(
|
|
vec![p("Simple block quote with styled text", 2..41)],
|
|
0..41
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_simple_block_quote_with_multiple_lines() {
|
|
let parsed = parse(
|
|
"\
|
|
> # Heading
|
|
> More
|
|
> text
|
|
>
|
|
> More text
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![block_quote(
|
|
vec![
|
|
h1(text("Heading", 2..12), 2..12),
|
|
p("More text", 14..26),
|
|
p("More text", 30..40)
|
|
],
|
|
0..40
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_nested_block_quote() {
|
|
let parsed = parse(
|
|
"\
|
|
> A
|
|
>
|
|
> > # B
|
|
>
|
|
> C
|
|
|
|
More text
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![
|
|
block_quote(
|
|
vec![
|
|
p("A", 2..4),
|
|
block_quote(vec![h1(text("B", 10..14), 10..14)], 8..14),
|
|
p("C", 18..20)
|
|
],
|
|
0..20
|
|
),
|
|
p("More text", 21..31)
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_block() {
|
|
let parsed = parse(
|
|
"\
|
|
```
|
|
fn main() {
|
|
return 0;
|
|
}
|
|
```
|
|
",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![code_block(
|
|
None,
|
|
"fn main() {\n return 0;\n}",
|
|
0..35,
|
|
None
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_code_block_with_language(executor: BackgroundExecutor) {
|
|
let language_registry = Arc::new(LanguageRegistry::test(executor.clone()));
|
|
language_registry.add(rust_lang());
|
|
|
|
let parsed = parse_markdown(
|
|
"\
|
|
```rust
|
|
fn main() {
|
|
return 0;
|
|
}
|
|
```
|
|
",
|
|
None,
|
|
Some(language_registry),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
parsed.children,
|
|
vec![code_block(
|
|
Some("rust".to_string()),
|
|
"fn main() {\n return 0;\n}",
|
|
0..39,
|
|
Some(vec![])
|
|
)]
|
|
);
|
|
}
|
|
|
|
fn rust_lang() -> Arc<Language> {
|
|
Arc::new(Language::new(
|
|
LanguageConfig {
|
|
name: "Rust".into(),
|
|
matcher: LanguageMatcher {
|
|
path_suffixes: vec!["rs".into()],
|
|
..Default::default()
|
|
},
|
|
collapsed_placeholder: " /* ... */ ".to_string(),
|
|
..Default::default()
|
|
},
|
|
Some(tree_sitter_rust::language()),
|
|
))
|
|
}
|
|
|
|
fn h1(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
|
|
ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
|
|
source_range,
|
|
level: HeadingLevel::H1,
|
|
contents,
|
|
})
|
|
}
|
|
|
|
fn h2(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
|
|
ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
|
|
source_range,
|
|
level: HeadingLevel::H2,
|
|
contents,
|
|
})
|
|
}
|
|
|
|
fn h3(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
|
|
ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
|
|
source_range,
|
|
level: HeadingLevel::H3,
|
|
contents,
|
|
})
|
|
}
|
|
|
|
fn p(contents: &str, source_range: Range<usize>) -> ParsedMarkdownElement {
|
|
ParsedMarkdownElement::Paragraph(text(contents, source_range))
|
|
}
|
|
|
|
fn text(contents: &str, source_range: Range<usize>) -> ParsedMarkdownText {
|
|
ParsedMarkdownText {
|
|
highlights: Vec::new(),
|
|
region_ranges: Vec::new(),
|
|
regions: Vec::new(),
|
|
source_range,
|
|
contents: contents.to_string(),
|
|
}
|
|
}
|
|
|
|
fn block_quote(
|
|
children: Vec<ParsedMarkdownElement>,
|
|
source_range: Range<usize>,
|
|
) -> ParsedMarkdownElement {
|
|
ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote {
|
|
source_range,
|
|
children: children.into_iter().map(Box::new).collect(),
|
|
})
|
|
}
|
|
|
|
fn code_block(
|
|
language: Option<String>,
|
|
code: &str,
|
|
source_range: Range<usize>,
|
|
highlights: Option<Vec<(Range<usize>, HighlightId)>>,
|
|
) -> ParsedMarkdownElement {
|
|
ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
|
|
source_range,
|
|
language,
|
|
contents: code.to_string().into(),
|
|
highlights,
|
|
})
|
|
}
|
|
|
|
fn list(
|
|
children: Vec<ParsedMarkdownListItem>,
|
|
source_range: Range<usize>,
|
|
) -> ParsedMarkdownElement {
|
|
List(ParsedMarkdownList {
|
|
source_range,
|
|
children,
|
|
})
|
|
}
|
|
|
|
fn list_item(
|
|
depth: u16,
|
|
item_type: ParsedMarkdownListItemType,
|
|
contents: Vec<ParsedMarkdownElement>,
|
|
) -> ParsedMarkdownListItem {
|
|
ParsedMarkdownListItem {
|
|
item_type,
|
|
depth,
|
|
contents: contents.into_iter().map(Box::new).collect(),
|
|
}
|
|
}
|
|
|
|
fn table(
|
|
source_range: Range<usize>,
|
|
header: ParsedMarkdownTableRow,
|
|
body: Vec<ParsedMarkdownTableRow>,
|
|
) -> ParsedMarkdownTable {
|
|
ParsedMarkdownTable {
|
|
column_alignments: Vec::new(),
|
|
source_range,
|
|
header,
|
|
body,
|
|
}
|
|
}
|
|
|
|
fn row(children: Vec<ParsedMarkdownText>) -> ParsedMarkdownTableRow {
|
|
ParsedMarkdownTableRow { children }
|
|
}
|
|
|
|
impl PartialEq for ParsedMarkdownTable {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.source_range == other.source_range
|
|
&& self.header == other.header
|
|
&& self.body == other.body
|
|
}
|
|
}
|
|
|
|
impl PartialEq for ParsedMarkdownText {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.source_range == other.source_range && self.contents == other.contents
|
|
}
|
|
}
|
|
}
|