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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263pub enum MarkdownSourceRole {
264    Heading,
265    Link,
266    Quote,
267    Code,
268    Strong,
269    Emphasis,
270    Strikethrough,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct MarkdownSourceStyle {
275    pub source: SourceRange,
276    pub role: MarkdownSourceRole,
277}
278
279/// A parsed Markdown document.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281pub struct MarkdownDocument {
282    source_path: Option<String>,
283    title: Option<String>,
284    source: Arc<str>,
285    blocks: Vec<MarkdownBlock>,
286    outline: Vec<MarkdownHeading>,
287    targets: Vec<MarkdownTarget>,
288    source_styles: Vec<MarkdownSourceStyle>,
289}
290
291impl MarkdownDocument {
292    /// Creates a document by parsing Markdown with the supported GFM extensions enabled.
293    #[must_use]
294    pub fn new(source: impl Into<String>) -> Self {
295        Self::parse(source)
296    }
297
298    /// Returns an empty Markdown document.
299    #[must_use]
300    pub fn empty() -> Self {
301        Self::parse("")
302    }
303
304    /// Parses Markdown with the supported GFM extensions enabled.
305    #[must_use]
306    pub fn parse(source: impl Into<String>) -> Self {
307        Self::parse_with_metadata(None, None, source)
308    }
309
310    /// Alias for [`Self::parse`] that makes the source-oriented API explicit.
311    #[must_use]
312    pub fn from_source(source: impl Into<String>) -> Self {
313        Self::parse(source)
314    }
315
316    /// Parses Markdown while retaining optional host-provided metadata.
317    #[must_use]
318    pub fn parse_with_metadata(
319        source_path: Option<String>,
320        title: Option<String>,
321        source: impl Into<String>,
322    ) -> Self {
323        let source: Arc<str> = Arc::from(source.into());
324        let line_starts = LineIndex::new(&source);
325        let events = Parser::new_ext(&source, parser_options())
326            .into_offset_iter()
327            .map(|(event, range)| (event.into_static(), range))
328            .collect::<Vec<_>>();
329        let roots = event_tree(&events);
330        let mut source_styles = Vec::new();
331        collect_source_styles(&roots, &line_starts, &mut source_styles);
332        let mut blocks = roots
333            .iter()
334            .filter_map(|node| parse_block(node, 0, &source, &line_starts))
335            .collect::<Vec<_>>();
336        let mut targets = Vec::new();
337        let mut outline = Vec::new();
338        assign_targets(&mut blocks, &mut targets, &mut outline);
339        Self {
340            source_path,
341            title,
342            source,
343            blocks,
344            outline,
345            targets,
346            source_styles,
347        }
348    }
349
350    #[must_use]
351    pub fn source_styles(&self) -> &[MarkdownSourceStyle] {
352        &self.source_styles
353    }
354
355    /// Returns the original source, including its original line endings.
356    #[must_use]
357    pub fn source(&self) -> &str {
358        &self.source
359    }
360
361    /// Returns the optional source path supplied by the host.
362    #[must_use]
363    pub fn source_path(&self) -> Option<&str> {
364        self.source_path.as_deref()
365    }
366
367    /// Returns the optional display title supplied by the host.
368    #[must_use]
369    pub fn title(&self) -> Option<&str> {
370        self.title.as_deref()
371    }
372
373    /// Returns the top-level semantic blocks.
374    #[must_use]
375    pub fn blocks(&self) -> &[MarkdownBlock] {
376        &self.blocks
377    }
378
379    /// Returns headings in document order.
380    #[must_use]
381    pub fn outline(&self) -> &[MarkdownHeading] {
382        &self.outline
383    }
384
385    /// Returns all commentable targets in document order.
386    #[must_use]
387    pub fn targets(&self) -> &[MarkdownTarget] {
388        &self.targets
389    }
390
391    /// Finds a target by its document-local ID.
392    #[must_use]
393    pub fn target(&self, id: MarkdownTargetId) -> Option<&MarkdownTarget> {
394        self.targets.get(id.0)
395    }
396
397    #[must_use]
398    pub fn code_blocks(&self) -> Vec<&MarkdownCodeBlock> {
399        fn collect<'a>(blocks: &'a [MarkdownBlock], output: &mut Vec<&'a MarkdownCodeBlock>) {
400            for block in blocks {
401                match &block.kind {
402                    MarkdownBlockKind::CodeBlock(code) => output.push(code),
403                    MarkdownBlockKind::List { items, .. } => {
404                        for item in items {
405                            collect(&item.blocks, output);
406                        }
407                    }
408                    MarkdownBlockKind::BlockQuote { blocks } => collect(blocks, output),
409                    _ => {}
410                }
411            }
412        }
413        let mut output = Vec::new();
414        collect(&self.blocks, &mut output);
415        output
416    }
417}
418
419#[derive(Debug, Clone)]
420struct Node {
421    event: Option<Event<'static>>,
422    range: Range<usize>,
423    children: Vec<Node>,
424}
425
426fn parser_options() -> Options {
427    Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_STRIKETHROUGH
428}
429
430fn event_tree(events: &[(Event<'static>, Range<usize>)]) -> Vec<Node> {
431    let mut stack = vec![Node {
432        event: None,
433        range: 0..0,
434        children: Vec::new(),
435    }];
436    for (event, range) in events {
437        match event {
438            Event::Start(_) => stack.push(Node {
439                event: Some(event.clone()),
440                range: range.clone(),
441                children: Vec::new(),
442            }),
443            Event::End(_) => {
444                if stack.len() > 1
445                    && let Some(mut node) = stack.pop()
446                {
447                    node.range.end = cmp::max(node.range.end, range.end);
448                    if let Some(parent) = stack.last_mut() {
449                        parent.children.push(node);
450                    }
451                }
452            }
453            _ => {
454                if let Some(parent) = stack.last_mut() {
455                    parent.children.push(Node {
456                        event: Some(event.clone()),
457                        range: range.clone(),
458                        children: Vec::new(),
459                    });
460                }
461            }
462        }
463    }
464    // pulldown-cmark promises balanced events. Recovering here still makes the
465    // model useful if a future parser extension violates that promise.
466    while stack.len() > 1 {
467        if let Some(node) = stack.pop()
468            && let Some(parent) = stack.last_mut()
469        {
470            parent.children.push(node);
471        }
472    }
473    stack.pop().map_or_else(Vec::new, |root| root.children)
474}
475
476fn collect_source_styles(nodes: &[Node], index: &LineIndex, output: &mut Vec<MarkdownSourceStyle>) {
477    for node in nodes {
478        let role = match node.event.as_ref() {
479            Some(Event::Start(Tag::Heading { .. })) => Some(MarkdownSourceRole::Heading),
480            Some(Event::Start(Tag::Link { .. })) => Some(MarkdownSourceRole::Link),
481            Some(Event::Start(Tag::BlockQuote(_))) => Some(MarkdownSourceRole::Quote),
482            Some(Event::Start(Tag::Strong)) => Some(MarkdownSourceRole::Strong),
483            Some(Event::Start(Tag::Emphasis)) => Some(MarkdownSourceRole::Emphasis),
484            Some(Event::Start(Tag::Strikethrough)) => Some(MarkdownSourceRole::Strikethrough),
485            Some(Event::Code(_)) => Some(MarkdownSourceRole::Code),
486            _ => None,
487        };
488        if let Some(role) = role {
489            output.push(MarkdownSourceStyle {
490                source: source_range(node.range.clone(), index),
491                role,
492            });
493        }
494        collect_source_styles(&node.children, index, output);
495    }
496}
497
498fn source_range(range: Range<usize>, index: &LineIndex) -> SourceRange {
499    SourceRange {
500        lines: index.range(range.clone()),
501        bytes: range,
502    }
503}
504
505fn parse_block(
506    node: &Node,
507    depth: usize,
508    source: &str,
509    line_index: &LineIndex,
510) -> Option<MarkdownBlock> {
511    let event = node.event.as_ref()?;
512    let kind = match event {
513        Event::Start(Tag::Heading { level, .. }) => MarkdownBlockKind::Heading {
514            level: heading_level(*level),
515            content: inline_children(&node.children),
516        },
517        Event::Start(Tag::Paragraph) => MarkdownBlockKind::Paragraph {
518            content: inline_children(&node.children),
519        },
520        Event::Start(Tag::BlockQuote(_)) => MarkdownBlockKind::BlockQuote {
521            blocks: node
522                .children
523                .iter()
524                .filter_map(|child| parse_block(child, depth, source, line_index))
525                .collect(),
526        },
527        Event::Start(Tag::List(start)) => MarkdownBlockKind::List {
528            ordered: start.is_some(),
529            start: *start,
530            items: node
531                .children
532                .iter()
533                .filter_map(|child| parse_item(child, depth, source, line_index))
534                .collect(),
535        },
536        Event::Start(Tag::CodeBlock(kind)) => {
537            MarkdownBlockKind::CodeBlock(parse_code_block(node, kind, source, line_index))
538        }
539        Event::Start(Tag::Table(alignments)) => {
540            MarkdownBlockKind::Table(parse_table(node, alignments, line_index))
541        }
542        Event::Rule => MarkdownBlockKind::Rule,
543        Event::Start(Tag::HtmlBlock) => MarkdownBlockKind::HtmlFallback {
544            content: html_fallback(&node.children),
545        },
546        _ => return None,
547    };
548    Some(MarkdownBlock {
549        source: source_range(node.range.clone(), line_index),
550        kind,
551        target_id: None,
552    })
553}
554
555fn parse_item(
556    node: &Node,
557    depth: usize,
558    source: &str,
559    line_index: &LineIndex,
560) -> Option<MarkdownListItem> {
561    if !matches!(node.event, Some(Event::Start(Tag::Item))) {
562        return None;
563    }
564    let mut content = Vec::new();
565    let mut blocks = Vec::new();
566    let mut took_paragraph = false;
567    for child in &node.children {
568        if !took_paragraph && matches!(child.event, Some(Event::Start(Tag::Paragraph))) {
569            content = inline_children(&child.children);
570            took_paragraph = true;
571        } else if let Some(inline) = inline_node(child) {
572            // Tight list items may contain inline events directly rather than
573            // a paragraph wrapper. Preserve those events as the item's content.
574            content.push(inline);
575        } else if let Some(block) = parse_block(child, depth + 1, source, line_index) {
576            blocks.push(block);
577        }
578    }
579    Some(MarkdownListItem {
580        depth,
581        source: source_range(node.range.clone(), line_index),
582        content,
583        blocks,
584        target_id: None,
585    })
586}
587
588fn parse_code_block(
589    node: &Node,
590    kind: &CodeBlockKind<'static>,
591    source: &str,
592    line_index: &LineIndex,
593) -> MarkdownCodeBlock {
594    let source_range_value = source_range(node.range.clone(), line_index);
595    let text = node
596        .children
597        .iter()
598        .filter_map(|child| match child.event.as_ref()? {
599            Event::Text(text) => Some(text.as_ref()),
600            _ => None,
601        })
602        .collect::<String>();
603    let (info, language) = match kind {
604        CodeBlockKind::Indented => (None, None),
605        CodeBlockKind::Fenced(info) => {
606            let info = info.to_string();
607            let language = info.split_whitespace().next().map(str::to_owned);
608            (Some(info), language)
609        }
610    };
611    let content_bytes = code_content_range(node.range.clone(), kind, source);
612    let fenced = matches!(kind, CodeBlockKind::Fenced(_));
613    let code = if fenced {
614        source
615            .get(content_bytes.clone())
616            .unwrap_or(text.as_str())
617            .to_owned()
618    } else {
619        text.strip_suffix('\n').unwrap_or(&text).to_owned()
620    };
621    let first_source_line = line_index.line_for_offset(node.range.start);
622    let lines = code.is_empty().then(Vec::new).unwrap_or_else(|| {
623        code.split('\n')
624            .enumerate()
625            .map(|(index, line)| {
626                let line_offset = code_line_offset(&content_bytes, &code, index);
627                let source_line = if fenced {
628                    line_index.line_for_offset(line_offset)
629                } else {
630                    first_source_line + index
631                };
632                let line_source = if fenced {
633                    let end = line_offset + line.len();
634                    source_range(line_offset..end, line_index)
635                } else {
636                    line_index.source_line_range(source_line)
637                };
638                MarkdownCodeLine {
639                    index,
640                    source: line_source,
641                    source_line: Some(source_line),
642                    text: line.strip_suffix('\r').unwrap_or(line).to_owned(),
643                    target_id: None,
644                }
645            })
646            .collect()
647    });
648    MarkdownCodeBlock {
649        language,
650        info,
651        source: source_range_value,
652        content: source_range(content_bytes, line_index),
653        lines,
654        target_id: None,
655    }
656}
657
658fn code_line_offset(content: &Range<usize>, code: &str, index: usize) -> usize {
659    content.start
660        + code
661            .split_inclusive('\n')
662            .take(index)
663            .map(str::len)
664            .sum::<usize>()
665}
666
667fn code_content_range(
668    block: Range<usize>,
669    kind: &CodeBlockKind<'static>,
670    source: &str,
671) -> Range<usize> {
672    if !matches!(kind, CodeBlockKind::Fenced(_)) {
673        return block;
674    }
675    let start = block.start.min(source.len());
676    let end = block.end.min(source.len());
677    let opening_end = source[start..end]
678        .find('\n')
679        .map_or(end, |offset| start + offset + 1);
680    if opening_end >= end {
681        return opening_end..opening_end;
682    }
683    let opening_line = source[start..opening_end].trim_end_matches(['\r', '\n']);
684    let trimmed = opening_line.trim_start();
685    let marker = trimmed.chars().next().unwrap_or('`');
686    let marker_len = trimmed
687        .chars()
688        .take_while(|character| *character == marker)
689        .count();
690    let mut closing_start = None;
691    let mut cursor = opening_end;
692    while cursor < end {
693        let line_end = source[cursor..end]
694            .find('\n')
695            .map_or(end, |offset| cursor + offset + 1);
696        let line = source[cursor..line_end].trim_end_matches(['\r', '\n']);
697        let candidate = line.trim_start();
698        let count = candidate
699            .chars()
700            .take_while(|character| *character == marker)
701            .count();
702        if count >= marker_len && marker_len > 0 && candidate[count..].trim().is_empty() {
703            closing_start = Some(cursor);
704            break;
705        }
706        cursor = line_end;
707    }
708    let mut content_end = closing_start.unwrap_or(end);
709    if closing_start.is_some() && source[..content_end].ends_with('\n') {
710        content_end -= 1;
711        if content_end > opening_end && source.as_bytes()[content_end - 1] == b'\r' {
712            content_end -= 1;
713        }
714    }
715    opening_end.min(content_end)..content_end
716}
717
718fn parse_table(node: &Node, alignments: &[Alignment], line_index: &LineIndex) -> MarkdownTable {
719    let rows = node
720        .children
721        .iter()
722        .filter_map(|section| {
723            let header = matches!(section.event, Some(Event::Start(Tag::TableHead)));
724            if !matches!(section.event, Some(Event::Start(Tag::TableHead)))
725                && !matches!(section.event, Some(Event::Start(Tag::TableRow)))
726            {
727                return None;
728            }
729            table_row(section, header, line_index)
730        })
731        .collect();
732    MarkdownTable {
733        alignments: alignments.iter().copied().map(table_alignment).collect(),
734        rows,
735    }
736}
737
738fn table_row(node: &Node, header: bool, line_index: &LineIndex) -> Option<MarkdownTableRow> {
739    if !matches!(
740        node.event,
741        Some(Event::Start(Tag::TableRow | Tag::TableHead))
742    ) {
743        return None;
744    }
745    let cells = node
746        .children
747        .iter()
748        .filter_map(|cell| {
749            if !matches!(cell.event, Some(Event::Start(Tag::TableCell))) {
750                return None;
751            }
752            Some(MarkdownTableCell {
753                source: source_range(cell.range.clone(), line_index),
754                content: inline_children(&cell.children),
755            })
756        })
757        .collect();
758    Some(MarkdownTableRow {
759        header,
760        source: source_range(node.range.clone(), line_index),
761        cells,
762        target_id: None,
763    })
764}
765
766fn table_alignment(alignment: Alignment) -> MarkdownTableAlignment {
767    match alignment {
768        Alignment::None => MarkdownTableAlignment::None,
769        Alignment::Left => MarkdownTableAlignment::Left,
770        Alignment::Center => MarkdownTableAlignment::Center,
771        Alignment::Right => MarkdownTableAlignment::Right,
772    }
773}
774
775fn inline_children(children: &[Node]) -> Vec<MarkdownInline> {
776    children.iter().filter_map(inline_node).collect()
777}
778
779fn inline_node(node: &Node) -> Option<MarkdownInline> {
780    let event = node.event.as_ref()?;
781    Some(match event {
782        Event::Text(text) => MarkdownInline::Text(text.to_string()),
783        Event::Code(text) => MarkdownInline::Code(text.to_string()),
784        Event::Start(Tag::Strong) => MarkdownInline::Strong(inline_children(&node.children)),
785        Event::Start(Tag::Emphasis) => MarkdownInline::Emphasis(inline_children(&node.children)),
786        Event::Start(Tag::Strikethrough) => {
787            MarkdownInline::Strikethrough(inline_children(&node.children))
788        }
789        Event::Start(Tag::Link {
790            dest_url, title, ..
791        }) => MarkdownInline::Link {
792            destination: dest_url.to_string(),
793            title: (!title.is_empty()).then(|| title.to_string()),
794            content: inline_children(&node.children),
795        },
796        Event::Start(Tag::Image { .. }) => {
797            MarkdownInline::ImageAlt(rendered_text(&inline_children(&node.children)))
798        }
799        Event::SoftBreak => MarkdownInline::SoftBreak,
800        Event::HardBreak => MarkdownInline::HardBreak,
801        Event::InlineHtml(html) | Event::Html(html) => MarkdownInline::Text(html.to_string()),
802        Event::TaskListMarker(checked) => MarkdownInline::Text(if *checked {
803            "☑".to_owned()
804        } else {
805            "☐".to_owned()
806        }),
807        // Math, footnotes, and metadata are not enabled by this parser. Keep a
808        // useful textual fallback if a future option starts producing them.
809        Event::InlineMath(text) | Event::DisplayMath(text) | Event::FootnoteReference(text) => {
810            MarkdownInline::Text(text.to_string())
811        }
812        Event::Start(_) | Event::End(_) | Event::Rule => return None,
813    })
814}
815
816fn html_fallback(children: &[Node]) -> Vec<MarkdownInline> {
817    children
818        .iter()
819        .filter_map(|child| match child.event.as_ref()? {
820            Event::Html(text) | Event::InlineHtml(text) => {
821                Some(MarkdownInline::Text(text.to_string()))
822            }
823            _ => None,
824        })
825        .collect()
826}
827
828fn assign_targets(
829    blocks: &mut [MarkdownBlock],
830    targets: &mut Vec<MarkdownTarget>,
831    outline: &mut Vec<MarkdownHeading>,
832) {
833    for block in blocks {
834        let mut block_target = None;
835        match &mut block.kind {
836            MarkdownBlockKind::Heading { level, content } => {
837                let title = rendered_text(content);
838                let id = push_target(
839                    MarkdownTargetKind::Heading,
840                    block.source.clone(),
841                    title.clone(),
842                    targets,
843                );
844                block_target = Some(id);
845                outline.push(MarkdownHeading {
846                    level: *level,
847                    title,
848                    source: block.source.clone(),
849                    target_id: id,
850                });
851            }
852            MarkdownBlockKind::Paragraph { content } => {
853                block_target = Some(push_target(
854                    MarkdownTargetKind::Paragraph,
855                    block.source.clone(),
856                    rendered_text(content),
857                    targets,
858                ));
859            }
860            MarkdownBlockKind::List { items, .. } => {
861                for item in items {
862                    let id = MarkdownTargetId(targets.len());
863                    item.target_id = Some(id);
864                    targets.push(MarkdownTarget {
865                        id,
866                        kind: MarkdownTargetKind::ListItem,
867                        source: item.source.clone(),
868                        display_label: rendered_text(&item.content),
869                    });
870                    assign_nested_list_targets(&mut item.blocks, targets);
871                }
872            }
873            MarkdownBlockKind::BlockQuote { blocks: children } => {
874                block_target = Some(push_target(
875                    MarkdownTargetKind::BlockQuote,
876                    block.source.clone(),
877                    block_text(children),
878                    targets,
879                ));
880            }
881            MarkdownBlockKind::CodeBlock(code) => {
882                let id = MarkdownTargetId(targets.len());
883                code.target_id = Some(id);
884                targets.push(MarkdownTarget {
885                    id,
886                    kind: MarkdownTargetKind::CodeBlock,
887                    source: code.source.clone(),
888                    display_label: "Code block".to_owned(),
889                });
890                block_target = Some(id);
891                for line in &mut code.lines {
892                    let id = MarkdownTargetId(targets.len());
893                    line.target_id = Some(id);
894                    targets.push(MarkdownTarget {
895                        id,
896                        kind: MarkdownTargetKind::CodeLine,
897                        source: line.source.clone(),
898                        display_label: format!("Code line {}", line.index + 1),
899                    });
900                }
901            }
902            MarkdownBlockKind::Table(table) => {
903                for row in &mut table.rows {
904                    let id = MarkdownTargetId(targets.len());
905                    row.target_id = Some(id);
906                    targets.push(MarkdownTarget {
907                        id,
908                        kind: MarkdownTargetKind::TableRow,
909                        source: row.source.clone(),
910                        display_label: row_text(row),
911                    });
912                }
913            }
914            MarkdownBlockKind::HtmlFallback { .. } | MarkdownBlockKind::Rule => {}
915        }
916        block.target_id = block_target;
917    }
918}
919
920fn assign_nested_list_targets(blocks: &mut [MarkdownBlock], targets: &mut Vec<MarkdownTarget>) {
921    for block in blocks {
922        if let MarkdownBlockKind::List { items, .. } = &mut block.kind {
923            for item in items {
924                let id = MarkdownTargetId(targets.len());
925                item.target_id = Some(id);
926                targets.push(MarkdownTarget {
927                    id,
928                    kind: MarkdownTargetKind::ListItem,
929                    source: item.source.clone(),
930                    display_label: rendered_text(&item.content),
931                });
932                assign_nested_list_targets(&mut item.blocks, targets);
933            }
934        }
935    }
936}
937
938fn push_target(
939    kind: MarkdownTargetKind,
940    source: SourceRange,
941    display_label: String,
942    targets: &mut Vec<MarkdownTarget>,
943) -> MarkdownTargetId {
944    let id = MarkdownTargetId(targets.len());
945    targets.push(MarkdownTarget {
946        id,
947        kind,
948        source,
949        display_label,
950    });
951    id
952}
953
954fn block_text(blocks: &[MarkdownBlock]) -> String {
955    blocks
956        .iter()
957        .map(|block| match &block.kind {
958            MarkdownBlockKind::Heading { content, .. }
959            | MarkdownBlockKind::Paragraph { content }
960            | MarkdownBlockKind::HtmlFallback { content } => rendered_text(content),
961            MarkdownBlockKind::List { items, .. } => items
962                .iter()
963                .map(|item| rendered_text(&item.content))
964                .collect::<Vec<_>>()
965                .join(" "),
966            MarkdownBlockKind::BlockQuote { blocks } => block_text(blocks),
967            MarkdownBlockKind::CodeBlock(code) => code
968                .lines
969                .iter()
970                .map(|line| line.text.as_str())
971                .collect::<Vec<_>>()
972                .join("\n"),
973            MarkdownBlockKind::Table(table) => table
974                .rows
975                .iter()
976                .map(row_text)
977                .collect::<Vec<_>>()
978                .join(" "),
979            MarkdownBlockKind::Rule => String::new(),
980        })
981        .collect::<Vec<_>>()
982        .join(" ")
983}
984
985fn row_text(row: &MarkdownTableRow) -> String {
986    row.cells
987        .iter()
988        .map(|cell| rendered_text(&cell.content))
989        .collect::<Vec<_>>()
990        .join(" | ")
991}
992
993fn heading_level(level: HeadingLevel) -> u8 {
994    match level {
995        HeadingLevel::H1 => 1,
996        HeadingLevel::H2 => 2,
997        HeadingLevel::H3 => 3,
998        HeadingLevel::H4 => 4,
999        HeadingLevel::H5 => 5,
1000        HeadingLevel::H6 => 6,
1001    }
1002}
1003
1004#[derive(Debug, Clone)]
1005struct LineIndex {
1006    starts: Vec<usize>,
1007    content_ends: Vec<usize>,
1008    source_len: usize,
1009}
1010
1011impl LineIndex {
1012    fn new(source: &str) -> Self {
1013        let mut starts = vec![0];
1014        let mut content_ends = Vec::new();
1015        for (index, byte) in source.bytes().enumerate() {
1016            if byte == b'\n' {
1017                content_ends.push(index.saturating_sub(usize::from(
1018                    index > 0 && source.as_bytes()[index - 1] == b'\r',
1019                )));
1020                starts.push(index + 1);
1021            }
1022        }
1023        content_ends.push(source.len());
1024        Self {
1025            starts,
1026            content_ends,
1027            source_len: source.len(),
1028        }
1029    }
1030
1031    fn line_for_offset(&self, offset: usize) -> usize {
1032        let offset = offset.min(*self.starts.last().unwrap_or(&0));
1033        self.starts.partition_point(|start| *start <= offset)
1034    }
1035
1036    fn range(&self, range: Range<usize>) -> MarkdownLineRange {
1037        let start = self.line_for_offset(range.start);
1038        let end_offset = range.end.saturating_sub(1).max(range.start);
1039        let end = self.line_for_offset(end_offset).max(start);
1040        MarkdownLineRange { start, end }
1041    }
1042
1043    fn source_line_range(&self, line: usize) -> SourceRange {
1044        let start = self
1045            .starts
1046            .get(line.saturating_sub(1))
1047            .copied()
1048            .unwrap_or(0);
1049        let end = self
1050            .content_ends
1051            .get(line.saturating_sub(1))
1052            .copied()
1053            .unwrap_or(self.source_len)
1054            .max(start);
1055        SourceRange {
1056            bytes: start..end,
1057            lines: MarkdownLineRange {
1058                start: line,
1059                end: line,
1060            },
1061        }
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    #[test]
1070    fn parses_targets_in_document_order() {
1071        let document = MarkdownDocument::parse("# Title\n\nText\n\n```rust\nlet x = 1;\n```\n");
1072        assert_eq!(document.targets().len(), 4);
1073        assert_eq!(document.targets()[0].kind, MarkdownTargetKind::Heading);
1074        assert_eq!(document.targets()[1].kind, MarkdownTargetKind::Paragraph);
1075        assert_eq!(document.targets()[2].kind, MarkdownTargetKind::CodeBlock);
1076        assert_eq!(document.targets()[3].kind, MarkdownTargetKind::CodeLine);
1077        assert_eq!(document.outline()[0].title, "Title");
1078    }
1079
1080    #[test]
1081    fn preserves_source_and_maps_crlf_lines() {
1082        let source = "# Héading\r\n\r\n```\r\none\r\ntwo\r\n```\r\n";
1083        let document = MarkdownDocument::parse(source);
1084        assert_eq!(document.source(), source);
1085        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[1].kind else {
1086            panic!("expected code block")
1087        };
1088        assert_eq!(
1089            code.lines
1090                .iter()
1091                .map(|line| line.source_line)
1092                .collect::<Vec<_>>(),
1093            vec![Some(4), Some(5)]
1094        );
1095        assert_eq!(code.lines[0].text, "one");
1096    }
1097
1098    #[test]
1099    fn parses_nested_inline_nodes_and_table_rows() {
1100        let document = MarkdownDocument::parse(
1101            "**bold _em_** and ~~gone~~ [link](url) ![alt](image)\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n",
1102        );
1103        assert!(matches!(
1104            document.blocks()[0].kind,
1105            MarkdownBlockKind::Paragraph { .. }
1106        ));
1107        let MarkdownBlockKind::Table(table) = &document.blocks()[1].kind else {
1108            panic!("expected table")
1109        };
1110        assert_eq!(table.rows.len(), 2);
1111        assert_eq!(
1112            document
1113                .targets()
1114                .iter()
1115                .filter(|target| target.kind == MarkdownTargetKind::TableRow)
1116                .count(),
1117            2
1118        );
1119    }
1120
1121    #[test]
1122    fn longer_outer_fence_does_not_close_on_shorter_run() {
1123        let document = MarkdownDocument::parse("````\n```\nstill code\n````\n");
1124        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[0].kind else {
1125            panic!("expected code block")
1126        };
1127        assert_eq!(code.lines.len(), 2);
1128        assert_eq!(code.lines[0].text, "```");
1129    }
1130
1131    #[test]
1132    fn blockquote_is_the_only_target_for_its_contents() {
1133        let document = MarkdownDocument::parse("> # quoted\n>\n> text\n");
1134        assert_eq!(document.targets().len(), 1);
1135        assert_eq!(document.targets()[0].kind, MarkdownTargetKind::BlockQuote);
1136    }
1137
1138    #[test]
1139    fn nested_list_items_are_targets_without_overlapping_paragraph_targets() {
1140        let document = MarkdownDocument::parse("1. outer\n   - inner\n   - second\n2. last\n");
1141        let kinds = document
1142            .targets()
1143            .iter()
1144            .map(|target| target.kind)
1145            .collect::<Vec<_>>();
1146        assert_eq!(
1147            kinds,
1148            vec![
1149                MarkdownTargetKind::ListItem,
1150                MarkdownTargetKind::ListItem,
1151                MarkdownTargetKind::ListItem,
1152                MarkdownTargetKind::ListItem,
1153            ]
1154        );
1155        let MarkdownBlockKind::List { items, .. } = &document.blocks()[0].kind else {
1156            panic!("expected list")
1157        };
1158        assert_eq!(items[0].depth, 0);
1159        assert_eq!(items[0].blocks.len(), 1);
1160    }
1161
1162    #[test]
1163    fn indented_code_strips_parser_indentation() {
1164        let document = MarkdownDocument::parse("    first\n    second\n");
1165        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[0].kind else {
1166            panic!("expected code block")
1167        };
1168        assert_eq!(
1169            code.lines
1170                .iter()
1171                .map(|line| line.text.as_str())
1172                .collect::<Vec<_>>(),
1173            ["first", "second"]
1174        );
1175        assert_eq!(code.lines[0].source_line, Some(1));
1176        assert_eq!(code.lines[1].source.bytes, 10..20);
1177    }
1178
1179    #[test]
1180    fn indented_code_preserves_blank_content_lines() {
1181        let document = MarkdownDocument::parse("    one\n\n    two\n");
1182        let MarkdownBlockKind::CodeBlock(code) = &document.blocks()[0].kind else {
1183            panic!("expected code block")
1184        };
1185        assert_eq!(
1186            code.lines
1187                .iter()
1188                .map(|line| line.text.as_str())
1189                .collect::<Vec<_>>(),
1190            ["one", "", "two"]
1191        );
1192        assert_eq!(
1193            code.lines
1194                .iter()
1195                .map(|line| line.source_line)
1196                .collect::<Vec<_>>(),
1197            [Some(1), Some(2), Some(3)]
1198        );
1199    }
1200
1201    #[test]
1202    fn raw_html_is_plain_fallback_without_a_target() {
1203        let document = MarkdownDocument::parse("<div>not executed</div>\n");
1204        assert!(matches!(
1205            document.blocks()[0].kind,
1206            MarkdownBlockKind::HtmlFallback { .. }
1207        ));
1208        assert!(document.targets().is_empty());
1209    }
1210
1211    #[test]
1212    fn empty_and_malformed_documents_are_safe() {
1213        assert!(MarkdownDocument::parse("").targets().is_empty());
1214        let document = MarkdownDocument::parse("# unclosed **emphasis\n\n```rust\ncode");
1215        assert!(!document.blocks().is_empty());
1216    }
1217}