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