Files
oak-gpui/crates/markdown_preview/src/markdown_parser.rs
T
Marshall Bowers b54d1aa95e Upgrade pulldown_cmark to v0.12 (#17418)
This PR upgrades `pulldown_cmark` to v0.12.

There were a few breaking changes that needed to be accounted for:

- The `BlockQuote` variant now has a `kind` attached. Right now we're
ignoring it.
- `pulldown_cmark` now emits tags for definition lists. This codepath
has been left unimplemented, for now.

### Release Notes

<details>
<summary>raphlinus/pulldown-cmark (pulldown-cmark)</summary>

###
[`v0.12.1`](https://redirect.github.com/pulldown-cmark/pulldown-cmark/releases/tag/v0.12.1):
0.12.1

[Compare
Source](https://redirect.github.com/raphlinus/pulldown-cmark/compare/v0.12.0...v0.12.1)

##### Security

- Fix O(n\*\*2) comment parser by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/941](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/941)

##### New features

- impl From<CowStr> for String by
[@&#8203;oconnor663](https://redirect.github.com/oconnor663) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/943](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/943)

##### Developers

- Make dos-fuzzer part of the workspace by
[@&#8203;kdarkhan](https://redirect.github.com/kdarkhan) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/945](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/945)

##### New Contributors

- [@&#8203;oconnor663](https://redirect.github.com/oconnor663) made
their first contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/943](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/943)
- [@&#8203;kdarkhan](https://redirect.github.com/kdarkhan) made their
first contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/945](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/945)

**Full Changelog**:
https://github.com/pulldown-cmark/pulldown-cmark/compare/v0.12.0...v0.12.1

###
[`v0.12.0`](https://redirect.github.com/pulldown-cmark/pulldown-cmark/releases/tag/v0.12.0):
0.12.0

[Compare
Source](https://redirect.github.com/raphlinus/pulldown-cmark/compare/v0.11.3...v0.12.0)

Thanks to all contributors! This release mainly adds the long awaited
commonmark-hs description lists (under a flag) and enables the
blockquote kind in `TagEnd` reverted in 0.11.2.

#### Breaking changes

- feat: re-add kind for BlockQuote in TagEnd by
[@&#8203;Martin1887](https://redirect.github.com/Martin1887) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/940](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/940)
- Refactor TextMergeStream by
[@&#8203;ollpu](https://redirect.github.com/ollpu) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/931](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/931)

#### New features

- Implement commonmark-hs compatible definition lists by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/915](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/915)

#### Other changes

- Rename superlinear time fuzzer to `dos-fuzzer` by
[@&#8203;ollpu](https://redirect.github.com/ollpu) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/938](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/938)

**Full Changelog**:
https://github.com/pulldown-cmark/pulldown-cmark/compare/v0.11.2...v0.12.0

###
[`v0.11.3`](https://redirect.github.com/pulldown-cmark/pulldown-cmark/releases/tag/v0.11.3):
0.11.3

[Compare
Source](https://redirect.github.com/raphlinus/pulldown-cmark/compare/v0.11.2...v0.11.3)

#### Security

- Fix O(n\*\*2) comment parser by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/944](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/944)

**Full Changelog**:
https://github.com/pulldown-cmark/pulldown-cmark/compare/v0.11.2...v0.11.3

###
[`v0.11.2`](https://redirect.github.com/pulldown-cmark/pulldown-cmark/releases/tag/v0.11.2)

[Compare
Source](https://redirect.github.com/raphlinus/pulldown-cmark/compare/v0.11.1...v0.11.2)

Revert BlockQuote kind to avoid breaking change.

###
[`v0.11.1`](https://redirect.github.com/pulldown-cmark/pulldown-cmark/releases/tag/v0.11.1)

[Compare
Source](https://redirect.github.com/raphlinus/pulldown-cmark/compare/v0.11.0...v0.11.1)

Thanks to all people involved in this release! The main change of this
release is the reduction of the MSRV to 1.71.1, but it also includes a
lot of bug fixes and a new mdBook for user-friendly documentation.

#### Breaking changes

- Add BlockQuoteKind to BlockQuote TagEnd by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/926](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/926)
(reverted in v0.11.2)

#### What's Changed

- fix: CowStr deserialization when escaping by
[@&#8203;aatifsyed](https://redirect.github.com/aatifsyed) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/895](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/895)
- fix(test): fix generating spec tests doesn't work on Windows due to
line-endings by [@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/903](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/903)
- feat: add `-G` CLI option to enable GFM support by
[@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/905](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/905)
- feat: set `DefaultBrokenLinkCallback` as the default broken link
callback of `OffsetIter` by
[@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/901](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/901)
- chore(doc): use `cargo add` to instruct how to install this crate as
dependency by [@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/904](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/904)
- fix typo by [@&#8203;jmbhughes](https://redirect.github.com/jmbhughes)
in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/909](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/909)
- Fix parsing blocks inside alert body by
[@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/908](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/908)
- fuzz: fix building fuzzer and improve fuzzing coverage by enabling
more parse options by [@&#8203;rhysd](https://redirect.github.com/rhysd)
in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/910](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/910)
- fix: fix warnings reported from nightly rustc by
[@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/911](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/911)
- fix: fix infinite loop when metadata delimiter is indented by
[@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/913](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/913)
- Raise the link cutoff from 5 to 32 by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/917](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/917)
- Reduce MSRV to 1.71.1 by separating benchmarks into a new crate with
CI enhancements by [@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/916](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/916)
- Add guide book and deploy script for it by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/883](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/883)
- ci: fix deploying the document to GitHub Pages and make the deploy job
faster by [@&#8203;rhysd](https://redirect.github.com/rhysd) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/920](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/920)
- Fix lone task list item bug by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/924](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/924)
- Fix offset range around footnotes that look like images by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/925](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/925)
- Update old footnote format to interrupt paragraph by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/928](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/928)
- Fix confusing bug with back-to-back footnotes by
[@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/930](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/930)
- Add reproduction cases for
[#&#8203;927](https://redirect.github.com/raphlinus/pulldown-cmark/issues/927)
by [@&#8203;zoni](https://redirect.github.com/zoni) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/929](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/929)
- Add regression test for
[#&#8203;655](https://redirect.github.com/raphlinus/pulldown-cmark/issues/655)
by [@&#8203;ollpu](https://redirect.github.com/ollpu) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/932](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/932)
- Renovate the superlinear time fuzzer by
[@&#8203;ollpu](https://redirect.github.com/ollpu) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/935](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/935)

#### New Contributors

- [@&#8203;aatifsyed](https://redirect.github.com/aatifsyed) made their
first contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/895](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/895)
- [@&#8203;jmbhughes](https://redirect.github.com/jmbhughes) made their
first contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/909](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/909)
- [@&#8203;zoni](https://redirect.github.com/zoni) made their first
contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/929](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/929)

**Full Changelog**:
https://github.com/pulldown-cmark/pulldown-cmark/compare/v0.11.0...v0.11.1

###
[`v0.11.0`](https://redirect.github.com/pulldown-cmark/pulldown-cmark/releases/tag/v0.11.0):
0.11.0

[Compare
Source](https://redirect.github.com/raphlinus/pulldown-cmark/compare/v0.10.3...v0.11.0)

##### Finally, the so long awaited math mode is here! Enable the option
to use it.

This release also includes other improvements and bugfixes, please see
the changelog below for more details. Thanks to all contributors that
has made possible this release!

#### Breaking changes

- Change `write_to_html` to allow `fmt::Write` by
[@&#8203;stepantubanov](https://redirect.github.com/stepantubanov) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/870](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/870)

#### New features

-   Math mode

#### Bugfixes

- \[0.11] Don't exit `scan_attribute` with the ix pointing at block
quote by [@&#8203;notriddle](https://redirect.github.com/notriddle) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/873](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/873)
- (Re)introduce simd feature to pulldown-cmark-escape by
[@&#8203;ollpu](https://redirect.github.com/ollpu) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/880](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/880)
- fix: remove unnecessary end_newline set by
[@&#8203;tomcur](https://redirect.github.com/tomcur) in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/885](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/885)

#### New Contributors

- [@&#8203;duskmoon314](https://redirect.github.com/duskmoon314) made
their first contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/874](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/874)
- [@&#8203;stepantubanov](https://redirect.github.com/stepantubanov)
made their first contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/870](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/870)
- [@&#8203;tomcur](https://redirect.github.com/tomcur) made their first
contribution in
[https://github.com/pulldown-cmark/pulldown-cmark/pull/885](https://redirect.github.com/pulldown-cmark/pulldown-cmark/pull/885)

**Full Changelog**:
https://github.com/pulldown-cmark/pulldown-cmark/compare/v0.10.3...v0.11.0

</details>

Release Notes:

- N/A
2024-09-05 10:46:51 -04:00

1349 lines
42 KiB
Rust

use crate::markdown_elements::*;
use async_recursion::async_recursion;
use collections::FxHashMap;
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.extend(block);
}
}
self
}
#[async_recursion]
async fn parse_block(&mut self) -> Option<Vec<ParsedMarkdownElement>> {
let (current, source_range) = self.current().unwrap();
let source_range = source_range.clone();
match current {
Event::Start(tag) => match tag {
Tag::Paragraph => {
self.cursor += 1;
let text = self.parse_text(false, Some(source_range));
Some(vec![ParsedMarkdownElement::Paragraph(text)])
}
Tag::Heading {
level,
id: _,
classes: _,
attrs: _,
} => {
let level = *level;
self.cursor += 1;
let heading = self.parse_heading(level);
Some(vec![ParsedMarkdownElement::Heading(heading)])
}
Tag::Table(alignment) => {
let alignment = alignment.clone();
self.cursor += 1;
let table = self.parse_table(alignment);
Some(vec![ParsedMarkdownElement::Table(table)])
}
Tag::List(order) => {
let order = *order;
self.cursor += 1;
let list = self.parse_list(order).await;
Some(list)
}
Tag::BlockQuote(_kind) => {
self.cursor += 1;
let block_quote = self.parse_block_quote().await;
Some(vec![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(vec![ParsedMarkdownElement::CodeBlock(code_block)])
}
_ => {
self.cursor += 1;
None
}
},
Event::Rule => {
let source_range = source_range.clone();
self.cursor += 1;
Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)])
}
_ => {
self.cursor += 1;
None
}
}
}
fn parse_text(
&mut self,
should_complete_on_soft_break: bool,
source_range: Option<Range<usize>>,
) -> ParsedMarkdownText {
let source_range = source_range.unwrap_or_else(|| {
self.current()
.map(|(_, range)| range.clone())
.unwrap_or_default()
});
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, None);
// 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();
let source_range = source_range.clone();
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, Some(source_range));
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 fn parse_list(&mut self, order: Option<u64>) -> Vec<ParsedMarkdownElement> {
let (_, list_source_range) = self.previous().unwrap();
let mut items = Vec::new();
let mut items_stack = vec![Vec::new()];
let mut depth = 1;
let mut task_item = None;
let mut order = order;
let mut order_stack = Vec::new();
let mut insertion_indices = FxHashMap::default();
let mut source_ranges = FxHashMap::default();
let mut start_item_range = list_source_range.clone();
while !self.eof() {
let (current, source_range) = self.current().unwrap();
match current {
Event::Start(Tag::List(new_order)) => {
if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) {
insertion_indices.insert(depth, items.len());
}
// We will use the start of the nested list as the end for the current item's range,
// because we don't care about the hierarchy of list items
if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) {
e.insert(start_item_range.start..source_range.start);
}
order_stack.push(order);
order = *new_order;
self.cursor += 1;
depth += 1;
}
Event::End(TagEnd::List(_)) => {
order = order_stack.pop().flatten();
self.cursor += 1;
depth -= 1;
if depth == 0 {
break;
}
}
Event::Start(Tag::Item) => {
start_item_range = source_range.clone();
self.cursor += 1;
items_stack.push(Vec::new());
// 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, range)) = self.current() {
// 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, Some(range.clone()));
let block = ParsedMarkdownElement::Paragraph(text);
if let Some(content) = items_stack.last_mut() {
content.push(block);
}
} else {
let block = self.parse_block().await;
if let Some(block) = block {
if let Some(content) = items_stack.last_mut() {
content.extend(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);
}
if let Some(content) = items_stack.pop() {
let source_range = source_ranges
.remove(&depth)
.unwrap_or(start_item_range.clone());
// We need to remove the last character of the source range, because it includes the newline character
let source_range = source_range.start..source_range.end - 1;
let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
source_range,
content,
depth,
item_type,
});
if let Some(index) = insertion_indices.get(&depth) {
items.insert(*index, item);
insertion_indices.remove(&depth);
} else {
items.push(item);
}
}
task_item = None;
}
_ => {
if depth == 0 {
break;
}
// This can only happen if a list item starts with more then one paragraph,
// or the list item contains blocks that should be rendered after the nested list items
let block = self.parse_block().await;
if let Some(block) = block {
if let Some(items_stack) = items_stack.last_mut() {
// If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item
if !insertion_indices.contains_key(&depth) {
items_stack.extend(block);
continue;
}
}
// Otherwise we need to insert the block after all the nested items
// that have been parsed so far
items.extend(block);
}
}
}
}
items
}
#[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<ParsedMarkdownElement> = vec![];
while !self.eof() {
let block = self.parse_block().await;
if let Some(block) = block {
children.extend(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(_kind)) => {
nested_depth += 1;
}
Event::End(TagEnd::BlockQuote(_kind)) => {
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 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", 2..13), 0..14),
h2(text("Heading two", 17..28), 14..29),
h3(text("Heading three", 33..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", 2..5), 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_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]),
],
);
}
#[gpui::test]
async fn test_list_with_tasks() {
let parsed = parse(
"\
- [ ] TODO
- [x] Checked
",
)
.await;
assert_eq!(
parsed.children,
vec![
list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]),
list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]),
],
);
}
#[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_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]),
list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]),
],
);
}
#[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_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]),
list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]),
list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]),
list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]),
list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]),
list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]),
list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]),
list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]),
list_item(73..82, 1, Unordered, vec![p("First", 75..80)]),
list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]),
list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]),
list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]),
list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]),
list_item(143..154, 2, Ordered(2), vec![p("Goodbyte", 146..154)]),
list_item(155..161, 1, Unordered, vec![p("Last", 157..161)]),
]
);
}
#[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_item(
0..96,
1,
Unordered,
vec![
p("This is a list item with two paragraphs.", 4..44),
p("This is the second paragraph in the list item.", 50..97)
],
),],
);
}
#[gpui::test]
async fn test_nested_list_with_paragraph_inside() {
let parsed = parse(
"\
1. a
1. b
1. c
text
1. d
",
)
.await;
assert_eq!(
parsed.children,
vec![
list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],),
list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],),
list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],),
p("text", 32..37),
list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],),
],
);
}
#[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_item(0..8, 1, Unordered, vec![p("code", 2..8)]),
list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]),
list_item(20..49, 1, Unordered, vec![p("link", 22..49)],)
],
);
}
#[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", 4..11), 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", 12..13), 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,
})
}
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_item(
source_range: Range<usize>,
depth: u16,
item_type: ParsedMarkdownListItemType,
content: Vec<ParsedMarkdownElement>,
) -> ParsedMarkdownElement {
ParsedMarkdownElement::ListItem(ParsedMarkdownListItem {
source_range,
item_type,
depth,
content,
})
}
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
}
}
}