Skip to main content

clankerdiff_markdown/
document.rs

1use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
2use serde::{Deserialize, Serialize};
3use std::{cmp, ops::Range, sync::Arc};
4
5/// A one-based, inclusive range of lines in the original Markdown source.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub struct MarkdownLineRange {
8    pub start: usize,
9    pub end: usize,
10}
11
12/// A byte and line range into the original, unmodified source.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct SourceRange {
15    pub bytes: Range<usize>,
16    pub lines: MarkdownLineRange,
17}
18
19/// An opaque identifier valid only for the lifetime of one parsed document.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
21pub struct MarkdownTargetId(usize);
22
23impl MarkdownTargetId {
24    /// Returns the document-local ordinal of this target.
25    #[must_use]
26    pub const fn index(self) -> usize {
27        self.0
28    }
29}
30
31/// The semantic kinds that can be selected and commented on.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub enum MarkdownTargetKind {
34    Heading,
35    Paragraph,
36    ListItem,
37    BlockQuote,
38    TableRow,
39    CodeBlock,
40    CodeLine,
41}
42
43/// A formatted inline Markdown node.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub enum MarkdownInline {
46    Text(String),
47    Code(String),
48    Strong(Vec<MarkdownInline>),
49    Emphasis(Vec<MarkdownInline>),
50    Strikethrough(Vec<MarkdownInline>),
51    Link {
52        destination: String,
53        title: Option<String>,
54        content: Vec<MarkdownInline>,
55    },
56    SoftBreak,
57    HardBreak,
58    /// Images intentionally do not load assets. Their alt text is the fallback.
59    ImageAlt(String),
60}
61
62impl MarkdownInline {
63    fn visible_text(&self, output: &mut String) {
64        match self {
65            Self::Text(text) | Self::Code(text) | Self::ImageAlt(text) => output.push_str(text),
66            Self::Strong(children) | Self::Emphasis(children) | Self::Strikethrough(children) => {
67                for child in children {
68                    child.visible_text(output);
69                }
70            }
71            Self::Link { content, .. } => {
72                for child in content {
73                    child.visible_text(output);
74                }
75            }
76            Self::SoftBreak | Self::HardBreak => output.push('\n'),
77        }
78    }
79}
80
81/// Parses Markdown into a renderer-neutral semantic document.
82#[must_use]
83pub fn parse_markdown(source: impl Into<String>) -> MarkdownDocument {
84    MarkdownDocument::parse(source)
85}
86
87/// Returns the rendered, style-free text represented by inline nodes.
88#[must_use]
89pub fn rendered_text(inlines: &[MarkdownInline]) -> String {
90    let mut text = String::new();
91    for inline in inlines {
92        inline.visible_text(&mut text);
93    }
94    text
95}
96
97/// A commentable or structurally significant Markdown block.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct MarkdownBlock {
100    pub source: SourceRange,
101    pub kind: MarkdownBlockKind,
102    pub target_id: Option<MarkdownTargetId>,
103}
104
105impl MarkdownBlock {
106    /// Returns the target ID when this block is commentable.
107    #[must_use]
108    pub const fn target_id(&self) -> Option<MarkdownTargetId> {
109        self.target_id
110    }
111}
112
113/// The semantic kinds of blocks exposed to renderers.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub enum MarkdownBlockKind {
116    Heading {
117        level: u8,
118        content: Vec<MarkdownInline>,
119    },
120    Paragraph {
121        content: Vec<MarkdownInline>,
122    },
123    List {
124        ordered: bool,
125        start: Option<u64>,
126        items: Vec<MarkdownListItem>,
127    },
128    BlockQuote {
129        blocks: Vec<MarkdownBlock>,
130    },
131    CodeBlock(MarkdownCodeBlock),
132    Table(MarkdownTable),
133    /// Raw HTML is retained as escaped/plain fallback text, but is not a target.
134    HtmlFallback {
135        content: Vec<MarkdownInline>,
136    },
137    Rule,
138}
139
140/// One list item. The list container itself is not a review target.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct MarkdownListItem {
143    pub depth: usize,
144    pub source: SourceRange,
145    pub content: Vec<MarkdownInline>,
146    pub blocks: Vec<MarkdownBlock>,
147    pub target_id: Option<MarkdownTargetId>,
148}
149
150impl MarkdownListItem {
151    /// Returns this item's document-local target ID.
152    #[must_use]
153    pub const fn target_id(&self) -> Option<MarkdownTargetId> {
154        self.target_id
155    }
156}
157
158/// A fenced or indented code block.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct MarkdownCodeBlock {
161    pub language: Option<String>,
162    /// The complete info string for a fenced block, when present.
163    pub info: Option<String>,
164    pub source: SourceRange,
165    pub content: SourceRange,
166    pub lines: Vec<MarkdownCodeLine>,
167    pub target_id: Option<MarkdownTargetId>,
168}
169
170impl MarkdownCodeBlock {
171    /// Returns this code block's target ID.
172    #[must_use]
173    pub const fn target_id(&self) -> Option<MarkdownTargetId> {
174        self.target_id
175    }
176
177    #[must_use]
178    pub fn highlight_hint(&self) -> &str {
179        self.info
180            .as_deref()
181            .or(self.language.as_deref())
182            .unwrap_or_default()
183    }
184}
185
186/// One content line inside a code block.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct MarkdownCodeLine {
189    pub index: usize,
190    pub source: SourceRange,
191    pub source_line: Option<usize>,
192    pub text: String,
193    pub target_id: Option<MarkdownTargetId>,
194}
195
196impl MarkdownCodeLine {
197    /// Returns this line's document-local target ID.
198    #[must_use]
199    pub const fn target_id(&self) -> Option<MarkdownTargetId> {
200        self.target_id
201    }
202}
203
204/// A table and its rows. Cells are structural and are not independent targets.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct MarkdownTable {
207    pub alignments: Vec<MarkdownTableAlignment>,
208    pub rows: Vec<MarkdownTableRow>,
209}
210
211/// Alignment requested by a Markdown table delimiter.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213pub enum MarkdownTableAlignment {
214    None,
215    Left,
216    Center,
217    Right,
218}
219
220/// One table row, including the header row when one exists.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct MarkdownTableRow {
223    pub header: bool,
224    pub source: SourceRange,
225    pub cells: Vec<MarkdownTableCell>,
226    pub target_id: Option<MarkdownTargetId>,
227}
228
229impl MarkdownTableRow {
230    /// Returns this row's document-local target ID.
231    #[must_use]
232    pub const fn target_id(&self) -> Option<MarkdownTargetId> {
233        self.target_id
234    }
235}
236
237/// One structural table cell.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct MarkdownTableCell {
240    pub source: SourceRange,
241    pub content: Vec<MarkdownInline>,
242}
243
244/// A heading in document outline order.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct MarkdownHeading {
247    pub level: u8,
248    pub title: String,
249    pub source: SourceRange,
250    pub target_id: MarkdownTargetId,
251}
252
253/// A selectable target in document order.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct MarkdownTarget {
256    pub id: MarkdownTargetId,
257    pub kind: MarkdownTargetKind,
258    pub source: SourceRange,
259    pub display_label: String,
260}
261
262/// A parsed Markdown document.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct MarkdownDocument {
265    source_path: Option<String>,
266    title: Option<String>,
267    source: Arc<str>,
268    blocks: Vec<MarkdownBlock>,
269    outline: Vec<MarkdownHeading>,
270    targets: Vec<MarkdownTarget>,
271}
272
273impl MarkdownDocument {
274    /// Creates a document by parsing Markdown with the supported GFM extensions enabled.
275    #[must_use]
276    pub fn new(source: impl Into<String>) -> Self {
277        Self::parse(source)
278    }
279
280    /// Returns an empty Markdown document.
281    #[must_use]
282    pub fn empty() -> Self {
283        Self::parse("")
284    }
285
286    /// Parses Markdown with the supported GFM extensions enabled.
287    #[must_use]
288    pub fn parse(source: impl Into<String>) -> Self {
289        Self::parse_with_metadata(None, None, source)
290    }
291
292    /// Alias for [`Self::parse`] that makes the source-oriented API explicit.
293    #[must_use]
294    pub fn from_source(source: impl Into<String>) -> Self {
295        Self::parse(source)
296    }
297
298    /// Parses Markdown while retaining optional host-provided metadata.
299    #[must_use]
300    pub fn parse_with_metadata(
301        source_path: Option<String>,
302        title: Option<String>,
303        source: impl Into<String>,
304    ) -> Self {
305        let source: Arc<str> = Arc::from(source.into());
306        let line_starts = LineIndex::new(&source);
307        let events = Parser::new_ext(&source, parser_options())
308            .into_offset_iter()
309            .map(|(event, range)| (event.into_static(), range))
310            .collect::<Vec<_>>();
311        let roots = event_tree(&events);
312        let mut blocks = roots
313            .iter()
314            .filter_map(|node| parse_block(node, 0, &source, &line_starts))
315            .collect::<Vec<_>>();
316        let mut targets = Vec::new();
317        let mut outline = Vec::new();
318        assign_targets(&mut blocks, &mut targets, &mut outline);
319        Self {
320            source_path,
321            title,
322            source,
323            blocks,
324            outline,
325            targets,
326        }
327    }
328
329    /// Returns the original source, including its original line endings.
330    #[must_use]
331    pub fn source(&self) -> &str {
332        &self.source
333    }
334
335    /// Returns the optional source path supplied by the host.
336    #[must_use]
337    pub fn source_path(&self) -> Option<&str> {
338        self.source_path.as_deref()
339    }
340
341    /// Returns the optional display title supplied by the host.
342    #[must_use]
343    pub fn title(&self) -> Option<&str> {
344        self.title.as_deref()
345    }
346
347    /// Returns the top-level semantic blocks.
348    #[must_use]
349    pub fn blocks(&self) -> &[MarkdownBlock] {
350        &self.blocks
351    }
352
353    /// Returns headings in document order.
354    #[must_use]
355    pub fn outline(&self) -> &[MarkdownHeading] {
356        &self.outline
357    }
358
359    /// Returns all commentable targets in document order.
360    #[must_use]
361    pub fn targets(&self) -> &[MarkdownTarget] {
362        &self.targets
363    }
364
365    /// Finds a target by its document-local ID.
366    #[must_use]
367    pub fn target(&self, id: MarkdownTargetId) -> Option<&MarkdownTarget> {
368        self.targets.get(id.0)
369    }
370}
371
372#[derive(Debug, Clone)]
373struct Node {
374    event: Option<Event<'static>>,
375    range: Range<usize>,
376    children: Vec<Node>,
377}
378
379fn parser_options() -> Options {
380    Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_STRIKETHROUGH
381}
382
383fn event_tree(events: &[(Event<'static>, Range<usize>)]) -> Vec<Node> {
384    let mut stack = vec![Node {
385        event: None,
386        range: 0..0,
387        children: Vec::new(),
388    }];
389    for (event, range) in events {
390        match event {
391            Event::Start(_) => stack.push(Node {
392                event: Some(event.clone()),
393                range: range.clone(),
394                children: Vec::new(),
395            }),
396            Event::End(_) => {
397                if stack.len() > 1
398                    && let Some(mut node) = stack.pop()
399                {
400                    node.range.end = cmp::max(node.range.end, range.end);
401                    if let Some(parent) = stack.last_mut() {
402                        parent.children.push(node);
403                    }
404                }
405            }
406            _ => {
407                if let Some(parent) = stack.last_mut() {
408                    parent.children.push(Node {
409                        event: Some(event.clone()),
410                        range: range.clone(),
411                        children: Vec::new(),
412                    });
413                }
414            }
415        }
416    }
417    // pulldown-cmark promises balanced events. Recovering here still makes the
418    // model useful if a future parser extension violates that promise.
419    while stack.len() > 1 {
420        if let Some(node) = stack.pop()
421            && let Some(parent) = stack.last_mut()
422        {
423            parent.children.push(node);
424        }
425    }
426    stack.pop().map_or_else(Vec::new, |root| root.children)
427}
428
429fn source_range(range: Range<usize>, index: &LineIndex) -> SourceRange {
430    SourceRange {
431        lines: index.range(range.clone()),
432        bytes: range,
433    }
434}
435
436fn parse_block(
437    node: &Node,
438    depth: usize,
439    source: &str,
440    line_index: &LineIndex,
441) -> Option<MarkdownBlock> {
442    let event = node.event.as_ref()?;
443    let kind = match event {
444        Event::Start(Tag::Heading { level, .. }) => MarkdownBlockKind::Heading {
445            level: heading_level(*level),
446            content: inline_children(&node.children),
447        },
448        Event::Start(Tag::Paragraph) => MarkdownBlockKind::Paragraph {
449            content: inline_children(&node.children),
450        },
451        Event::Start(Tag::BlockQuote(_)) => MarkdownBlockKind::BlockQuote {
452            blocks: node
453                .children
454                .iter()
455                .filter_map(|child| parse_block(child, depth, source, line_index))
456                .collect(),
457        },
458        Event::Start(Tag::List(start)) => MarkdownBlockKind::List {
459            ordered: start.is_some(),
460            start: *start,
461            items: node
462                .children
463                .iter()
464                .filter_map(|child| parse_item(child, depth, source, line_index))
465                .collect(),
466        },
467        Event::Start(Tag::CodeBlock(kind)) => {
468            MarkdownBlockKind::CodeBlock(parse_code_block(node, kind, source, line_index))
469        }
470        Event::Start(Tag::Table(alignments)) => {
471            MarkdownBlockKind::Table(parse_table(node, alignments, line_index))
472        }
473        Event::Rule => MarkdownBlockKind::Rule,
474        Event::Start(Tag::HtmlBlock) => MarkdownBlockKind::HtmlFallback {
475            content: html_fallback(&node.children),
476        },
477        _ => return None,
478    };
479    Some(MarkdownBlock {
480        source: source_range(node.range.clone(), line_index),
481        kind,
482        target_id: None,
483    })
484}
485
486fn parse_item(
487    node: &Node,
488    depth: usize,
489    source: &str,
490    line_index: &LineIndex,
491) -> Option<MarkdownListItem> {
492    if !matches!(node.event, Some(Event::Start(Tag::Item))) {
493        return None;
494    }
495    let mut content = Vec::new();
496    let mut blocks = Vec::new();
497    let mut took_paragraph = false;
498    for child in &node.children {
499        if !took_paragraph && matches!(child.event, Some(Event::Start(Tag::Paragraph))) {
500            content = inline_children(&child.children);
501            took_paragraph = true;
502        } else if let Some(inline) = inline_node(child) {
503            // Tight list items may contain inline events directly rather than
504            // a paragraph wrapper. Preserve those events as the item's content.
505            content.push(inline);
506        } else if let Some(block) = parse_block(child, depth + 1, source, line_index) {
507            blocks.push(block);
508        }
509    }
510    Some(MarkdownListItem {
511        depth,
512        source: source_range(node.range.clone(), line_index),
513        content,
514        blocks,
515        target_id: None,
516    })
517}
518
519fn parse_code_block(
520    node: &Node,
521    kind: &CodeBlockKind<'static>,
522    source: &str,
523    line_index: &LineIndex,
524) -> MarkdownCodeBlock {
525    let source_range_value = source_range(node.range.clone(), line_index);
526    let text = node
527        .children
528        .iter()
529        .filter_map(|child| match child.event.as_ref()? {
530            Event::Text(text) => Some(text.as_ref()),
531            _ => None,
532        })
533        .collect::<String>();
534    let (info, language) = match kind {
535        CodeBlockKind::Indented => (None, None),
536        CodeBlockKind::Fenced(info) => {
537            let info = info.to_string();
538            let language = info.split_whitespace().next().map(str::to_owned);
539            (Some(info), language)
540        }
541    };
542    let content_bytes = code_content_range(node.range.clone(), kind, source);
543    let fenced = matches!(kind, CodeBlockKind::Fenced(_));
544    let code = if fenced {
545        source
546            .get(content_bytes.clone())
547            .unwrap_or(text.as_str())
548            .to_owned()
549    } else {
550        text.strip_suffix('\n').unwrap_or(&text).to_owned()
551    };
552    let first_source_line = line_index.line_for_offset(node.range.start);
553    let lines = code.is_empty().then(Vec::new).unwrap_or_else(|| {
554        code.split('\n')
555            .enumerate()
556            .map(|(index, line)| {
557                let line_offset = code_line_offset(&content_bytes, &code, index);
558                let source_line = if fenced {
559                    line_index.line_for_offset(line_offset)
560                } else {
561                    first_source_line + index
562                };
563                let line_source = if fenced {
564                    let end = line_offset + line.len();
565                    source_range(line_offset..end, line_index)
566                } else {
567                    line_index.source_line_range(source_line)
568                };
569                MarkdownCodeLine {
570                    index,
571                    source: line_source,
572                    source_line: Some(source_line),
573                    text: line.strip_suffix('\r').unwrap_or(line).to_owned(),
574                    target_id: None,
575                }
576            })
577            .collect()
578    });
579    MarkdownCodeBlock {
580        language,
581        info,
582        source: source_range_value,
583        content: source_range(content_bytes, line_index),
584        lines,
585        target_id: None,
586    }
587}
588
589fn code_line_offset(content: &Range<usize>, code: &str, index: usize) -> usize {
590    content.start
591        + code
592            .split_inclusive('\n')
593            .take(index)
594            .map(str::len)
595            .sum::<usize>()
596}
597
598fn code_content_range(
599    block: Range<usize>,
600    kind: &CodeBlockKind<'static>,
601    source: &str,
602) -> Range<usize> {
603    if !matches!(kind, CodeBlockKind::Fenced(_)) {
604        return block;
605    }
606    let start = block.start.min(source.len());
607    let end = block.end.min(source.len());
608    let opening_end = source[start..end]
609        .find('\n')
610        .map_or(end, |offset| start + offset + 1);
611    if opening_end >= end {
612        return opening_end..opening_end;
613    }
614    let opening_line = source[start..opening_end].trim_end_matches(['\r', '\n']);
615    let trimmed = opening_line.trim_start();
616    let marker = trimmed.chars().next().unwrap_or('`');
617    let marker_len = trimmed
618        .chars()
619        .take_while(|character| *character == marker)
620        .count();
621    let mut closing_start = None;
622    let mut cursor = opening_end;
623    while cursor < end {
624        let line_end = source[cursor..end]
625            .find('\n')
626            .map_or(end, |offset| cursor + offset + 1);
627        let line = source[cursor..line_end].trim_end_matches(['\r', '\n']);
628        let candidate = line.trim_start();
629        let count = candidate
630            .chars()
631            .take_while(|character| *character == marker)
632            .count();
633        if count >= marker_len && marker_len > 0 && candidate[count..].trim().is_empty() {
634            closing_start = Some(cursor);
635            break;
636        }
637        cursor = line_end;
638    }
639    let mut content_end = closing_start.unwrap_or(end);
640    if closing_start.is_some() && source[..content_end].ends_with('\n') {
641        content_end -= 1;
642        if content_end > opening_end && source.as_bytes()[content_end - 1] == b'\r' {
643            content_end -= 1;
644        }
645    }
646    opening_end.min(content_end)..content_end
647}
648
649fn parse_table(node: &Node, alignments: &[Alignment], line_index: &LineIndex) -> MarkdownTable {
650    let rows = node
651        .children
652        .iter()
653        .filter_map(|section| {
654            let header = matches!(section.event, Some(Event::Start(Tag::TableHead)));
655            if !matches!(section.event, Some(Event::Start(Tag::TableHead)))
656                && !matches!(section.event, Some(Event::Start(Tag::TableRow)))
657            {
658                return None;
659            }
660            table_row(section, header, line_index)
661        })
662        .collect();
663    MarkdownTable {
664        alignments: alignments.iter().copied().map(table_alignment).collect(),
665        rows,
666    }
667}
668
669fn table_row(node: &Node, header: bool, line_index: &LineIndex) -> Option<MarkdownTableRow> {
670    if !matches!(
671        node.event,
672        Some(Event::Start(Tag::TableRow | Tag::TableHead))
673    ) {
674        return None;
675    }
676    let cells = node
677        .children
678        .iter()
679        .filter_map(|cell| {
680            if !matches!(cell.event, Some(Event::Start(Tag::TableCell))) {
681                return None;
682            }
683            Some(MarkdownTableCell {
684                source: source_range(cell.range.clone(), line_index),
685                content: inline_children(&cell.children),
686            })
687        })
688        .collect();
689    Some(MarkdownTableRow {
690        header,
691        source: source_range(node.range.clone(), line_index),
692        cells,
693        target_id: None,
694    })
695}
696
697fn table_alignment(alignment: Alignment) -> MarkdownTableAlignment {
698    match alignment {
699        Alignment::None => MarkdownTableAlignment::None,
700        Alignment::Left => MarkdownTableAlignment::Left,
701        Alignment::Center => MarkdownTableAlignment::Center,
702        Alignment::Right => MarkdownTableAlignment::Right,
703    }
704}
705
706fn inline_children(children: &[Node]) -> Vec<MarkdownInline> {
707    children.iter().filter_map(inline_node).collect()
708}
709
710fn inline_node(node: &Node) -> Option<MarkdownInline> {
711    let event = node.event.as_ref()?;
712    Some(match event {
713        Event::Text(text) => MarkdownInline::Text(text.to_string()),
714        Event::Code(text) => MarkdownInline::Code(text.to_string()),
715        Event::Start(Tag::Strong) => MarkdownInline::Strong(inline_children(&node.children)),
716        Event::Start(Tag::Emphasis) => MarkdownInline::Emphasis(inline_children(&node.children)),
717        Event::Start(Tag::Strikethrough) => {
718            MarkdownInline::Strikethrough(inline_children(&node.children))
719        }
720        Event::Start(Tag::Link {
721            dest_url, title, ..
722        }) => MarkdownInline::Link {
723            destination: dest_url.to_string(),
724            title: (!title.is_empty()).then(|| title.to_string()),
725            content: inline_children(&node.children),
726        },
727        Event::Start(Tag::Image { .. }) => {
728            MarkdownInline::ImageAlt(rendered_text(&inline_children(&node.children)))
729        }
730        Event::SoftBreak => MarkdownInline::SoftBreak,
731        Event::HardBreak => MarkdownInline::HardBreak,
732        Event::InlineHtml(html) | Event::Html(html) => MarkdownInline::Text(html.to_string()),
733        Event::TaskListMarker(checked) => MarkdownInline::Text(if *checked {
734            "☑".to_owned()
735        } else {
736            "☐".to_owned()
737        }),
738        // Math, footnotes, and metadata are not enabled by this parser. Keep a
739        // useful textual fallback if a future option starts producing them.
740        Event::InlineMath(text) | Event::DisplayMath(text) | Event::FootnoteReference(text) => {
741            MarkdownInline::Text(text.to_string())
742        }
743        Event::Start(_) | Event::End(_) | Event::Rule => return None,
744    })
745}
746
747fn html_fallback(children: &[Node]) -> Vec<MarkdownInline> {
748    children
749        .iter()
750        .filter_map(|child| match child.event.as_ref()? {
751            Event::Html(text) | Event::InlineHtml(text) => {
752                Some(MarkdownInline::Text(text.to_string()))
753            }
754            _ => None,
755        })
756        .collect()
757}
758
759fn assign_targets(
760    blocks: &mut [MarkdownBlock],
761    targets: &mut Vec<MarkdownTarget>,
762    outline: &mut Vec<MarkdownHeading>,
763) {
764    for block in blocks {
765        let mut block_target = None;
766        match &mut block.kind {
767            MarkdownBlockKind::Heading { level, content } => {
768                let title = rendered_text(content);
769                let id = push_target(
770                    MarkdownTargetKind::Heading,
771                    block.source.clone(),
772                    title.clone(),
773                    targets,
774                );
775                block_target = Some(id);
776                outline.push(MarkdownHeading {
777                    level: *level,
778                    title,
779                    source: block.source.clone(),
780                    target_id: id,
781                });
782            }
783            MarkdownBlockKind::Paragraph { content } => {
784                block_target = Some(push_target(
785                    MarkdownTargetKind::Paragraph,
786                    block.source.clone(),
787                    rendered_text(content),
788                    targets,
789                ));
790            }
791            MarkdownBlockKind::List { items, .. } => {
792                for item in items {
793                    let id = MarkdownTargetId(targets.len());
794                    item.target_id = Some(id);
795                    targets.push(MarkdownTarget {
796                        id,
797                        kind: MarkdownTargetKind::ListItem,
798                        source: item.source.clone(),
799                        display_label: rendered_text(&item.content),
800                    });
801                    assign_nested_list_targets(&mut item.blocks, targets);
802                }
803            }
804            MarkdownBlockKind::BlockQuote { blocks: children } => {
805                block_target = Some(push_target(
806                    MarkdownTargetKind::BlockQuote,
807                    block.source.clone(),
808                    block_text(children),
809                    targets,
810                ));
811            }
812            MarkdownBlockKind::CodeBlock(code) => {
813                let id = MarkdownTargetId(targets.len());
814                code.target_id = Some(id);
815                targets.push(MarkdownTarget {
816                    id,
817                    kind: MarkdownTargetKind::CodeBlock,
818                    source: code.source.clone(),
819                    display_label: "Code block".to_owned(),
820                });
821                block_target = Some(id);
822                for line in &mut code.lines {
823                    let id = MarkdownTargetId(targets.len());
824                    line.target_id = Some(id);
825                    targets.push(MarkdownTarget {
826                        id,
827                        kind: MarkdownTargetKind::CodeLine,
828                        source: line.source.clone(),
829                        display_label: format!("Code line {}", line.index + 1),
830                    });
831                }
832            }
833            MarkdownBlockKind::Table(table) => {
834                for row in &mut table.rows {
835                    let id = MarkdownTargetId(targets.len());
836                    row.target_id = Some(id);
837                    targets.push(MarkdownTarget {
838                        id,
839                        kind: MarkdownTargetKind::TableRow,
840                        source: row.source.clone(),
841                        display_label: row_text(row),
842                    });
843                }
844            }
845            MarkdownBlockKind::HtmlFallback { .. } | MarkdownBlockKind::Rule => {}
846        }
847        block.target_id = block_target;
848    }
849}
850
851fn assign_nested_list_targets(blocks: &mut [MarkdownBlock], targets: &mut Vec<MarkdownTarget>) {
852    for block in blocks {
853        if let MarkdownBlockKind::List { items, .. } = &mut block.kind {
854            for item in items {
855                let id = MarkdownTargetId(targets.len());
856                item.target_id = Some(id);
857                targets.push(MarkdownTarget {
858                    id,
859                    kind: MarkdownTargetKind::ListItem,
860                    source: item.source.clone(),
861                    display_label: rendered_text(&item.content),
862                });
863                assign_nested_list_targets(&mut item.blocks, targets);
864            }
865        }
866    }
867}
868
869fn push_target(
870    kind: MarkdownTargetKind,
871    source: SourceRange,
872    display_label: String,
873    targets: &mut Vec<MarkdownTarget>,
874) -> MarkdownTargetId {
875    let id = MarkdownTargetId(targets.len());
876    targets.push(MarkdownTarget {
877        id,
878        kind,
879        source,
880        display_label,
881    });
882    id
883}
884
885fn block_text(blocks: &[MarkdownBlock]) -> String {
886    blocks
887        .iter()
888        .map(|block| match &block.kind {
889            MarkdownBlockKind::Heading { content, .. }
890            | MarkdownBlockKind::Paragraph { content }
891            | MarkdownBlockKind::HtmlFallback { content } => rendered_text(content),
892            MarkdownBlockKind::List { items, .. } => items
893                .iter()
894                .map(|item| rendered_text(&item.content))
895                .collect::<Vec<_>>()
896                .join(" "),
897            MarkdownBlockKind::BlockQuote { blocks } => block_text(blocks),
898            MarkdownBlockKind::CodeBlock(code) => code
899                .lines
900                .iter()
901                .map(|line| line.text.as_str())
902                .collect::<Vec<_>>()
903                .join("\n"),
904            MarkdownBlockKind::Table(table) => table
905                .rows
906                .iter()
907                .map(row_text)
908                .collect::<Vec<_>>()
909                .join(" "),
910            MarkdownBlockKind::Rule => String::new(),
911        })
912        .collect::<Vec<_>>()
913        .join(" ")
914}
915
916fn row_text(row: &MarkdownTableRow) -> String {
917    row.cells
918        .iter()
919        .map(|cell| rendered_text(&cell.content))
920        .collect::<Vec<_>>()
921        .join(" | ")
922}
923
924fn heading_level(level: HeadingLevel) -> u8 {
925    match level {
926        HeadingLevel::H1 => 1,
927        HeadingLevel::H2 => 2,
928        HeadingLevel::H3 => 3,
929        HeadingLevel::H4 => 4,
930        HeadingLevel::H5 => 5,
931        HeadingLevel::H6 => 6,
932    }
933}
934
935#[derive(Debug, Clone)]
936struct LineIndex {
937    starts: Vec<usize>,
938    content_ends: Vec<usize>,
939    source_len: usize,
940}
941
942impl LineIndex {
943    fn new(source: &str) -> Self {
944        let mut starts = vec![0];
945        let mut content_ends = Vec::new();
946        for (index, byte) in source.bytes().enumerate() {
947            if byte == b'\n' {
948                content_ends.push(index.saturating_sub(usize::from(
949                    index > 0 && source.as_bytes()[index - 1] == b'\r',
950                )));
951                starts.push(index + 1);
952            }
953        }
954        content_ends.push(source.len());
955        Self {
956            starts,
957            content_ends,
958            source_len: source.len(),
959        }
960    }
961
962    fn line_for_offset(&self, offset: usize) -> usize {
963        let offset = offset.min(*self.starts.last().unwrap_or(&0));
964        self.starts.partition_point(|start| *start <= offset)
965    }
966
967    fn range(&self, range: Range<usize>) -> MarkdownLineRange {
968        let start = self.line_for_offset(range.start);
969        let end_offset = range.end.saturating_sub(1).max(range.start);
970        let end = self.line_for_offset(end_offset).max(start);
971        MarkdownLineRange { start, end }
972    }
973
974    fn source_line_range(&self, line: usize) -> SourceRange {
975        let start = self
976            .starts
977            .get(line.saturating_sub(1))
978            .copied()
979            .unwrap_or(0);
980        let end = self
981            .content_ends
982            .get(line.saturating_sub(1))
983            .copied()
984            .unwrap_or(self.source_len)
985            .max(start);
986        SourceRange {
987            bytes: start..end,
988            lines: MarkdownLineRange {
989                start: line,
990                end: line,
991            },
992        }
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999
1000    #[test]
1001    fn parses_targets_in_document_order() {
1002        let document = MarkdownDocument::parse("# Title\n\nText\n\n```rust\nlet x = 1;\n```\n");
1003        assert_eq!(document.targets().len(), 4);
1004        assert_eq!(document.targets()[0].kind, MarkdownTargetKind::Heading);
1005        assert_eq!(document.targets()[1].kind, MarkdownTargetKind::Paragraph);
1006        assert_eq!(document.targets()[2].kind, MarkdownTargetKind::CodeBlock);
1007        assert_eq!(document.targets()[3].kind, MarkdownTargetKind::CodeLine);
1008        assert_eq!(document.outline()[0].title, "Title");
1009    }
1010
1011    #[test]
1012    fn preserves_source_and_maps_crlf_lines() {
1013        let source = "# Héading\r\n\r\n```\r\none\r\ntwo\r\n```\r\n";
1014        let document = MarkdownDocument::parse(source);
1015        assert_eq!(document.source(), source);
1016        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[1].kind else {
1017            panic!("expected code block")
1018        };
1019        assert_eq!(
1020            code.lines
1021                .iter()
1022                .map(|line| line.source_line)
1023                .collect::<Vec<_>>(),
1024            vec![Some(4), Some(5)]
1025        );
1026        assert_eq!(code.lines[0].text, "one");
1027    }
1028
1029    #[test]
1030    fn parses_nested_inline_nodes_and_table_rows() {
1031        let document = MarkdownDocument::parse(
1032            "**bold _em_** and ~~gone~~ [link](url) ![alt](image)\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n",
1033        );
1034        assert!(matches!(
1035            document.blocks()[0].kind,
1036            MarkdownBlockKind::Paragraph { .. }
1037        ));
1038        let MarkdownBlockKind::Table(table) = &document.blocks()[1].kind else {
1039            panic!("expected table")
1040        };
1041        assert_eq!(table.rows.len(), 2);
1042        assert_eq!(
1043            document
1044                .targets()
1045                .iter()
1046                .filter(|target| target.kind == MarkdownTargetKind::TableRow)
1047                .count(),
1048            2
1049        );
1050    }
1051
1052    #[test]
1053    fn longer_outer_fence_does_not_close_on_shorter_run() {
1054        let document = MarkdownDocument::parse("````\n```\nstill code\n````\n");
1055        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[0].kind else {
1056            panic!("expected code block")
1057        };
1058        assert_eq!(code.lines.len(), 2);
1059        assert_eq!(code.lines[0].text, "```");
1060    }
1061
1062    #[test]
1063    fn blockquote_is_the_only_target_for_its_contents() {
1064        let document = MarkdownDocument::parse("> # quoted\n>\n> text\n");
1065        assert_eq!(document.targets().len(), 1);
1066        assert_eq!(document.targets()[0].kind, MarkdownTargetKind::BlockQuote);
1067    }
1068
1069    #[test]
1070    fn nested_list_items_are_targets_without_overlapping_paragraph_targets() {
1071        let document = MarkdownDocument::parse("1. outer\n   - inner\n   - second\n2. last\n");
1072        let kinds = document
1073            .targets()
1074            .iter()
1075            .map(|target| target.kind)
1076            .collect::<Vec<_>>();
1077        assert_eq!(
1078            kinds,
1079            vec![
1080                MarkdownTargetKind::ListItem,
1081                MarkdownTargetKind::ListItem,
1082                MarkdownTargetKind::ListItem,
1083                MarkdownTargetKind::ListItem,
1084            ]
1085        );
1086        let MarkdownBlockKind::List { items, .. } = &document.blocks()[0].kind else {
1087            panic!("expected list")
1088        };
1089        assert_eq!(items[0].depth, 0);
1090        assert_eq!(items[0].blocks.len(), 1);
1091    }
1092
1093    #[test]
1094    fn indented_code_strips_parser_indentation() {
1095        let document = MarkdownDocument::parse("    first\n    second\n");
1096        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[0].kind else {
1097            panic!("expected code block")
1098        };
1099        assert_eq!(
1100            code.lines
1101                .iter()
1102                .map(|line| line.text.as_str())
1103                .collect::<Vec<_>>(),
1104            ["first", "second"]
1105        );
1106        assert_eq!(code.lines[0].source_line, Some(1));
1107        assert_eq!(code.lines[1].source.bytes, 10..20);
1108    }
1109
1110    #[test]
1111    fn indented_code_preserves_blank_content_lines() {
1112        let document = MarkdownDocument::parse("    one\n\n    two\n");
1113        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[0].kind else {
1114            panic!("expected code block")
1115        };
1116        assert_eq!(
1117            code.lines
1118                .iter()
1119                .map(|line| line.text.as_str())
1120                .collect::<Vec<_>>(),
1121            ["one", "", "two"]
1122        );
1123        assert_eq!(
1124            code.lines
1125                .iter()
1126                .map(|line| line.source_line)
1127                .collect::<Vec<_>>(),
1128            [Some(1), Some(2), Some(3)]
1129        );
1130    }
1131
1132    #[test]
1133    fn raw_html_is_plain_fallback_without_a_target() {
1134        let document = MarkdownDocument::parse("<div>not executed</div>\n");
1135        assert!(matches!(
1136            document.blocks()[0].kind,
1137            MarkdownBlockKind::HtmlFallback { .. }
1138        ));
1139        assert!(document.targets().is_empty());
1140    }
1141
1142    #[test]
1143    fn empty_and_malformed_documents_are_safe() {
1144        assert!(MarkdownDocument::parse("").targets().is_empty());
1145        let document = MarkdownDocument::parse("# unclosed **emphasis\n\n```rust\ncode");
1146        assert!(!document.blocks().is_empty());
1147    }
1148}