1use collections::{BTreeMap, HashMap, HashSet};
2use gpui::SharedString;
3use linkify::LinkFinder;
4pub use pulldown_cmark::TagEnd as MarkdownTagEnd;
5use pulldown_cmark::{
6 Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser,
7};
8use std::{ops::Range, sync::Arc};
9use util::markdown::generate_heading_slug;
10
11use crate::path_range::PathWithRange;
12
13pub(crate) mod html;
17
18pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES
19 .union(Options::ENABLE_FOOTNOTES)
20 .union(Options::ENABLE_STRIKETHROUGH)
21 .union(Options::ENABLE_TASKLISTS)
22 .union(Options::ENABLE_SMART_PUNCTUATION)
23 .union(Options::ENABLE_HEADING_ATTRIBUTES)
24 .union(Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS)
25 .union(Options::ENABLE_OLD_FOOTNOTES)
26 .union(Options::ENABLE_GFM)
27 .union(Options::ENABLE_SUPERSCRIPT)
28 .union(Options::ENABLE_SUBSCRIPT);
29
30#[derive(Default)]
31struct ParseState {
32 events: Vec<(Range<usize>, MarkdownEvent)>,
33 root_block_starts: Vec<usize>,
34 depth: usize,
35}
36
37#[derive(Debug, Default)]
38#[cfg_attr(test, derive(PartialEq))]
39pub(crate) struct ParsedMarkdownData {
40 pub events: Vec<(Range<usize>, MarkdownEvent)>,
41 pub language_names: HashSet<SharedString>,
42 pub language_paths: HashSet<Arc<str>>,
43 pub root_block_starts: Vec<usize>,
44 pub html_blocks: BTreeMap<usize, html::html_parser::ParsedHtmlBlock>,
45 pub metadata_blocks: BTreeMap<usize, ParsedMetadataBlock>,
46 pub heading_slugs: HashMap<SharedString, usize>,
47 pub footnote_definitions: HashMap<SharedString, usize>,
48}
49
50#[derive(Clone, Debug, PartialEq)]
51pub(crate) struct ParsedMetadataBlock {
52 pub content_range: Range<usize>,
53 pub rows: Option<Vec<MetadataRow>>,
54}
55
56#[derive(Clone, Debug, PartialEq)]
57pub(crate) struct MetadataRow {
58 pub key: Range<usize>,
59 pub value: Range<usize>,
60}
61
62impl ParseState {
63 fn push_event(&mut self, range: Range<usize>, event: MarkdownEvent) {
64 match &event {
65 MarkdownEvent::Start(_) => {
66 if self.depth == 0 {
67 self.root_block_starts.push(range.start);
68 self.events.push((range.clone(), MarkdownEvent::RootStart));
69 }
70 self.depth += 1;
71 self.events.push((range, event));
72 }
73 MarkdownEvent::End(_) => {
74 self.events.push((range.clone(), event));
75 if self.depth > 0 {
76 self.depth -= 1;
77 if self.depth == 0 {
78 let root_block_index = self.root_block_starts.len() - 1;
79 self.events
80 .push((range, MarkdownEvent::RootEnd(root_block_index)));
81 }
82 }
83 }
84 MarkdownEvent::Rule => {
85 if self.depth == 0 && !range.is_empty() {
86 self.root_block_starts.push(range.start);
87 let root_block_index = self.root_block_starts.len() - 1;
88 self.events.push((range.clone(), MarkdownEvent::RootStart));
89 self.events.push((range.clone(), event));
90 self.events
91 .push((range, MarkdownEvent::RootEnd(root_block_index)));
92 } else {
93 self.events.push((range, event));
94 }
95 }
96 _ => {
97 self.events.push((range, event));
98 }
99 }
100 }
101}
102
103const MAX_DUPLICATE_HEADING_SLUGS: usize = 128;
104
105fn build_heading_slugs(
106 source: &str,
107 events: &[(Range<usize>, MarkdownEvent)],
108) -> HashMap<SharedString, usize> {
109 let mut slugs = HashMap::default();
110 let mut slug_counts: HashMap<String, usize> = HashMap::default();
111 let mut inside_heading = false;
112 let mut heading_text = String::new();
113 let mut heading_source_start: Option<usize> = None;
114
115 for (range, event) in events {
116 match event {
117 MarkdownEvent::Start(MarkdownTag::Heading { .. }) => {
118 inside_heading = true;
119 heading_text.clear();
120 heading_source_start = None;
121 }
122 MarkdownEvent::End(MarkdownTagEnd::Heading(_)) => {
123 if inside_heading {
124 let source_offset = heading_source_start.unwrap_or(range.start);
125 let base_slug = generate_heading_slug(&heading_text);
126 let count = slug_counts.entry(base_slug.clone()).or_insert(0);
127 let mut slug = if *count == 0 {
128 base_slug.clone()
129 } else {
130 format!("{base_slug}-{count}")
131 };
132 *count += 1;
133 while slugs.contains_key(slug.as_str()) {
134 let Some(count) = slug_counts.get_mut(&base_slug) else {
135 slug.clear();
136 break;
137 };
138 if *count >= MAX_DUPLICATE_HEADING_SLUGS {
139 slug.clear();
140 break;
141 }
142 slug = format!("{base_slug}-{count}");
143 *count += 1;
144 }
145 if !slug.is_empty() {
146 slugs.insert(SharedString::from(slug), source_offset);
147 }
148 inside_heading = false;
149 }
150 }
151 MarkdownEvent::Text | MarkdownEvent::Code if inside_heading => {
152 if heading_source_start.is_none() {
153 heading_source_start = Some(range.start);
154 }
155 heading_text.push_str(&source[range.clone()]);
156 }
157 MarkdownEvent::SubstitutedCode(substituted) if inside_heading => {
158 if heading_source_start.is_none() {
159 heading_source_start = Some(range.start);
160 }
161 heading_text.push_str(substituted);
162 }
163 MarkdownEvent::SubstitutedText(substituted) if inside_heading => {
164 if heading_source_start.is_none() {
165 heading_source_start = Some(range.start);
166 }
167 heading_text.push_str(substituted);
168 }
169 _ => {}
170 }
171 }
172
173 slugs
174}
175
176fn parse_metadata_table_rows(source: &str, source_range: Range<usize>) -> Option<Vec<MetadataRow>> {
177 let mut rows = Vec::new();
178 let mut line_start = source_range.start;
179
180 for line in source[source_range].split_inclusive('\n') {
181 let line_end = line_start + line.len();
182 let content_end = line_start + line.trim_end_matches(['\r', '\n']).len();
183 let content_range = line_start..content_end;
184 let line_text = &source[content_range.clone()];
185
186 if line_text.is_empty()
187 || line_text
188 .chars()
189 .next()
190 .is_some_and(|character| character.is_whitespace())
191 {
192 return None;
193 }
194
195 let delimiter = line_text.find(':')?;
196 let key = trim_metadata_range(source, content_range.start..content_range.start + delimiter);
197 let value = trim_metadata_range(
198 source,
199 content_range.start + delimiter + 1..content_range.end,
200 );
201 if key.is_empty() || value.is_empty() {
202 return None;
203 }
204
205 rows.push(MetadataRow { key, value });
206 line_start = line_end;
207 }
208
209 if rows.is_empty() { None } else { Some(rows) }
210}
211
212fn trim_metadata_range(source: &str, range: Range<usize>) -> Range<usize> {
213 let text = &source[range.clone()];
214 let start_offset = text.len() - text.trim_start().len();
215 let end_offset = text.trim_end().len();
216 let start = range.start + start_offset;
217 let end = (range.start + end_offset).max(start);
218 start..end
219}
220
221fn is_br_tag(html: &str) -> bool {
222 let Some(inner) = html
223 .trim()
224 .strip_prefix('<')
225 .and_then(|s| s.strip_suffix('>'))
226 else {
227 return false;
228 };
229 let inner = inner.strip_suffix('/').unwrap_or(inner);
230 inner
231 .split_ascii_whitespace()
232 .next()
233 .is_some_and(|name| name.eq_ignore_ascii_case("br"))
234}
235
236pub(crate) fn parse_markdown_with_options(
237 text: &str,
238 parse_html: bool,
239 parse_heading_slugs: bool,
240 parse_metadata_blocks: bool,
241) -> ParsedMarkdownData {
242 let mut state = ParseState::default();
243 let mut language_names = HashSet::default();
244 let mut language_paths = HashSet::default();
245 let mut html_blocks = BTreeMap::default();
246 let mut metadata_blocks = BTreeMap::default();
247 let mut within_link = false;
248 let mut within_code_block = false;
249 let mut within_metadata = false;
250 let mut within_table = false;
251 let mut current_metadata_block_start = None;
252 let mut metadata_block_content_range: Option<Range<usize>> = None;
253 let parse_options = if parse_metadata_blocks {
254 PARSE_OPTIONS.union(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS)
255 } else {
256 PARSE_OPTIONS
257 };
258 let mut parser = Parser::new_ext(text, parse_options)
259 .into_offset_iter()
260 .peekable();
261 while let Some((pulldown_event, range)) = parser.next() {
262 if within_metadata && !parse_metadata_blocks {
263 if let pulldown_cmark::Event::End(pulldown_cmark::TagEnd::MetadataBlock(_)) =
264 pulldown_event
265 {
266 within_metadata = false;
267 current_metadata_block_start = None;
268 metadata_block_content_range = None;
269 }
270 continue;
271 }
272 match pulldown_event {
273 pulldown_cmark::Event::Start(tag) => {
274 if let pulldown_cmark::Tag::HtmlBlock = &tag {
275 state.push_event(range.clone(), MarkdownEvent::Start(MarkdownTag::HtmlBlock));
276
277 if parse_html {
278 if let Some(block) =
279 html::html_parser::parse_html_block(&text[range.clone()], range.clone())
280 {
281 html_blocks.insert(range.start, block);
282
283 while let Some((event, end_range)) = parser.next() {
284 if let pulldown_cmark::Event::End(
285 pulldown_cmark::TagEnd::HtmlBlock,
286 ) = event
287 {
288 state.push_event(
289 end_range,
290 MarkdownEvent::End(MarkdownTagEnd::HtmlBlock),
291 );
292 break;
293 }
294 }
295 }
296 }
297 continue;
298 }
299
300 let tag = match tag {
301 pulldown_cmark::Tag::Link {
302 link_type,
303 dest_url,
304 title,
305 id,
306 } => {
307 within_link = true;
308 MarkdownTag::Link {
309 link_type,
310 dest_url: SharedString::from(dest_url.into_string()),
311 title: SharedString::from(title.into_string()),
312 id: SharedString::from(id.into_string()),
313 }
314 }
315 pulldown_cmark::Tag::MetadataBlock(kind) => {
316 within_metadata = true;
317 current_metadata_block_start = Some(range.start);
318 metadata_block_content_range = None;
319 if !parse_metadata_blocks {
320 continue;
321 }
322 MarkdownTag::MetadataBlock(kind)
323 }
324 pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => {
325 within_code_block = true;
326 MarkdownTag::CodeBlock {
327 kind: CodeBlockKind::Indented,
328 metadata: CodeBlockMetadata {
329 content_range: range.clone(),
330 line_count: 1,
331 is_fenced_closed: false,
332 },
333 }
334 }
335 pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(
336 ref info,
337 )) => {
338 within_code_block = true;
339 let content_range = extract_code_block_content_range(&text[range.clone()]);
340 let content_range =
341 content_range.start + range.start..content_range.end + range.start;
342
343 let line_count = text[content_range.clone()]
345 .bytes()
346 .filter(|c| *c == b'\n')
347 .count();
348 let is_fenced_closed = {
349 let code_block_source = &text[range.clone()];
350 code_block_source
351 .trim_end()
352 .lines()
353 .last()
354 .is_some_and(|line| {
355 let trimmed = line.trim_start();
356 trimmed.len() >= 3 && trimmed.chars().all(|c| c == '`')
357 })
358 };
359
360 let metadata = CodeBlockMetadata {
361 content_range,
362 line_count,
363 is_fenced_closed,
364 };
365
366 let info = info.trim();
367 let kind = if info.is_empty() {
368 CodeBlockKind::Fenced
369 } else if info.contains('/') {
372 let path_range = PathWithRange::new(info);
373 language_paths.insert(path_range.path.clone());
374 CodeBlockKind::FencedSrc(path_range)
375 } else {
376 let language = SharedString::from(info.to_string());
377 language_names.insert(language.clone());
378 CodeBlockKind::FencedLang(language)
379 };
380
381 MarkdownTag::CodeBlock { kind, metadata }
382 }
383 pulldown_cmark::Tag::Paragraph => MarkdownTag::Paragraph,
384 pulldown_cmark::Tag::Heading {
385 level,
386 id,
387 classes,
388 attrs,
389 } => {
390 let id = id.map(|id| SharedString::from(id.into_string()));
391 let classes = classes
392 .into_iter()
393 .map(|c| SharedString::from(c.into_string()))
394 .collect();
395 let attrs = attrs
396 .into_iter()
397 .map(|(key, value)| {
398 (
399 SharedString::from(key.into_string()),
400 value.map(|v| SharedString::from(v.into_string())),
401 )
402 })
403 .collect();
404 MarkdownTag::Heading {
405 level,
406 id,
407 classes,
408 attrs,
409 }
410 }
411 pulldown_cmark::Tag::BlockQuote(kind) => MarkdownTag::BlockQuote(kind),
412 pulldown_cmark::Tag::List(start_number) => MarkdownTag::List(start_number),
413 pulldown_cmark::Tag::Item => MarkdownTag::Item,
414 pulldown_cmark::Tag::FootnoteDefinition(label) => {
415 MarkdownTag::FootnoteDefinition(SharedString::from(label.to_string()))
416 }
417 pulldown_cmark::Tag::Table(alignments) => {
418 within_table = true;
419 MarkdownTag::Table(alignments)
420 }
421 pulldown_cmark::Tag::TableHead => MarkdownTag::TableHead,
422 pulldown_cmark::Tag::TableRow => MarkdownTag::TableRow,
423 pulldown_cmark::Tag::TableCell => MarkdownTag::TableCell,
424 pulldown_cmark::Tag::Emphasis => MarkdownTag::Emphasis,
425 pulldown_cmark::Tag::Strong => MarkdownTag::Strong,
426 pulldown_cmark::Tag::Strikethrough => MarkdownTag::Strikethrough,
427 pulldown_cmark::Tag::Superscript => MarkdownTag::Superscript,
428 pulldown_cmark::Tag::Subscript => MarkdownTag::Subscript,
429 pulldown_cmark::Tag::Image {
430 link_type,
431 dest_url,
432 title,
433 id,
434 } => MarkdownTag::Image {
435 link_type,
436 dest_url: SharedString::from(dest_url.into_string()),
437 title: SharedString::from(title.into_string()),
438 id: SharedString::from(id.into_string()),
439 },
440 pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, pulldown_cmark::Tag::DefinitionList => MarkdownTag::DefinitionList,
442 pulldown_cmark::Tag::DefinitionListTitle => MarkdownTag::DefinitionListTitle,
443 pulldown_cmark::Tag::DefinitionListDefinition => {
444 MarkdownTag::DefinitionListDefinition
445 }
446 };
447 state.push_event(range, MarkdownEvent::Start(tag))
448 }
449 pulldown_cmark::Event::End(tag) => {
450 if let pulldown_cmark::TagEnd::Link = tag {
451 within_link = false;
452 } else if let pulldown_cmark::TagEnd::CodeBlock = tag {
453 within_code_block = false;
454 } else if let pulldown_cmark::TagEnd::MetadataBlock(_) = tag {
455 within_metadata = false;
456 let block_start = current_metadata_block_start.take();
457 let content_range = metadata_block_content_range.take();
458 if parse_metadata_blocks
459 && let (Some(block_start), Some(content_range)) =
460 (block_start, content_range)
461 {
462 metadata_blocks.insert(
463 block_start,
464 ParsedMetadataBlock {
465 rows: parse_metadata_table_rows(text, content_range.clone()),
466 content_range,
467 },
468 );
469 }
470 if !parse_metadata_blocks {
471 continue;
472 }
473 } else if let pulldown_cmark::TagEnd::Table = tag {
474 within_table = false;
475 }
476 state.push_event(range, MarkdownEvent::End(tag));
477 }
478 pulldown_cmark::Event::Text(parsed) => {
479 fn event_for(
480 text: &str,
481 range: Range<usize>,
482 str: &str,
483 ) -> (Range<usize>, MarkdownEvent) {
484 if str == &text[range.clone()] {
485 (range, MarkdownEvent::Text)
486 } else {
487 (range, MarkdownEvent::SubstitutedText(str.to_owned()))
488 }
489 }
490
491 if within_metadata {
492 match &mut metadata_block_content_range {
493 Some(content_range) => {
494 content_range.start = content_range.start.min(range.start);
495 content_range.end = content_range.end.max(range.end);
496 }
497 None => metadata_block_content_range = Some(range.clone()),
498 }
499 state.push_event(range, MarkdownEvent::Text);
500 continue;
501 }
502
503 if within_code_block {
504 let (range, event) = event_for(text, range, &parsed);
505 state.push_event(range, event);
506 continue;
507 }
508
509 #[derive(Debug)]
510 struct TextRange<'a> {
511 source_range: Range<usize>,
512 merged_range: Range<usize>,
513 parsed: CowStr<'a>,
514 }
515
516 let mut last_len = parsed.len();
517 let mut ranges = vec![TextRange {
518 source_range: range.clone(),
519 merged_range: 0..last_len,
520 parsed,
521 }];
522
523 while match parser.peek() {
524 Some((pulldown_cmark::Event::Text(_), _)) => true,
525 Some((pulldown_cmark::Event::InlineHtml(html), _)) => {
526 parse_html && !is_br_tag(html)
527 }
528 _ => false,
529 } {
530 let Some((next_event, next_range)) = parser.next() else {
531 unreachable!()
532 };
533 let next_text = match next_event {
534 pulldown_cmark::Event::Text(next_event) => next_event,
535 pulldown_cmark::Event::InlineHtml(_) => CowStr::Borrowed(""),
536 _ => unreachable!(),
537 };
538 let next_len = last_len + next_text.len();
539 ranges.push(TextRange {
540 source_range: next_range.clone(),
541 merged_range: last_len..next_len,
542 parsed: next_text,
543 });
544 last_len = next_len;
545 }
546
547 let mut merged_text =
548 String::with_capacity(ranges.last().unwrap().merged_range.end);
549 for range in &ranges {
550 merged_text.push_str(&range.parsed);
551 }
552
553 let mut ranges = ranges.into_iter().peekable();
554
555 if !within_link && !within_code_block {
556 let mut finder = LinkFinder::new();
557 finder.kinds(&[linkify::LinkKind::Url]);
558
559 for link in finder.links(&merged_text) {
561 let link_start_in_merged = link.start();
562 let link_end_in_merged = link.end();
563
564 while ranges
565 .peek()
566 .is_some_and(|range| range.merged_range.end <= link_start_in_merged)
567 {
568 let range = ranges.next().unwrap();
569 let (range, event) = event_for(text, range.source_range, &range.parsed);
570 state.push_event(range, event);
571 }
572
573 let Some(range) = ranges.peek_mut() else {
574 continue;
575 };
576 let prefix_len = link_start_in_merged - range.merged_range.start;
577 if prefix_len > 0 {
578 let (head, tail) = range.parsed.split_at(prefix_len);
579 let (event_range, event) = event_for(
580 text,
581 range.source_range.start..range.source_range.start + prefix_len,
582 head,
583 );
584 state.push_event(event_range, event);
585 range.parsed = CowStr::Boxed(tail.into());
586 range.merged_range.start += prefix_len;
587 range.source_range.start += prefix_len;
588 }
589
590 let link_start_in_source = range.source_range.start;
591 let mut link_end_in_source = range.source_range.end;
592 let mut link_events = Vec::new();
593
594 while ranges
595 .peek()
596 .is_some_and(|range| range.merged_range.end <= link_end_in_merged)
597 {
598 let range = ranges.next().unwrap();
599 link_end_in_source = range.source_range.end;
600 link_events.push(event_for(text, range.source_range, &range.parsed));
601 }
602
603 if let Some(range) = ranges.peek_mut() {
604 let prefix_len = link_end_in_merged - range.merged_range.start;
605 if prefix_len > 0 {
606 let (head, tail) = range.parsed.split_at(prefix_len);
607 link_events.push(event_for(
608 text,
609 range.source_range.start..range.source_range.start + prefix_len,
610 head,
611 ));
612 range.parsed = CowStr::Boxed(tail.into());
613 range.merged_range.start += prefix_len;
614 range.source_range.start += prefix_len;
615 link_end_in_source = range.source_range.start;
616 }
617 }
618 let link_range = link_start_in_source..link_end_in_source;
619
620 state.push_event(
621 link_range.clone(),
622 MarkdownEvent::Start(MarkdownTag::Link {
623 link_type: LinkType::Autolink,
624 dest_url: SharedString::from(link.as_str().to_string()),
625 title: SharedString::default(),
626 id: SharedString::default(),
627 }),
628 );
629 for (range, event) in link_events {
630 state.push_event(range, event);
631 }
632 state.push_event(
633 link_range.clone(),
634 MarkdownEvent::End(MarkdownTagEnd::Link),
635 );
636 }
637 }
638
639 for range in ranges {
640 let (range, event) = event_for(text, range.source_range, &range.parsed);
641 state.push_event(range, event);
642 }
643 }
644 pulldown_cmark::Event::Code(parsed) => {
645 let content_range = extract_code_content_range(&text[range.clone()]);
646 let content_range =
647 content_range.start + range.start..content_range.end + range.start;
648 let source = &text[content_range.clone()];
649 let event = if within_table && source.contains(r"\|") {
650 MarkdownEvent::SubstitutedCode(parsed.to_string())
651 } else {
652 MarkdownEvent::Code
653 };
654 state.push_event(content_range, event)
655 }
656 pulldown_cmark::Event::Html(_) => state.push_event(range, MarkdownEvent::Html),
657 pulldown_cmark::Event::InlineHtml(html) => {
658 if parse_html && is_br_tag(&html) {
659 state.push_event(range, MarkdownEvent::HardBreak)
660 } else {
661 state.push_event(range, MarkdownEvent::InlineHtml)
662 }
663 }
664 pulldown_cmark::Event::FootnoteReference(label) => state.push_event(
665 range,
666 MarkdownEvent::FootnoteReference(SharedString::from(label.to_string())),
667 ),
668 pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak),
669 pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak),
670 pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule),
671 pulldown_cmark::Event::TaskListMarker(checked) => {
672 state.push_event(range, MarkdownEvent::TaskListMarker(checked))
673 }
674 pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {}
675 }
676 }
677
678 let heading_slugs = if parse_heading_slugs {
679 build_heading_slugs(text, &state.events)
680 } else {
681 HashMap::default()
682 };
683 let footnote_definitions = build_footnote_definitions(&state.events);
684
685 ParsedMarkdownData {
686 events: state.events,
687 language_names,
688 language_paths,
689 root_block_starts: state.root_block_starts,
690 html_blocks,
691 metadata_blocks,
692 heading_slugs,
693 footnote_definitions,
694 }
695}
696
697fn build_footnote_definitions(
698 events: &[(Range<usize>, MarkdownEvent)],
699) -> HashMap<SharedString, usize> {
700 let mut definitions = HashMap::default();
701 let mut current_label: Option<SharedString> = None;
702
703 for (range, event) in events {
704 match event {
705 MarkdownEvent::Start(MarkdownTag::FootnoteDefinition(label)) => {
706 current_label = Some(label.clone());
707 }
708 MarkdownEvent::End(MarkdownTagEnd::FootnoteDefinition) => {
709 current_label = None;
710 }
711 MarkdownEvent::Text if current_label.is_some() => {
712 if let Some(label) = current_label.take() {
713 definitions.entry(label).or_insert(range.start);
714 }
715 }
716 _ => {}
717 }
718 }
719
720 definitions
721}
722
723pub fn parse_links_only(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
724 let mut events = Vec::new();
725 let mut finder = LinkFinder::new();
726 finder.kinds(&[linkify::LinkKind::Url]);
727 let mut text_range = Range {
728 start: 0,
729 end: text.len(),
730 };
731 for link in finder.links(text) {
732 let link_range = link.start()..link.end();
733
734 if link_range.start > text_range.start {
735 events.push((text_range.start..link_range.start, MarkdownEvent::Text));
736 }
737
738 events.push((
739 link_range.clone(),
740 MarkdownEvent::Start(MarkdownTag::Link {
741 link_type: LinkType::Autolink,
742 dest_url: SharedString::from(link.as_str().to_string()),
743 title: SharedString::default(),
744 id: SharedString::default(),
745 }),
746 ));
747 events.push((link_range.clone(), MarkdownEvent::Text));
748 events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link)));
749
750 text_range.start = link_range.end;
751 }
752
753 if text_range.end > text_range.start {
754 events.push((text_range, MarkdownEvent::Text));
755 }
756
757 events
758}
759
760#[derive(Clone, Debug, PartialEq)]
763pub enum MarkdownEvent {
764 Start(MarkdownTag),
768 End(MarkdownTagEnd),
770 Text,
772 SubstitutedText(String),
775 Code,
777 SubstitutedCode(String),
779 Html,
781 InlineHtml,
783 FootnoteReference(SharedString),
787 SoftBreak,
789 HardBreak,
791 Rule,
793 TaskListMarker(bool),
795 RootStart,
797 RootEnd(usize),
799}
800
801#[derive(Clone, Debug, PartialEq)]
803pub enum MarkdownTag {
804 Paragraph,
806
807 Heading {
812 level: HeadingLevel,
813 id: Option<SharedString>,
814 classes: Vec<SharedString>,
815 attrs: Vec<(SharedString, Option<SharedString>)>,
817 },
818
819 BlockQuote(Option<pulldown_cmark::BlockQuoteKind>),
820
821 CodeBlock {
823 kind: CodeBlockKind,
824 metadata: CodeBlockMetadata,
825 },
826
827 HtmlBlock,
829
830 List(Option<u64>),
833
834 Item,
836
837 FootnoteDefinition(SharedString),
840
841 Table(Vec<Alignment>),
843
844 TableHead,
847
848 TableRow,
850 TableCell,
851
852 Emphasis,
854 Strong,
855 Strikethrough,
856 Superscript,
857 Subscript,
858
859 Link {
861 link_type: LinkType,
862 dest_url: SharedString,
863 title: SharedString,
864 id: SharedString,
866 },
867
868 Image {
871 link_type: LinkType,
872 dest_url: SharedString,
873 title: SharedString,
874 id: SharedString,
876 },
877
878 MetadataBlock(MetadataBlockKind),
880
881 DefinitionList,
882 DefinitionListTitle,
883 DefinitionListDefinition,
884}
885
886#[derive(Clone, Debug, PartialEq)]
887pub enum CodeBlockKind {
888 Indented,
889 Fenced,
894 FencedLang(SharedString),
895 FencedSrc(PathWithRange),
896}
897
898#[derive(Default, Clone, Debug, PartialEq)]
899pub struct CodeBlockMetadata {
900 pub content_range: Range<usize>,
901 pub line_count: usize,
902 pub is_fenced_closed: bool,
903}
904
905fn extract_code_content_range(text: &str) -> Range<usize> {
906 let text_len = text.len();
907 if text_len == 0 {
908 return 0..0;
909 }
910
911 let start_ticks = text.chars().take_while(|&c| c == '`').count();
912
913 if start_ticks == 0 || start_ticks > text_len {
914 return 0..text_len;
915 }
916
917 let end_ticks = text.chars().rev().take_while(|&c| c == '`').count();
918
919 if end_ticks != start_ticks || text_len < start_ticks + end_ticks {
920 return 0..text_len;
921 }
922
923 start_ticks..text_len - end_ticks
924}
925
926pub(crate) fn extract_code_block_content_range(text: &str) -> Range<usize> {
927 let mut range = 0..text.len();
928 if text.starts_with("```") {
929 range.start += 3;
930
931 if let Some(newline_ix) = text[range.clone()].find('\n') {
932 range.start += newline_ix + 1;
933 }
934 }
935
936 if !range.is_empty() && text.ends_with("```") {
937 range.end -= 3;
938 }
939 if range.start > range.end {
940 range.end = range.start;
941 }
942 range
943}
944
945#[cfg(test)]
946mod tests {
947 use super::MarkdownEvent::*;
948 use super::MarkdownTag::*;
949 use super::*;
950
951 const CONDITIONAL_OPTIONS: Options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
952 const UNWANTED_OPTIONS: Options = Options::ENABLE_MATH
953 .union(Options::ENABLE_DEFINITION_LIST)
954 .union(Options::ENABLE_WIKILINKS);
955
956 #[test]
957 fn all_options_considered() {
958 assert_eq!(
961 PARSE_OPTIONS
962 .union(CONDITIONAL_OPTIONS)
963 .union(UNWANTED_OPTIONS),
964 Options::all()
965 );
966 }
967
968 #[test]
969 fn wanted_and_unwanted_options_disjoint() {
970 assert_eq!(
971 PARSE_OPTIONS
972 .union(CONDITIONAL_OPTIONS)
973 .intersection(UNWANTED_OPTIONS),
974 Options::empty()
975 );
976 }
977
978 #[test]
979 fn test_yaml_style_metadata_block() {
980 assert_eq!(
981 parse_markdown_with_options("---\ntitle: Post\n---\n# Heading", false, false, true),
982 ParsedMarkdownData {
983 events: vec![
984 (0..19, RootStart),
985 (0..19, Start(MetadataBlock(MetadataBlockKind::YamlStyle))),
986 (4..16, Text),
987 (
988 0..19,
989 End(MarkdownTagEnd::MetadataBlock(MetadataBlockKind::YamlStyle))
990 ),
991 (0..19, RootEnd(0)),
992 (20..29, RootStart),
993 (
994 20..29,
995 Start(Heading {
996 level: HeadingLevel::H1,
997 id: None,
998 classes: Vec::new(),
999 attrs: Vec::new(),
1000 })
1001 ),
1002 (22..29, Text),
1003 (20..29, End(MarkdownTagEnd::Heading(HeadingLevel::H1))),
1004 (20..29, RootEnd(1)),
1005 ],
1006 root_block_starts: vec![0, 20],
1007 metadata_blocks: BTreeMap::from_iter([(
1008 0,
1009 ParsedMetadataBlock {
1010 content_range: 4..16,
1011 rows: Some(vec![MetadataRow {
1012 key: 4..9,
1013 value: 11..15,
1014 }]),
1015 },
1016 )]),
1017 ..Default::default()
1018 }
1019 )
1020 }
1021
1022 #[test]
1023 fn test_metadata_block_text_is_verbatim() {
1024 let parsed =
1025 parse_markdown_with_options("---\nurl: https://zed.dev\n---\nBody", false, false, true);
1026 assert!(
1027 parsed
1028 .events
1029 .iter()
1030 .all(|(_, event)| !matches!(event, Start(Link { .. })))
1031 );
1032 }
1033
1034 #[test]
1035 fn test_metadata_blocks_store_table_rows() {
1036 let parsed = parse_markdown_with_options(
1037 "---\ntitle: Post\nauthor: Zed\n---\nBody",
1038 false,
1039 false,
1040 true,
1041 );
1042
1043 assert_eq!(
1044 parsed.metadata_blocks,
1045 BTreeMap::from_iter([(
1046 0,
1047 ParsedMetadataBlock {
1048 content_range: 4..28,
1049 rows: Some(vec![
1050 MetadataRow {
1051 key: 4..9,
1052 value: 11..15,
1053 },
1054 MetadataRow {
1055 key: 16..22,
1056 value: 24..27,
1057 },
1058 ]),
1059 },
1060 )])
1061 );
1062 }
1063
1064 #[test]
1065 fn test_metadata_blocks_store_fallback_for_nested_yaml() {
1066 let parsed =
1067 parse_markdown_with_options("---\ntags:\n - zed\n---\nBody", false, false, true);
1068
1069 assert_eq!(
1070 parsed.metadata_blocks,
1071 BTreeMap::from_iter([(
1072 0,
1073 ParsedMetadataBlock {
1074 content_range: 4..18,
1075 rows: None,
1076 },
1077 )])
1078 );
1079 }
1080
1081 #[test]
1082 fn test_metadata_table_rows_parse_simple_colon_pairs() {
1083 let source = "title: Post\nauthor: Zed\n";
1084 let Some(rows) = parse_metadata_table_rows(source, 0..source.len()) else {
1085 panic!("expected metadata rows");
1086 };
1087 let pairs = rows
1088 .into_iter()
1089 .map(|row| (&source[row.key], &source[row.value]))
1090 .collect::<Vec<_>>();
1091
1092 assert_eq!(pairs, vec![("title", "Post"), ("author", "Zed")]);
1093 }
1094
1095 #[test]
1096 fn test_metadata_table_rows_reject_non_simple_colon_pairs() {
1097 for source in [
1098 "tags:\n - zed\n",
1099 "title = Post\n",
1100 "title:\n",
1101 "title: \n",
1102 ": Post\n",
1103 " title: Post\n",
1104 "\n",
1105 ] {
1106 assert!(parse_metadata_table_rows(source, 0..source.len()).is_none());
1107 }
1108 }
1109
1110 #[test]
1111 fn test_trim_metadata_range_returns_valid_empty_range() {
1112 let source = "key: \n";
1113 let trimmed = trim_metadata_range(source, 4..7);
1114
1115 assert_eq!(trimmed, 7..7);
1116 assert!(source[trimmed].is_empty());
1117 }
1118
1119 #[test]
1120 fn test_html_comments() {
1121 assert_eq!(
1122 parse_markdown_with_options(
1123 " <!--\nrdoc-file=string.c\n-->\nReturns",
1124 false,
1125 false,
1126 false
1127 ),
1128 ParsedMarkdownData {
1129 events: vec![
1130 (2..30, RootStart),
1131 (2..30, Start(HtmlBlock)),
1132 (2..2, SubstitutedText(" ".into())),
1133 (2..7, Html),
1134 (7..26, Html),
1135 (26..30, Html),
1136 (2..30, End(MarkdownTagEnd::HtmlBlock)),
1137 (2..30, RootEnd(0)),
1138 (30..37, RootStart),
1139 (30..37, Start(Paragraph)),
1140 (30..37, Text),
1141 (30..37, End(MarkdownTagEnd::Paragraph)),
1142 (30..37, RootEnd(1)),
1143 ],
1144 root_block_starts: vec![2, 30],
1145 ..Default::default()
1146 }
1147 )
1148 }
1149
1150 #[test]
1151 fn test_plain_urls_and_escaped_text() {
1152 assert_eq!(
1153 parse_markdown_with_options(
1154 " https://some.url some \\`►\\` text",
1155 false,
1156 false,
1157 false,
1158 ),
1159 ParsedMarkdownData {
1160 events: vec![
1161 (0..51, RootStart),
1162 (0..51, Start(Paragraph)),
1163 (0..6, SubstitutedText("\u{a0}".into())),
1164 (6..12, SubstitutedText("\u{a0}".into())),
1165 (12..13, Text),
1166 (
1167 13..29,
1168 Start(Link {
1169 link_type: LinkType::Autolink,
1170 dest_url: "https://some.url".into(),
1171 title: "".into(),
1172 id: "".into(),
1173 })
1174 ),
1175 (13..29, Text),
1176 (13..29, End(MarkdownTagEnd::Link)),
1177 (29..35, Text),
1178 (36..37, Text), (37..44, SubstitutedText("►".into())),
1180 (45..46, Text), (46..51, Text),
1182 (0..51, End(MarkdownTagEnd::Paragraph)),
1183 (0..51, RootEnd(0)),
1184 ],
1185 root_block_starts: vec![0],
1186 ..Default::default()
1187 }
1188 );
1189 }
1190
1191 #[test]
1192 fn test_incomplete_link() {
1193 assert_eq!(
1194 parse_markdown_with_options(
1195 "You can use the [GitHub Search API](https://docs.github.com/en",
1196 false,
1197 false,
1198 false,
1199 )
1200 .events,
1201 vec![
1202 (0..62, RootStart),
1203 (0..62, Start(Paragraph)),
1204 (0..16, Text),
1205 (16..17, Text),
1206 (17..34, Text),
1207 (34..35, Text),
1208 (35..36, Text),
1209 (
1210 36..62,
1211 Start(Link {
1212 link_type: LinkType::Autolink,
1213 dest_url: "https://docs.github.com/en".into(),
1214 title: "".into(),
1215 id: "".into()
1216 })
1217 ),
1218 (36..62, Text),
1219 (36..62, End(MarkdownTagEnd::Link)),
1220 (0..62, End(MarkdownTagEnd::Paragraph)),
1221 (0..62, RootEnd(0)),
1222 ],
1223 );
1224 }
1225
1226 #[test]
1227 fn test_smart_punctuation() {
1228 assert_eq!(
1229 parse_markdown_with_options(
1230 "-- --- ... \"double quoted\" 'single quoted' ----------",
1231 false,
1232 false,
1233 false,
1234 ),
1235 ParsedMarkdownData {
1236 events: vec![
1237 (0..53, RootStart),
1238 (0..53, Start(Paragraph)),
1239 (0..2, SubstitutedText("–".into())),
1240 (2..3, Text),
1241 (3..6, SubstitutedText("—".into())),
1242 (6..7, Text),
1243 (7..10, SubstitutedText("…".into())),
1244 (10..11, Text),
1245 (11..12, SubstitutedText("\u{201c}".into())),
1246 (12..25, Text),
1247 (25..26, SubstitutedText("\u{201d}".into())),
1248 (26..27, Text),
1249 (27..28, SubstitutedText("\u{2018}".into())),
1250 (28..41, Text),
1251 (41..42, SubstitutedText("\u{2019}".into())),
1252 (42..43, Text),
1253 (43..53, SubstitutedText("–––––".into())),
1254 (0..53, End(MarkdownTagEnd::Paragraph)),
1255 (0..53, RootEnd(0)),
1256 ],
1257 root_block_starts: vec![0],
1258 ..Default::default()
1259 }
1260 )
1261 }
1262
1263 #[test]
1264 fn test_code_block_metadata() {
1265 assert_eq!(
1266 parse_markdown_with_options(
1267 "```rust\nfn main() {\n let a = 1;\n}\n```",
1268 false,
1269 false,
1270 false
1271 ),
1272 ParsedMarkdownData {
1273 events: vec![
1274 (0..37, RootStart),
1275 (
1276 0..37,
1277 Start(CodeBlock {
1278 kind: CodeBlockKind::FencedLang("rust".into()),
1279 metadata: CodeBlockMetadata {
1280 content_range: 8..34,
1281 line_count: 3,
1282 is_fenced_closed: true,
1283 }
1284 })
1285 ),
1286 (8..34, Text),
1287 (0..37, End(MarkdownTagEnd::CodeBlock)),
1288 (0..37, RootEnd(0)),
1289 ],
1290 language_names: {
1291 let mut h = HashSet::default();
1292 h.insert("rust".into());
1293 h
1294 },
1295 root_block_starts: vec![0],
1296 ..Default::default()
1297 }
1298 );
1299 assert_eq!(
1300 parse_markdown_with_options(" fn main() {}", false, false, false),
1301 ParsedMarkdownData {
1302 events: vec![
1303 (4..16, RootStart),
1304 (
1305 4..16,
1306 Start(CodeBlock {
1307 kind: CodeBlockKind::Indented,
1308 metadata: CodeBlockMetadata {
1309 content_range: 4..16,
1310 line_count: 1,
1311 is_fenced_closed: false,
1312 }
1313 })
1314 ),
1315 (4..16, Text),
1316 (4..16, End(MarkdownTagEnd::CodeBlock)),
1317 (4..16, RootEnd(0)),
1318 ],
1319 root_block_starts: vec![4],
1320 ..Default::default()
1321 }
1322 );
1323 }
1324
1325 fn assert_code_block_does_not_emit_links(markdown: &str) {
1326 let parsed = parse_markdown_with_options(markdown, false, false, false);
1327 let mut code_block_depth = 0;
1328 let mut code_block_count = 0;
1329 let mut saw_text_inside_code_block = false;
1330
1331 for (_, event) in &parsed.events {
1332 match event {
1333 Start(CodeBlock { .. }) => {
1334 code_block_depth += 1;
1335 code_block_count += 1;
1336 }
1337 End(MarkdownTagEnd::CodeBlock) => {
1338 assert!(
1339 code_block_depth > 0,
1340 "encountered a code block end without a matching start"
1341 );
1342 code_block_depth -= 1;
1343 }
1344 Start(Link { .. }) | End(MarkdownTagEnd::Link) => {
1345 assert_eq!(
1346 code_block_depth, 0,
1347 "code blocks should not emit link events"
1348 );
1349 }
1350 Text | SubstitutedText(_) if code_block_depth > 0 => {
1351 saw_text_inside_code_block = true;
1352 }
1353 _ => {}
1354 }
1355 }
1356
1357 assert_eq!(code_block_count, 1, "expected exactly one code block");
1358 assert_eq!(code_block_depth, 0, "unterminated code block");
1359 assert!(
1360 saw_text_inside_code_block,
1361 "expected text inside the code block"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_code_blocks_do_not_autolink_urls() {
1367 assert_code_block_does_not_emit_links("```txt\nhttps://example.com\n```");
1368 assert_code_block_does_not_emit_links(" https://example.com");
1369 assert_code_block_does_not_emit_links(
1370 "```txt\r\nhttps:/\\/example.com\r\nhttps://example.com\r\n```",
1371 );
1372 assert_code_block_does_not_emit_links(
1373 " https:/\\/example.com\r\n https://example.com",
1374 );
1375 }
1376
1377 #[test]
1378 fn test_metadata_blocks_are_root_blocks() {
1379 assert_eq!(
1380 parse_markdown_with_options(
1381 "+++\ntitle = \"Example\"\n+++\n\nParagraph",
1382 false,
1383 false,
1384 true
1385 ),
1386 ParsedMarkdownData {
1387 events: vec![
1388 (0..25, RootStart),
1389 (0..25, Start(MetadataBlock(MetadataBlockKind::PlusesStyle))),
1390 (4..22, Text),
1391 (
1392 0..25,
1393 End(MarkdownTagEnd::MetadataBlock(
1394 MetadataBlockKind::PlusesStyle
1395 ))
1396 ),
1397 (0..25, RootEnd(0)),
1398 (27..36, RootStart),
1399 (27..36, Start(Paragraph)),
1400 (27..36, Text),
1401 (27..36, End(MarkdownTagEnd::Paragraph)),
1402 (27..36, RootEnd(1)),
1403 ],
1404 root_block_starts: vec![0, 27],
1405 metadata_blocks: BTreeMap::from_iter([(
1406 0,
1407 ParsedMetadataBlock {
1408 content_range: 4..22,
1409 rows: None,
1410 },
1411 )]),
1412 ..Default::default()
1413 }
1414 );
1415 }
1416
1417 #[test]
1418 fn test_metadata_blocks_are_omitted_by_default() {
1419 assert_eq!(
1420 parse_markdown_with_options(
1421 "+++\ntitle = \"Example\"\n+++\n\nParagraph",
1422 false,
1423 false,
1424 false
1425 ),
1426 ParsedMarkdownData {
1427 events: vec![
1428 (27..36, RootStart),
1429 (27..36, Start(Paragraph)),
1430 (27..36, Text),
1431 (27..36, End(MarkdownTagEnd::Paragraph)),
1432 (27..36, RootEnd(0)),
1433 ],
1434 root_block_starts: vec![27],
1435 ..Default::default()
1436 }
1437 );
1438 }
1439
1440 #[test]
1441 fn test_table_checkboxes_remain_text_in_cells() {
1442 let markdown = "\
1443| Done | Task |
1444|------|---------|
1445| [x] | Fix bug |
1446| [ ] | Add feature |";
1447 let parsed = parse_markdown_with_options(markdown, false, false, false);
1448
1449 let mut in_table = false;
1450 let mut saw_task_list_marker = false;
1451 let mut cell_texts = Vec::new();
1452 let mut current_cell = String::new();
1453
1454 for (range, event) in &parsed.events {
1455 match event {
1456 Start(Table(_)) => in_table = true,
1457 End(MarkdownTagEnd::Table) => in_table = false,
1458 Start(TableCell) => current_cell.clear(),
1459 End(MarkdownTagEnd::TableCell) => {
1460 if in_table {
1461 cell_texts.push(current_cell.clone());
1462 }
1463 }
1464 Text if in_table => current_cell.push_str(&markdown[range.clone()]),
1465 TaskListMarker(_) if in_table => saw_task_list_marker = true,
1466 _ => {}
1467 }
1468 }
1469
1470 let checkbox_cells: Vec<&str> = cell_texts
1471 .iter()
1472 .map(|cell| cell.trim())
1473 .filter(|cell| *cell == "[x]" || *cell == "[X]" || *cell == "[ ]")
1474 .collect();
1475
1476 assert!(
1477 !saw_task_list_marker,
1478 "Table checkboxes should remain text, not task-list markers"
1479 );
1480 assert_eq!(checkbox_cells, vec!["[x]", "[ ]"]);
1481 }
1482
1483 #[test]
1484 fn test_extract_code_content_range() {
1485 let input = "```let x = 5;```";
1486 assert_eq!(extract_code_content_range(input), 3..13);
1487
1488 let input = "``let x = 5;``";
1489 assert_eq!(extract_code_content_range(input), 2..12);
1490
1491 let input = "`let x = 5;`";
1492 assert_eq!(extract_code_content_range(input), 1..11);
1493
1494 let input = "plain text";
1495 assert_eq!(extract_code_content_range(input), 0..10);
1496
1497 let input = "``let x = 5;`";
1498 assert_eq!(extract_code_content_range(input), 0..13);
1499 }
1500
1501 #[test]
1502 fn test_inline_code_substitutes_escaped_pipes() {
1503 let markdown = r"| Pattern |
1504| --- |
1505| `a\|b` |";
1506 let parsed = parse_markdown_with_options(markdown, false, false, false);
1507 let code_range = {
1508 let start = markdown.find(r"a\|b").expect("inline code source");
1509 start..start + r"a\|b".len()
1510 };
1511
1512 assert!(
1513 parsed
1514 .events
1515 .iter()
1516 .any(|(range, event)| range == &code_range
1517 && event == &SubstitutedCode("a|b".into())),
1518 "expected escaped pipe in table inline code to render as decoded inline code: {:?}",
1519 parsed.events
1520 );
1521 }
1522
1523 #[test]
1524 fn test_inline_code_keeps_escaped_pipes_outside_tables() {
1525 let markdown = r"`a\|b`";
1526 let parsed = parse_markdown_with_options(markdown, false, false, false);
1527
1528 assert!(
1529 parsed
1530 .events
1531 .iter()
1532 .any(|(range, event)| range == &(1..5) && event == &Code),
1533 "expected escaped pipe outside a table to remain normal inline code: {:?}",
1534 parsed.events
1535 );
1536 }
1537
1538 #[test]
1539 fn test_extract_code_block_content_range() {
1540 let input = "```rust\nlet x = 5;\n```";
1541 assert_eq!(extract_code_block_content_range(input), 8..19);
1542
1543 let input = "plain text";
1544 assert_eq!(extract_code_block_content_range(input), 0..10);
1545
1546 let input = "```python\nprint('hello')\nprint('world')\n```";
1547 assert_eq!(extract_code_block_content_range(input), 10..40);
1548
1549 let input = "`````";
1551 assert_eq!(extract_code_block_content_range(input), 3..3);
1552 }
1553
1554 #[test]
1555 fn test_footnotes() {
1556 let parsed = parse_markdown_with_options(
1557 "Text with a footnote[^1] and some more text.\n\n[^1]: This is the footnote content.",
1558 false,
1559 false,
1560 false,
1561 );
1562 assert_eq!(
1563 parsed.events,
1564 vec![
1565 (0..45, RootStart),
1566 (0..45, Start(Paragraph)),
1567 (0..20, Text),
1568 (20..24, FootnoteReference("1".into())),
1569 (24..44, Text),
1570 (0..45, End(MarkdownTagEnd::Paragraph)),
1571 (0..45, RootEnd(0)),
1572 (46..81, RootStart),
1573 (46..81, Start(FootnoteDefinition("1".into()))),
1574 (52..81, Start(Paragraph)),
1575 (52..81, Text),
1576 (52..81, End(MarkdownTagEnd::Paragraph)),
1577 (46..81, End(MarkdownTagEnd::FootnoteDefinition)),
1578 (46..81, RootEnd(1)),
1579 ]
1580 );
1581 assert_eq!(parsed.footnote_definitions.len(), 1);
1582 assert_eq!(parsed.footnote_definitions.get("1").copied(), Some(52));
1583 }
1584
1585 #[test]
1586 fn test_footnote_definitions_multiple() {
1587 let parsed = parse_markdown_with_options(
1588 "Text[^a] and[^b].\n\n[^a]: First.\n\n[^b]: Second.",
1589 false,
1590 false,
1591 false,
1592 );
1593 assert_eq!(parsed.footnote_definitions.len(), 2);
1594 assert!(parsed.footnote_definitions.contains_key("a"));
1595 assert!(parsed.footnote_definitions.contains_key("b"));
1596 }
1597
1598 #[test]
1599 fn test_links_split_across_fragments() {
1600 assert_eq!(
1605 parse_markdown_with_options(
1606 "https:/\\/example.com is equivalent to https://example.com!",
1607 false,
1608 false,
1609 false,
1610 )
1611 .events,
1612 vec![
1613 (0..62, RootStart),
1614 (0..62, Start(Paragraph)),
1615 (
1616 0..20,
1617 Start(Link {
1618 link_type: LinkType::Autolink,
1619 dest_url: "https://example.com".into(),
1620 title: "".into(),
1621 id: "".into()
1622 })
1623 ),
1624 (0..7, Text),
1625 (8..20, Text),
1626 (0..20, End(MarkdownTagEnd::Link)),
1627 (20..38, Text),
1628 (
1629 38..61,
1630 Start(Link {
1631 link_type: LinkType::Autolink,
1632 dest_url: "https://example.com".into(),
1633 title: "".into(),
1634 id: "".into()
1635 })
1636 ),
1637 (38..53, Text),
1638 (53..58, SubstitutedText(".".into())),
1639 (58..61, Text),
1640 (38..61, End(MarkdownTagEnd::Link)),
1641 (61..62, Text),
1642 (0..62, End(MarkdownTagEnd::Paragraph)),
1643 (0..62, RootEnd(0)),
1644 ],
1645 );
1646
1647 assert_eq!(
1648 parse_markdown_with_options(
1649 "Visit https://example.com/cat\\/é‍☕ for coffee!",
1650 false,
1651 false,
1652 false,
1653 )
1654 .events,
1655 [
1656 (0..55, RootStart),
1657 (0..55, Start(Paragraph)),
1658 (0..6, Text),
1659 (
1660 6..43,
1661 Start(Link {
1662 link_type: LinkType::Autolink,
1663 dest_url: "https://example.com/cat/é\u{200d}☕".into(),
1664 title: "".into(),
1665 id: "".into()
1666 })
1667 ),
1668 (6..29, Text),
1669 (30..33, Text),
1670 (33..40, SubstitutedText("\u{200d}".into())),
1671 (40..43, Text),
1672 (6..43, End(MarkdownTagEnd::Link)),
1673 (43..55, Text),
1674 (0..55, End(MarkdownTagEnd::Paragraph)),
1675 (0..55, RootEnd(0)),
1676 ]
1677 );
1678 }
1679
1680 #[test]
1681 fn test_heading_slugs() {
1682 let parsed = parse_markdown_with_options(
1683 "# Hello World\n\n## Code `block`\n\n### Third Level\n\n#### Fourth Level\n\n## Hello World",
1684 false,
1685 true,
1686 false,
1687 );
1688 assert_eq!(parsed.heading_slugs.len(), 5);
1689 assert!(parsed.heading_slugs.contains_key("hello-world"));
1690 assert!(parsed.heading_slugs.contains_key("code-block"));
1691 assert!(parsed.heading_slugs.contains_key("third-level"));
1692 assert!(parsed.heading_slugs.contains_key("fourth-level"));
1693 assert!(parsed.heading_slugs.contains_key("hello-world-1"));
1694 }
1695
1696 #[test]
1697 fn test_heading_source_index_for_slug() {
1698 let parsed = parse_markdown_with_options(
1699 "# Duplicate\n\nText\n\n## Duplicate\n\nMore text",
1700 false,
1701 true,
1702 false,
1703 );
1704 let first = parsed.heading_slugs.get("duplicate").copied();
1705 let second = parsed.heading_slugs.get("duplicate-1").copied();
1706 assert!(first.is_some());
1707 assert!(second.is_some());
1708 assert!(first.expect("first slug missing") < second.expect("second slug missing"));
1709 }
1710
1711 #[test]
1712 fn test_heading_slug_collision_with_dedup_suffix() {
1713 let parsed = parse_markdown_with_options("# Foo\n\n## Foo\n\n## Foo 1", false, true, false);
1714 assert_eq!(parsed.heading_slugs.len(), 3);
1715 assert!(parsed.heading_slugs.contains_key("foo"));
1716 assert!(parsed.heading_slugs.contains_key("foo-1"));
1717 assert!(parsed.heading_slugs.contains_key("foo-1-1"));
1718 }
1719
1720 #[test]
1721 fn test_gfm_alert_block_quote_kinds() {
1722 use pulldown_cmark::BlockQuoteKind;
1723
1724 let markdown = "\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!IMPORTANT]\n> Important.\n\n> [!WARNING]\n> A warning.\n\n> [!CAUTION]\n> A caution.\n\n> Plain quote.\n";
1725 let parsed = parse_markdown_with_options(markdown, false, false, false);
1726
1727 let block_quote_kinds: Vec<_> = parsed
1728 .events
1729 .iter()
1730 .filter_map(|(_, event)| match event {
1731 Start(BlockQuote(kind)) => Some(*kind),
1732 _ => None,
1733 })
1734 .collect();
1735
1736 assert_eq!(
1737 block_quote_kinds,
1738 vec![
1739 Some(BlockQuoteKind::Note),
1740 Some(BlockQuoteKind::Tip),
1741 Some(BlockQuoteKind::Important),
1742 Some(BlockQuoteKind::Warning),
1743 Some(BlockQuoteKind::Caution),
1744 None,
1745 ]
1746 );
1747 }
1748
1749 #[test]
1750 fn test_br_tag_emits_hard_break() {
1751 for input in [
1752 "hello<br>world",
1753 "hello<br/>world",
1754 "hello<br />world",
1755 "hello<br >world",
1756 "hello<BR>world",
1757 "hello<br class=\"x\">world",
1758 "hello<br class=\"x\"/>world",
1759 ] {
1760 let parsed = parse_markdown_with_options(input, true, false, false);
1761 let has_hard_break = parsed
1762 .events
1763 .iter()
1764 .any(|(_, event)| matches!(event, MarkdownEvent::HardBreak));
1765 let has_empty_substituted_text = parsed.events.iter().any(|(_, event)| {
1766 matches!(event, MarkdownEvent::SubstitutedText(text) if text.is_empty())
1767 });
1768 assert!(has_hard_break, "<br> in \"{input}\" should emit HardBreak");
1769 assert!(
1770 !has_empty_substituted_text,
1771 "<br> in \"{input}\" should not produce empty SubstitutedText"
1772 );
1773 }
1774 }
1775
1776 #[test]
1777 fn test_br_tag_not_a_hard_break_without_parse_html() {
1778 for input in ["hello<br>world", "hello<br/>world", "hello<br />world"] {
1779 let parsed = parse_markdown_with_options(input, false, false, false);
1780 let has_hard_break = parsed
1781 .events
1782 .iter()
1783 .any(|(_, event)| matches!(event, MarkdownEvent::HardBreak));
1784 let has_inline_html = parsed
1785 .events
1786 .iter()
1787 .any(|(_, event)| matches!(event, MarkdownEvent::InlineHtml));
1788 assert!(
1789 !has_hard_break,
1790 "<br> in \"{input}\" should not emit HardBreak when parse_html is disabled"
1791 );
1792 assert!(
1793 has_inline_html,
1794 "<br> in \"{input}\" should be preserved as InlineHtml when parse_html is disabled"
1795 );
1796 }
1797 }
1798
1799 #[test]
1800 fn test_br_prefixed_tag_is_not_a_hard_break() {
1801 for input in ["a<break>b", "a<brick>b", "a<b>bold</b>c"] {
1802 let parsed = parse_markdown_with_options(input, true, false, false);
1803 let has_hard_break = parsed
1804 .events
1805 .iter()
1806 .any(|(_, event)| matches!(event, MarkdownEvent::HardBreak));
1807 assert!(
1808 !has_hard_break,
1809 "\"{input}\" should not be treated as a <br> hard break"
1810 );
1811 }
1812 }
1813
1814 #[test]
1815 fn test_unrecognized_inline_html_preserved_as_inline_html() {
1816 for input in ["a<span>b</span>c", "a<em>b</em>c", "a<strong>b</strong>c"] {
1817 let parsed = parse_markdown_with_options(input, false, false, false);
1818 let has_inline_html = parsed
1819 .events
1820 .iter()
1821 .any(|(_, event)| matches!(event, MarkdownEvent::InlineHtml));
1822 let has_hard_break = parsed
1823 .events
1824 .iter()
1825 .any(|(_, event)| matches!(event, MarkdownEvent::HardBreak));
1826 assert!(
1827 has_inline_html,
1828 "unrecognized inline HTML \"{input}\" should emit InlineHtml"
1829 );
1830 assert!(
1831 !has_hard_break,
1832 "unrecognized inline HTML \"{input}\" should not emit HardBreak"
1833 );
1834 }
1835 }
1836}