Skip to main content

mdi_core/
text_projection.rs

1use crate::{
2    Diagnostic, DiagnosticSeverity, Document, MDI_IR_VERSION, MDI_SPEC_VERSION, ParserCapabilities,
3    SourceSpan, diagnostics, parse_document,
4};
5use serde::Serialize;
6use unicode_segmentation::UnicodeSegmentation;
7
8pub(crate) enum PlainInline<'a> {
9    Value(&'a str),
10    Break,
11    Skip,
12    Children,
13}
14
15/// The single plaintext rule table used by both `render_text` and the mapped
16/// block projection. Source-map construction is layered on top of this value.
17pub(crate) fn plain_inline(node: &serde_json::Value) -> PlainInline<'_> {
18    match node_type(node) {
19        "text" | "inlineCode" | "code" | "html" | "tcy" => PlainInline::Value(
20            node.get("value")
21                .and_then(serde_json::Value::as_str)
22                .unwrap_or_default(),
23        ),
24        "ruby" => PlainInline::Value(
25            node.get("base")
26                .and_then(serde_json::Value::as_str)
27                .unwrap_or_default(),
28        ),
29        "image" => PlainInline::Value(
30            node.get("alt")
31                .and_then(serde_json::Value::as_str)
32                .unwrap_or_default(),
33        ),
34        "break" => PlainInline::Break,
35        "footnoteReference" => PlainInline::Skip,
36        _ if node.get("children").is_some() => PlainInline::Children,
37        _ => node
38            .get("value")
39            .and_then(serde_json::Value::as_str)
40            .map_or(PlainInline::Skip, PlainInline::Value),
41    }
42}
43
44pub const MDI_TEXT_PROJECTION_VERSION: &str = "1.0";
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct MdiTextBlocksResult {
49    pub projection_version: &'static str,
50    pub position_encoding: &'static str,
51    pub ir_version: &'static str,
52    pub syntax_version: &'static str,
53    pub capabilities: ParserCapabilities,
54    pub blocks: Vec<MdiTextBlock>,
55    pub document: Document,
56    pub diagnostics: Vec<Diagnostic>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct MdiTextBlock {
62    pub index: u32,
63    pub kind: MdiTextBlockKind,
64    pub text: String,
65    pub range: MdiTextRange,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub span: Option<SourceSpan>,
68    pub source_map: MdiTextSourceMap,
69    pub annotations: Vec<MdiTextAnnotation>,
70    pub node: serde_json::Value,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub enum MdiTextBlockKind {
76    Heading,
77    Paragraph,
78    ListItem,
79    Blockquote,
80    Code,
81    Table,
82    Footnote,
83    Html,
84    Other,
85}
86
87/// A one-based block/Unicode-grapheme position, serialized as `3:18`.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct MdiTextPosition {
90    pub block: u32,
91    pub character: u32,
92}
93
94impl Serialize for MdiTextPosition {
95    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
96    where
97        S: serde::Serializer,
98    {
99        serializer.serialize_str(&format!("{}:{}", self.block, self.character))
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104pub struct MdiTextRange {
105    pub start: MdiTextPosition,
106    pub end: MdiTextPosition,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
110pub struct MdiTextSourceMap {
111    pub runs: Vec<MdiTextSourceRun>,
112    pub synthetic: Vec<MdiTextRange>,
113    pub unmapped: Vec<MdiTextRange>,
114}
115
116/// Annotation text uses the containing block number and its own one-based
117/// character offsets. This keeps the run format identical in both channels.
118pub type MdiAnnotationSourceMap = MdiTextSourceMap;
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "camelCase")]
122pub struct MdiTextSourceRun {
123    pub range: MdiTextRange,
124    pub source_boundaries: Vec<u32>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct MdiTextAnnotation {
130    pub kind: &'static str,
131    pub text: String,
132    pub anchor: MdiTextRange,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub span: Option<SourceSpan>,
135    pub source_map: MdiAnnotationSourceMap,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum UnitMap {
140    Mapped(SourceSpan),
141    Synthetic,
142    Unmapped,
143}
144
145struct AnnotationDraft {
146    text: String,
147    anchor_start: usize,
148    anchor_end: usize,
149    span: Option<SourceSpan>,
150    units: Vec<UnitMap>,
151}
152
153struct BlockDraft {
154    kind: MdiTextBlockKind,
155    text: String,
156    units: Vec<UnitMap>,
157    unit_texts: Vec<String>,
158    annotations: Vec<AnnotationDraft>,
159    span: Option<SourceSpan>,
160    node: serde_json::Value,
161    mapping_warning: bool,
162    source_cursor: u32,
163    source_end: u32,
164}
165
166impl BlockDraft {
167    fn new(kind: MdiTextBlockKind, node: &serde_json::Value) -> Self {
168        let span = node_span(node);
169        Self {
170            kind,
171            text: String::new(),
172            units: Vec::new(),
173            unit_texts: Vec::new(),
174            annotations: Vec::new(),
175            span,
176            node: node.clone(),
177            mapping_warning: false,
178            source_cursor: span.map_or(0, |span| span.start_byte),
179            source_end: span.map_or(0, |span| span.end_byte),
180        }
181    }
182
183    fn grapheme_len(&self) -> usize {
184        self.text.graphemes(true).count()
185    }
186
187    fn append_synthetic(&mut self, value: &str) {
188        self.text.push_str(value);
189        for grapheme in value.graphemes(true) {
190            self.unit_texts.push(grapheme.to_owned());
191            self.units.push(UnitMap::Synthetic);
192        }
193    }
194
195    fn append_unmapped(&mut self, value: &str) {
196        if value.is_empty() {
197            return;
198        }
199        self.text.push_str(value);
200        for grapheme in value.graphemes(true) {
201            self.unit_texts.push(grapheme.to_owned());
202            self.units.push(UnitMap::Unmapped);
203        }
204        self.mapping_warning = true;
205    }
206
207    fn append_mapped(&mut self, value: &str, spans: Option<Vec<SourceSpan>>) {
208        if value.is_empty() {
209            return;
210        }
211        let count = value.graphemes(true).count();
212        match spans {
213            Some(spans) if spans.len() == count => {
214                self.text.push_str(value);
215                for (grapheme, span) in value.graphemes(true).zip(spans) {
216                    self.unit_texts.push(grapheme.to_owned());
217                    self.units.push(UnitMap::Mapped(span));
218                }
219            }
220            _ => self.append_unmapped(value),
221        }
222    }
223}
224
225struct Collector<'a> {
226    source: &'a str,
227    blocks: Vec<MdiTextBlock>,
228    diagnostics: Vec<Diagnostic>,
229}
230
231/// Parse once and produce the complete IR envelope plus the Rust-owned text
232/// projection and its UTF-8 source map.
233pub fn get_mdi_text_blocks(source: &str) -> MdiTextBlocksResult {
234    let document = parse_document(source);
235    let mut collector = Collector {
236        source,
237        blocks: Vec::new(),
238        diagnostics: diagnostics(&document),
239    };
240    for node in &document.children {
241        collector.collect(node, false);
242    }
243    MdiTextBlocksResult {
244        projection_version: MDI_TEXT_PROJECTION_VERSION,
245        position_encoding: "unicode-grapheme-cluster-1-based",
246        ir_version: MDI_IR_VERSION,
247        syntax_version: MDI_SPEC_VERSION,
248        capabilities: ParserCapabilities {
249            mdi: true,
250            common_mark: true,
251            gfm: true,
252            front_matter: true,
253            source_spans: true,
254        },
255        blocks: collector.blocks,
256        document,
257        diagnostics: collector.diagnostics,
258    }
259}
260
261pub fn get_mdi_text_blocks_json(source: &str) -> String {
262    serde_json::to_string(&get_mdi_text_blocks(source))
263        .expect("serializing the MDI text projection cannot fail")
264}
265
266impl Collector<'_> {
267    fn collect(&mut self, node: &serde_json::Value, quoted: bool) {
268        let kind = node_type(node);
269        match kind {
270            "heading" => self.inline_block(MdiTextBlockKind::Heading, node),
271            "paragraph" => self.inline_block(
272                if quoted {
273                    MdiTextBlockKind::Blockquote
274                } else {
275                    MdiTextBlockKind::Paragraph
276                },
277                node,
278            ),
279            "blockquote" => {
280                for child in children(node) {
281                    self.collect(child, true);
282                }
283            }
284            "list" => {
285                for child in children(node) {
286                    self.collect(child, quoted);
287                }
288            }
289            "listItem" => self.list_item(node, quoted),
290            "code" => self.scalar_block(MdiTextBlockKind::Code, node, "value"),
291            "html" => self.scalar_block(MdiTextBlockKind::Html, node, "value"),
292            "table" => self.table(node),
293            "footnoteDefinition" => self.footnote(node),
294            "yaml" | "definition" | "blank" | "pagebreak" | "thematicBreak" => {}
295            _ => {
296                if node_span(node).is_some() {
297                    let mut draft = BlockDraft::new(MdiTextBlockKind::Other, node);
298                    self.project_inline(node, &mut draft);
299                    self.finish(draft);
300                }
301            }
302        }
303    }
304
305    fn inline_block(&mut self, kind: MdiTextBlockKind, node: &serde_json::Value) {
306        let mut draft = BlockDraft::new(kind, node);
307        self.project_children(node, &mut draft);
308        self.finish(draft);
309    }
310
311    fn scalar_block(&mut self, kind: MdiTextBlockKind, node: &serde_json::Value, field: &str) {
312        let mut draft = BlockDraft::new(kind, node);
313        if let Some(value) = node.get(field).and_then(serde_json::Value::as_str) {
314            // markdown-rs preserves the source line ending inside fenced code.
315            // Projection coordinates use a single `\n` text unit regardless of
316            // whether that unit came from LF or CRLF source bytes.
317            let normalized;
318            let value = if kind == MdiTextBlockKind::Code && value.contains('\r') {
319                normalized = value.replace("\r\n", "\n").replace('\r', "\n");
320                normalized.as_str()
321            } else {
322                value
323            };
324            let spans = if kind == MdiTextBlockKind::Code {
325                self.map_code_block(value, &mut draft)
326            } else {
327                self.map_value_in_block(value, &mut draft)
328            };
329            draft.append_mapped(value, spans);
330        }
331        self.finish(draft);
332    }
333
334    fn list_item(&mut self, node: &serde_json::Value, quoted: bool) {
335        let paragraphs: Vec<_> = children(node)
336            .filter(|child| node_type(child) == "paragraph")
337            .collect();
338        if !paragraphs.is_empty() {
339            let mut draft = BlockDraft::new(MdiTextBlockKind::ListItem, node);
340            for (index, paragraph) in paragraphs.into_iter().enumerate() {
341                if index > 0 {
342                    draft.append_synthetic("\n\n");
343                }
344                self.project_children(paragraph, &mut draft);
345            }
346            self.finish(draft);
347        }
348        for child in children(node) {
349            if node_type(child) != "paragraph" {
350                self.collect(child, quoted);
351            }
352        }
353    }
354
355    fn footnote(&mut self, node: &serde_json::Value) {
356        let mut draft = BlockDraft::new(MdiTextBlockKind::Footnote, node);
357        for (index, child) in children(node).enumerate() {
358            if index > 0 {
359                draft.append_synthetic("\n\n");
360            }
361            if node_type(child) == "paragraph" {
362                self.project_children(child, &mut draft);
363            } else {
364                self.project_inline(child, &mut draft);
365            }
366        }
367        self.finish(draft);
368    }
369
370    fn table(&mut self, node: &serde_json::Value) {
371        let mut draft = BlockDraft::new(MdiTextBlockKind::Table, node);
372        for (row_index, row) in children(node).enumerate() {
373            if row_index > 0 {
374                draft.append_synthetic("\n");
375            }
376            for (cell_index, cell) in children(row).enumerate() {
377                if cell_index > 0 {
378                    draft.append_synthetic("\t");
379                }
380                self.project_children(cell, &mut draft);
381            }
382        }
383        self.finish(draft);
384    }
385
386    fn project_children(&mut self, node: &serde_json::Value, draft: &mut BlockDraft) {
387        for child in children(node) {
388            self.project_inline(child, draft);
389        }
390    }
391
392    fn project_inline(&mut self, node: &serde_json::Value, draft: &mut BlockDraft) {
393        match plain_inline(node) {
394            PlainInline::Value(value) => {
395                if node_type(node) == "ruby" {
396                    self.project_ruby(node, draft);
397                    return;
398                }
399                let spans = match node_type(node) {
400                    "tcy" => self.map_delimited_in_block(value, &mut *draft, '^', '^'),
401                    "image" => self.map_image_in_block(value, draft),
402                    "inlineCode" => self.map_inline_code_in_block(value, draft),
403                    _ => self.map_value_from_node(value, node, draft),
404                };
405                draft.append_mapped(value, spans);
406            }
407            PlainInline::Break => {
408                let spans = self.map_break_in_block(draft);
409                draft.append_mapped("\n", spans);
410            }
411            PlainInline::Skip => {}
412            PlainInline::Children => {
413                self.project_children(node, draft);
414                self.advance_after_container(node, draft);
415            }
416        }
417    }
418
419    fn project_ruby(&mut self, node: &serde_json::Value, draft: &mut BlockDraft) {
420        let base = node
421            .get("base")
422            .and_then(serde_json::Value::as_str)
423            .unwrap_or_default();
424        let base_start = draft.grapheme_len();
425        let reading_value = node
426            .pointer("/ruby/value")
427            .map(|value| match value {
428                serde_json::Value::String(value) => value.clone(),
429                serde_json::Value::Array(values) => values
430                    .iter()
431                    .filter_map(serde_json::Value::as_str)
432                    .collect::<String>(),
433                _ => String::new(),
434            })
435            .unwrap_or_default();
436        let parts = find_ruby_parts(
437            base,
438            &reading_value,
439            self.source,
440            draft.source_cursor,
441            draft.source_end,
442        );
443        if let Some(parts) = &parts {
444            draft.source_cursor = parts.token_end;
445        }
446        let base_spans = parts
447            .as_ref()
448            .and_then(|parts| map_decoded(base, parts.base, parts.base_start));
449        draft.append_mapped(base, base_spans);
450        let base_end = draft.grapheme_len();
451
452        let ruby = node.get("ruby");
453        let ruby_type = ruby
454            .and_then(|value| value.get("type"))
455            .and_then(serde_json::Value::as_str)
456            .unwrap_or("group");
457        if ruby_type == "split" {
458            let readings = ruby
459                .and_then(|value| value.get("value"))
460                .and_then(serde_json::Value::as_array);
461            if let Some(readings) = readings {
462                let raw_parts = parts.as_ref().map(|value| split_raw_reading(value));
463                for (index, reading) in readings
464                    .iter()
465                    .filter_map(serde_json::Value::as_str)
466                    .enumerate()
467                {
468                    let raw = raw_parts.as_ref().and_then(|parts| parts.get(index));
469                    let units = annotation_units(reading, raw.copied());
470                    draft.annotations.push(AnnotationDraft {
471                        text: reading.to_owned(),
472                        anchor_start: base_start + index,
473                        anchor_end: base_start + index + 1,
474                        span: raw.map(|part| SourceSpan {
475                            start_byte: part.1,
476                            end_byte: part.1 + part.0.len() as u32,
477                        }),
478                        units,
479                    });
480                }
481                return;
482            }
483        }
484
485        let reading = ruby
486            .and_then(|value| value.get("value"))
487            .and_then(serde_json::Value::as_str)
488            .unwrap_or_default();
489        if reading.is_empty() {
490            return;
491        }
492        let raw = parts
493            .as_ref()
494            .map(|parts| (parts.reading, parts.reading_start));
495        let had_split_syntax = parts
496            .as_ref()
497            .is_some_and(|parts| split_unescaped_offsets(parts.reading, '.').len() > 1);
498        if had_split_syntax {
499            self.diagnostics.push(Diagnostic {
500                severity: DiagnosticSeverity::Warning,
501                code: "mdi.textProjection.rubySplitMismatch".to_owned(),
502                message: "split ruby component count does not match the base grapheme count; the reading was anchored to the complete base".to_owned(),
503                span: parts.as_ref().map(|parts| SourceSpan {
504                    start_byte: parts.token_start,
505                    end_byte: parts.token_end,
506                }),
507            });
508        }
509        draft.annotations.push(AnnotationDraft {
510            text: reading.to_owned(),
511            anchor_start: base_start,
512            anchor_end: base_end,
513            span: raw.map(|(raw, start)| SourceSpan {
514                start_byte: start,
515                end_byte: start + raw.len() as u32,
516            }),
517            units: annotation_ruby_units(reading, raw),
518        });
519    }
520
521    fn finish(&mut self, draft: BlockDraft) {
522        if draft.text.is_empty() {
523            return;
524        }
525        let index = self.blocks.len() as u32 + 1;
526        let grapheme_count = draft.text.graphemes(true).count();
527        let units = normalize_units(&draft.text, &draft.unit_texts, &draft.units);
528        let mapping_warning = draft.mapping_warning
529            || units.contains(&UnitMap::Unmapped)
530            || draft
531                .annotations
532                .iter()
533                .any(|annotation| annotation.units.contains(&UnitMap::Unmapped));
534        if mapping_warning {
535            self.diagnostics.push(Diagnostic {
536                severity: DiagnosticSeverity::Warning,
537                code: "mdi.textProjection.unmapped".to_owned(),
538                message: format!(
539                    "text block {index} contains text that could not be mapped precisely"
540                ),
541                span: draft.span,
542            });
543        }
544        let annotations = draft
545            .annotations
546            .into_iter()
547            .map(|annotation| MdiTextAnnotation {
548                kind: "rubyReading",
549                text: annotation.text,
550                anchor: text_range(index, annotation.anchor_start, annotation.anchor_end),
551                span: annotation.span,
552                source_map: source_map(index, &annotation.units),
553            })
554            .collect();
555        self.blocks.push(MdiTextBlock {
556            index,
557            kind: draft.kind,
558            text: draft.text,
559            range: text_range(index, 0, grapheme_count),
560            span: draft.span,
561            source_map: source_map(index, &units),
562            annotations,
563            node: draft.node,
564        });
565    }
566
567    fn map_value_in_block(&self, value: &str, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
568        let mapped = find_mapped_value(value, self.source, draft.source_cursor, draft.source_end)?;
569        draft.source_cursor = mapped.consumed_end;
570        Some(mapped.spans)
571    }
572
573    fn map_value_from_node(
574        &self,
575        value: &str,
576        node: &serde_json::Value,
577        draft: &mut BlockDraft,
578    ) -> Option<Vec<SourceSpan>> {
579        if let Some(span) = node_span(node) {
580            let mut suggested = span.start_byte;
581            if suggested > draft.source_cursor
582                && self.source.as_bytes().get(suggested as usize - 1) == Some(&b'\\')
583            {
584                suggested -= 1;
585            }
586            if suggested >= draft.source_cursor && suggested <= draft.source_end {
587                draft.source_cursor = suggested;
588            }
589        }
590        self.map_value_in_block(value, draft)
591    }
592
593    fn map_delimited_in_block(
594        &self,
595        value: &str,
596        draft: &mut BlockDraft,
597        open: char,
598        close: char,
599    ) -> Option<Vec<SourceSpan>> {
600        let needle = format!("{open}{value}{close}");
601        let raw = self
602            .source
603            .get(draft.source_cursor as usize..draft.source_end as usize)?;
604        let offset = raw.find(&needle)?;
605        let token_start = draft.source_cursor + offset as u32;
606        draft.source_cursor = token_start + needle.len() as u32;
607        map_direct(value, token_start + open.len_utf8() as u32)
608    }
609
610    fn map_image_in_block(&self, value: &str, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
611        let raw = self
612            .source
613            .get(draft.source_cursor as usize..draft.source_end as usize)?;
614        let image_start = raw.find("![")?;
615        let alt_start = image_start + 2;
616        let alt_end = first_unescaped(&raw[alt_start..], ']')? + alt_start;
617        let mapped = map_decoded(
618            value,
619            &raw[alt_start..alt_end],
620            draft.source_cursor + alt_start as u32,
621        )?;
622        let consumed = raw[alt_end..]
623            .find(')')
624            .map_or(alt_end + 1, |end| alt_end + end + 1);
625        draft.source_cursor += consumed as u32;
626        Some(mapped)
627    }
628
629    fn map_inline_code_in_block(
630        &self,
631        value: &str,
632        draft: &mut BlockDraft,
633    ) -> Option<Vec<SourceSpan>> {
634        let raw = self
635            .source
636            .get(draft.source_cursor as usize..draft.source_end as usize)?;
637        for (offset, _) in raw.match_indices('`') {
638            let opening = raw[offset..]
639                .chars()
640                .take_while(|character| *character == '`')
641                .count();
642            let delimiter = "`".repeat(opening);
643            let inner_start = offset + opening;
644            let Some(close_offset) = raw[inner_start..].find(&delimiter) else {
645                continue;
646            };
647            let inner_end = inner_start + close_offset;
648            let inner = &raw[inner_start..inner_end];
649            let mut normalized = String::new();
650            let mut spans = Vec::new();
651            for (grapheme_offset, grapheme) in inner.grapheme_indices(true) {
652                normalized.push_str(if grapheme == "\n" || grapheme == "\r\n" {
653                    " "
654                } else {
655                    grapheme
656                });
657                spans.push(SourceSpan {
658                    start_byte: draft.source_cursor + inner_start as u32 + grapheme_offset as u32,
659                    end_byte: draft.source_cursor
660                        + inner_start as u32
661                        + grapheme_offset as u32
662                        + grapheme.len() as u32,
663                });
664            }
665            if normalized.starts_with(' ')
666                && normalized.ends_with(' ')
667                && normalized.chars().any(|character| character != ' ')
668            {
669                normalized.remove(0);
670                normalized.pop();
671                spans.remove(0);
672                spans.pop();
673            }
674            if normalized == value {
675                draft.source_cursor += (inner_end + opening) as u32;
676                return Some(spans);
677            }
678        }
679        None
680    }
681
682    fn map_code_block(&self, value: &str, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
683        let raw = self
684            .source
685            .get(draft.source_cursor as usize..draft.source_end as usize)?;
686        let trimmed = raw.trim_start_matches([' ', '\t']);
687        let fenced = trimmed.starts_with("```") || trimmed.starts_with("~~~");
688        if fenced {
689            let opening_end = raw.find('\n')? + 1;
690            draft.source_cursor += opening_end as u32;
691        }
692        self.map_value_in_block(value, draft)
693    }
694
695    fn advance_after_container(&self, node: &serde_json::Value, draft: &mut BlockDraft) {
696        let Some(raw) = self
697            .source
698            .get(draft.source_cursor as usize..draft.source_end as usize)
699        else {
700            return;
701        };
702        let consumed = match node_type(node) {
703            "link" => raw.find(']').map(|label_end| {
704                let after_label = label_end + 1;
705                if raw[after_label..].starts_with('(') {
706                    raw[after_label + 1..]
707                        .find(')')
708                        .map_or(after_label, |end| after_label + end + 2)
709                } else if raw[after_label..].starts_with('[') {
710                    raw[after_label + 1..]
711                        .find(']')
712                        .map_or(after_label, |end| after_label + end + 2)
713                } else {
714                    after_label
715                }
716            }),
717            "noBreak" | "warichu" | "kern" => raw.find("\x5d\x5d").map(|offset| offset + 2),
718            "em" => raw
719                .find("\x5d\x5d")
720                .map(|offset| offset + 2)
721                .or_else(|| raw.find("》》").map(|offset| offset + "》》".len())),
722            "emphasis" | "strong" | "delete" => {
723                let width = if node_type(node) == "emphasis" { 1 } else { 2 };
724                raw.char_indices()
725                    .find(|(_, character)| matches!(character, '*' | '_' | '~'))
726                    .map(|(offset, character)| offset + character.len_utf8() * width)
727            }
728            _ => None,
729        };
730        if let Some(consumed) = consumed {
731            draft.source_cursor += consumed as u32;
732        }
733    }
734
735    fn map_break_in_block(&self, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
736        let raw = self
737            .source
738            .get(draft.source_cursor as usize..draft.source_end as usize)?;
739        if let Some(offset) = raw.find("[[br]]") {
740            let span = SourceSpan {
741                start_byte: draft.source_cursor + offset as u32,
742                end_byte: draft.source_cursor + offset as u32 + "[[br]]".len() as u32,
743            };
744            draft.source_cursor = span.end_byte;
745            return Some(vec![span]);
746        }
747        let newline = raw.find('\n')?;
748        let prefix = &raw[..newline];
749        let marker_prefix = prefix.strip_suffix('\r').unwrap_or(prefix);
750        let marker_start = marker_prefix
751            .rfind('\\')
752            .unwrap_or_else(|| marker_prefix.trim_end_matches(' ').len());
753        let span = SourceSpan {
754            start_byte: draft.source_cursor + marker_start as u32,
755            end_byte: draft.source_cursor + newline as u32 + 1,
756        };
757        draft.source_cursor = span.end_byte;
758        Some(vec![span])
759    }
760}
761
762fn annotation_units(value: &str, raw: Option<(&str, u32)>) -> Vec<UnitMap> {
763    raw.and_then(|(raw, start)| map_decoded(value, raw, start))
764        .map(|spans| spans.into_iter().map(UnitMap::Mapped).collect())
765        .unwrap_or_else(|| vec![UnitMap::Unmapped; value.graphemes(true).count()])
766}
767
768fn annotation_ruby_units(value: &str, raw: Option<(&str, u32)>) -> Vec<UnitMap> {
769    let Some((raw, start)) = raw else {
770        return vec![UnitMap::Unmapped; value.graphemes(true).count()];
771    };
772    let mut decoded = String::new();
773    let mut spans = Vec::new();
774    for (part_start, part_end) in split_unescaped_offsets(raw, '.') {
775        let part = &raw[part_start..part_end];
776        let part_decoded: String = decoded_atoms(part, start + part_start as u32)
777            .iter()
778            .map(|atom| atom.text.as_str())
779            .collect();
780        let Some(mut part_spans) = map_decoded(&part_decoded, part, start + part_start as u32)
781        else {
782            return vec![UnitMap::Unmapped; value.graphemes(true).count()];
783        };
784        decoded.push_str(&part_decoded);
785        spans.append(&mut part_spans);
786    }
787    if decoded == value && spans.len() == value.graphemes(true).count() {
788        spans.into_iter().map(UnitMap::Mapped).collect()
789    } else {
790        vec![UnitMap::Unmapped; value.graphemes(true).count()]
791    }
792}
793
794fn normalize_units(text: &str, unit_texts: &[String], units: &[UnitMap]) -> Vec<UnitMap> {
795    if unit_texts.len() == units.len()
796        && unit_texts
797            .iter()
798            .map(String::as_str)
799            .eq(text.graphemes(true))
800    {
801        return units.to_vec();
802    }
803    let mut pieces = Vec::with_capacity(unit_texts.len());
804    let mut offset = 0;
805    for (unit_text, unit) in unit_texts.iter().zip(units) {
806        let end = offset + unit_text.len();
807        pieces.push((offset, end, *unit));
808        offset = end;
809    }
810    if offset != text.len() {
811        return vec![UnitMap::Unmapped; text.graphemes(true).count()];
812    }
813    text.grapheme_indices(true)
814        .map(|(start, grapheme)| {
815            let end = start + grapheme.len();
816            let overlapping: Vec<_> = pieces
817                .iter()
818                .filter(|(piece_start, piece_end, _)| *piece_start < end && *piece_end > start)
819                .map(|(_, _, unit)| *unit)
820                .collect();
821            if overlapping
822                .iter()
823                .all(|unit| matches!(unit, UnitMap::Mapped(_)))
824            {
825                let first = match overlapping.first() {
826                    Some(UnitMap::Mapped(span)) => *span,
827                    _ => return UnitMap::Unmapped,
828                };
829                let last = match overlapping.last() {
830                    Some(UnitMap::Mapped(span)) => *span,
831                    _ => return UnitMap::Unmapped,
832                };
833                UnitMap::Mapped(SourceSpan {
834                    start_byte: first.start_byte,
835                    end_byte: last.end_byte,
836                })
837            } else if overlapping
838                .iter()
839                .all(|unit| matches!(unit, UnitMap::Synthetic))
840            {
841                UnitMap::Synthetic
842            } else {
843                UnitMap::Unmapped
844            }
845        })
846        .collect()
847}
848
849fn source_map(block: u32, units: &[UnitMap]) -> MdiTextSourceMap {
850    let mut map = MdiTextSourceMap::default();
851    let mut index = 0;
852    while index < units.len() {
853        match units[index] {
854            UnitMap::Mapped(first) => {
855                let start = index;
856                let mut boundaries = vec![first.start_byte, first.end_byte];
857                index += 1;
858                while let Some(UnitMap::Mapped(next)) = units.get(index).copied() {
859                    if boundaries.last().copied() != Some(next.start_byte) {
860                        break;
861                    }
862                    boundaries.push(next.end_byte);
863                    index += 1;
864                }
865                map.runs.push(MdiTextSourceRun {
866                    range: text_range(block, start, index),
867                    source_boundaries: boundaries,
868                });
869            }
870            UnitMap::Synthetic => {
871                let start = index;
872                while matches!(units.get(index), Some(UnitMap::Synthetic)) {
873                    index += 1;
874                }
875                map.synthetic.push(text_range(block, start, index));
876            }
877            UnitMap::Unmapped => {
878                let start = index;
879                while matches!(units.get(index), Some(UnitMap::Unmapped)) {
880                    index += 1;
881                }
882                map.unmapped.push(text_range(block, start, index));
883            }
884        }
885    }
886    map
887}
888
889fn text_range(block: u32, start: usize, end: usize) -> MdiTextRange {
890    MdiTextRange {
891        start: MdiTextPosition {
892            block,
893            character: start as u32 + 1,
894        },
895        end: MdiTextPosition {
896            block,
897            character: end as u32 + 1,
898        },
899    }
900}
901
902fn node_type(node: &serde_json::Value) -> &str {
903    node.get("type")
904        .and_then(serde_json::Value::as_str)
905        .unwrap_or_default()
906}
907
908fn children(node: &serde_json::Value) -> impl Iterator<Item = &serde_json::Value> {
909    node.get("children")
910        .and_then(serde_json::Value::as_array)
911        .into_iter()
912        .flatten()
913}
914
915fn node_span(node: &serde_json::Value) -> Option<SourceSpan> {
916    Some(SourceSpan {
917        start_byte: node.pointer("/span/startByte")?.as_u64()? as u32,
918        end_byte: node.pointer("/span/endByte")?.as_u64()? as u32,
919    })
920}
921
922struct MappedValue {
923    spans: Vec<SourceSpan>,
924    consumed_end: u32,
925}
926
927fn find_mapped_value(value: &str, source: &str, start: u32, end: u32) -> Option<MappedValue> {
928    if value.contains('\n') {
929        let mut spans = Vec::new();
930        let mut cursor = start;
931        let lines: Vec<_> = value.split('\n').collect();
932        for (index, line) in lines.iter().enumerate() {
933            if !line.is_empty() {
934                let mapped = find_mapped_value(line, source, cursor, end)?;
935                cursor = mapped.consumed_end;
936                spans.extend(mapped.spans);
937            }
938            if index + 1 < lines.len() {
939                let remaining = source.get(cursor as usize..end as usize)?;
940                let newline = remaining.find('\n')?;
941                let newline_end = cursor + newline as u32 + 1;
942                let newline_start = if newline > 0 && remaining.as_bytes()[newline - 1] == b'\r' {
943                    newline_end - 2
944                } else {
945                    newline_end - 1
946                };
947                spans.push(SourceSpan {
948                    start_byte: newline_start,
949                    end_byte: newline_end,
950                });
951                cursor = newline_end;
952            }
953        }
954        return Some(MappedValue {
955            spans,
956            consumed_end: cursor,
957        });
958    }
959    let raw = source.get(start as usize..end as usize)?;
960    let direct_offset = raw
961        .find(value)
962        .filter(|offset| direct_match_is_source_literal(raw, *offset, value));
963    if direct_offset == Some(0) {
964        return Some(MappedValue {
965            spans: map_direct(value, start)?,
966            consumed_end: start + value.len() as u32,
967        });
968    }
969    for (candidate, _) in raw.char_indices() {
970        if direct_offset.is_some_and(|offset| candidate > offset) {
971            break;
972        }
973        if let Some((spans, consumed)) =
974            map_decoded_prefix(value, &raw[candidate..], start + candidate as u32)
975        {
976            return Some(MappedValue {
977                spans,
978                consumed_end: start + candidate as u32 + consumed as u32,
979            });
980        }
981    }
982    None
983}
984
985fn map_decoded_prefix(value: &str, raw: &str, start: u32) -> Option<(Vec<SourceSpan>, usize)> {
986    let atoms = decoded_atoms(raw, start);
987    let mut decoded = String::new();
988    for atom in atoms {
989        decoded.push_str(&atom.text);
990        if decoded == value {
991            let consumed = atom.span.end_byte.checked_sub(start)? as usize;
992            return map_decoded(value, &raw[..consumed], start).map(|spans| (spans, consumed));
993        }
994        if !value.starts_with(&decoded) {
995            return None;
996        }
997    }
998    None
999}
1000
1001fn direct_match_is_source_literal(raw: &str, offset: usize, value: &str) -> bool {
1002    if offset > 0 && raw.as_bytes()[offset - 1] == b'\\' {
1003        return false;
1004    }
1005    let candidate = &raw[offset..];
1006    if candidate.starts_with('&')
1007        && let Some(end) = candidate.find(';')
1008        && decode_reference(&candidate[1..end]).as_deref() == Some(value)
1009        && end + 1 != value.len()
1010    {
1011        return false;
1012    }
1013    true
1014}
1015
1016fn map_direct(value: &str, start: u32) -> Option<Vec<SourceSpan>> {
1017    Some(
1018        value
1019            .grapheme_indices(true)
1020            .map(|(offset, grapheme)| SourceSpan {
1021                start_byte: start + offset as u32,
1022                end_byte: start + offset as u32 + grapheme.len() as u32,
1023            })
1024            .collect(),
1025    )
1026}
1027
1028struct Atom {
1029    text: String,
1030    span: SourceSpan,
1031}
1032
1033fn map_decoded(value: &str, raw: &str, start: u32) -> Option<Vec<SourceSpan>> {
1034    if value == raw {
1035        return map_direct(value, start);
1036    }
1037    let atoms = decoded_atoms(raw, start);
1038    let decoded: String = atoms.iter().map(|atom| atom.text.as_str()).collect();
1039    if decoded != value {
1040        return None;
1041    }
1042    let mut atom_ranges = Vec::with_capacity(atoms.len());
1043    let mut decoded_offset = 0;
1044    for atom in &atoms {
1045        let end = decoded_offset + atom.text.len();
1046        atom_ranges.push((decoded_offset, end, atom.span));
1047        decoded_offset = end;
1048    }
1049    let mut result = Vec::new();
1050    for (offset, grapheme) in value.grapheme_indices(true) {
1051        let end = offset + grapheme.len();
1052        let overlapping: Vec<_> = atom_ranges
1053            .iter()
1054            .filter(|(atom_start, atom_end, _)| *atom_start < end && *atom_end > offset)
1055            .collect();
1056        let first = overlapping.first()?.2;
1057        let last = overlapping.last()?.2;
1058        result.push(SourceSpan {
1059            start_byte: first.start_byte,
1060            end_byte: last.end_byte,
1061        });
1062    }
1063    Some(result)
1064}
1065
1066fn decoded_atoms(raw: &str, start: u32) -> Vec<Atom> {
1067    let mut atoms = Vec::new();
1068    let mut index = 0;
1069    while index < raw.len() {
1070        let rest = &raw[index..];
1071        if rest.starts_with('\\')
1072            && let Some(next) = rest.chars().nth(1)
1073            && (next.is_ascii_punctuation() || "{}|^[]:《》\\.".contains(next))
1074        {
1075            let len = 1 + next.len_utf8();
1076            atoms.push(Atom {
1077                text: next.to_string(),
1078                span: SourceSpan {
1079                    start_byte: start + index as u32,
1080                    end_byte: start + (index + len) as u32,
1081                },
1082            });
1083            index += len;
1084            continue;
1085        }
1086        if rest.starts_with('&')
1087            && let Some(end) = rest.find(';')
1088            && let Some(decoded) = decode_reference(&rest[1..end])
1089        {
1090            atoms.push(Atom {
1091                text: decoded,
1092                span: SourceSpan {
1093                    start_byte: start + index as u32,
1094                    end_byte: start + (index + end + 1) as u32,
1095                },
1096            });
1097            index += end + 1;
1098            continue;
1099        }
1100        let character = rest.chars().next().expect("non-empty remainder");
1101        let len = character.len_utf8();
1102        atoms.push(Atom {
1103            text: character.to_string(),
1104            span: SourceSpan {
1105                start_byte: start + index as u32,
1106                end_byte: start + (index + len) as u32,
1107            },
1108        });
1109        index += len;
1110    }
1111    atoms
1112}
1113
1114fn decode_reference(body: &str) -> Option<String> {
1115    if let Some(hex) = body.strip_prefix("#x").or_else(|| body.strip_prefix("#X")) {
1116        return (!hex.is_empty() && hex.chars().all(|character| character.is_ascii_hexdigit()))
1117            .then(|| markdown::decode_numeric(hex, 16));
1118    }
1119    if let Some(decimal) = body.strip_prefix('#') {
1120        return (!decimal.is_empty()
1121            && decimal.chars().all(|character| character.is_ascii_digit()))
1122        .then(|| markdown::decode_numeric(decimal, 10));
1123    }
1124    markdown::decode_named(body, true)
1125}
1126
1127struct RubyParts<'a> {
1128    token_start: u32,
1129    base: &'a str,
1130    base_start: u32,
1131    reading: &'a str,
1132    reading_start: u32,
1133    token_end: u32,
1134}
1135
1136fn find_ruby_parts<'a>(
1137    base: &str,
1138    reading: &str,
1139    source: &'a str,
1140    start: u32,
1141    end: u32,
1142) -> Option<RubyParts<'a>> {
1143    let raw = source.get(start as usize..end as usize)?;
1144    for (offset, _) in raw.match_indices('{') {
1145        let candidate = &raw[offset..];
1146        let Some(close) = first_unescaped(&candidate[1..], '}').map(|close| close + 1) else {
1147            continue;
1148        };
1149        let body = &candidate[1..close];
1150        let Some(separator) = first_unescaped(body, '|') else {
1151            continue;
1152        };
1153        let raw_base = &body[..separator];
1154        let raw_reading = &body[separator + 1..];
1155        let decoded_base: String = decoded_atoms(raw_base, 0)
1156            .into_iter()
1157            .map(|atom| atom.text)
1158            .collect();
1159        let decoded_reading: String = split_unescaped_offsets(raw_reading, '.')
1160            .into_iter()
1161            .flat_map(|(part_start, part_end)| {
1162                decoded_atoms(&raw_reading[part_start..part_end], 0)
1163                    .into_iter()
1164                    .map(|atom| atom.text)
1165            })
1166            .collect();
1167        if decoded_base == base && decoded_reading == reading {
1168            let token_start = start + offset as u32;
1169            return Some(RubyParts {
1170                token_start,
1171                base: raw_base,
1172                base_start: token_start + 1,
1173                reading: raw_reading,
1174                reading_start: token_start + 1 + separator as u32 + 1,
1175                token_end: token_start + close as u32 + 1,
1176            });
1177        }
1178    }
1179    None
1180}
1181
1182fn split_raw_reading<'a>(parts: &'a RubyParts<'a>) -> Vec<(&'a str, u32)> {
1183    split_unescaped_offsets(parts.reading, '.')
1184        .into_iter()
1185        .map(|(start, end)| {
1186            (
1187                &parts.reading[start..end],
1188                parts.reading_start + start as u32,
1189            )
1190        })
1191        .collect()
1192}
1193
1194fn first_unescaped(value: &str, needle: char) -> Option<usize> {
1195    let mut escaped = false;
1196    for (index, character) in value.char_indices() {
1197        if escaped {
1198            escaped = false;
1199        } else if character == '\\' {
1200            escaped = true;
1201        } else if character == needle {
1202            return Some(index);
1203        }
1204    }
1205    None
1206}
1207
1208fn split_unescaped_offsets(value: &str, separator: char) -> Vec<(usize, usize)> {
1209    let mut result = Vec::new();
1210    let mut start = 0;
1211    let mut escaped = false;
1212    for (index, character) in value.char_indices() {
1213        if escaped {
1214            escaped = false;
1215        } else if character == '\\' {
1216            escaped = true;
1217        } else if character == separator {
1218            result.push((start, index));
1219            start = index + character.len_utf8();
1220        }
1221    }
1222    result.push((start, value.len()));
1223    result
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    fn position(range: &MdiTextRange) -> (u32, u32, u32, u32) {
1231        (
1232            range.start.block,
1233            range.start.character,
1234            range.end.block,
1235            range.end.character,
1236        )
1237    }
1238
1239    fn assert_complete_mapping(block: &MdiTextBlock, source: &str) {
1240        let count = block.text.graphemes(true).count();
1241        let mut coverage = vec![0_u8; count];
1242        for run in &block.source_map.runs {
1243            let start = run.range.start.character as usize - 1;
1244            let end = run.range.end.character as usize - 1;
1245            assert_eq!(run.source_boundaries.len(), end - start + 1);
1246            for boundary in &run.source_boundaries {
1247                assert!((*boundary as usize) <= source.len());
1248                assert!(source.is_char_boundary(*boundary as usize));
1249            }
1250            for covered in &mut coverage[start..end] {
1251                *covered += 1;
1252            }
1253        }
1254        for range in &block.source_map.synthetic {
1255            let start = range.start.character as usize - 1;
1256            let end = range.end.character as usize - 1;
1257            for covered in &mut coverage[start..end] {
1258                *covered += 1;
1259            }
1260        }
1261        assert!(block.source_map.unmapped.is_empty(), "{block:#?}");
1262        assert!(coverage.iter().all(|covered| *covered == 1), "{block:#?}");
1263    }
1264
1265    #[test]
1266    fn projects_grapheme_positions_and_ruby_channels() {
1267        let result = get_mdi_text_blocks("# 序章\n\n我喜歡{東京|とうきょう}。\n\ne\u{301} 👩🏽‍💻");
1268        assert_eq!(result.blocks.len(), 3);
1269        assert_eq!(result.blocks[0].text, "序章");
1270        assert_eq!(position(&result.blocks[0].range), (1, 1, 1, 3));
1271        assert_eq!(result.blocks[1].text, "我喜歡東京。");
1272        assert_eq!(position(&result.blocks[1].range), (2, 1, 2, 7));
1273        let annotation = &result.blocks[1].annotations[0];
1274        assert_eq!(annotation.text, "とうきょう");
1275        assert_eq!(position(&annotation.anchor), (2, 4, 2, 6));
1276        assert_eq!(result.blocks[2].text.graphemes(true).count(), 3);
1277        assert_eq!(position(&result.blocks[2].range), (3, 1, 3, 4));
1278        assert!(result.diagnostics.is_empty());
1279
1280        let across_wrapper = get_mdi_text_blocks("e*\u{301}*");
1281        assert_eq!(across_wrapper.blocks[0].text, "e\u{301}");
1282        assert_eq!(position(&across_wrapper.blocks[0].range), (1, 1, 1, 2));
1283        assert!(across_wrapper.blocks[0].source_map.unmapped.is_empty());
1284
1285        let marker_text = get_mdi_text_blocks("# \\#\n\n- \\-\n\n> \\>");
1286        assert_eq!(
1287            marker_text
1288                .blocks
1289                .iter()
1290                .map(|block| block.source_map.runs[0].source_boundaries[0])
1291                .collect::<Vec<_>>(),
1292            vec![2, 8, 14]
1293        );
1294    }
1295
1296    #[test]
1297    fn maps_entities_escapes_and_mdi_delimiters_to_complete_source_tokens() {
1298        let source = r"&amp; \* {東京|とうきょう} ^12^ 前[[br]]次";
1299        let result = get_mdi_text_blocks(source);
1300        let block = &result.blocks[0];
1301        assert_eq!(block.text, "& * 東京 12 前\n次");
1302        assert!(block.source_map.unmapped.is_empty(), "{block:#?}");
1303        assert!(block.source_map.synthetic.is_empty());
1304
1305        let spans: Vec<_> = block
1306            .source_map
1307            .runs
1308            .iter()
1309            .flat_map(|run| run.source_boundaries.windows(2))
1310            .map(|pair| &source[pair[0] as usize..pair[1] as usize])
1311            .collect();
1312        assert!(spans.contains(&"&amp;"));
1313        assert!(spans.contains(&r"\*"));
1314        assert!(spans.contains(&"[[br]]"));
1315        assert!(!spans.contains(&"とうきょう"));
1316        assert!(result.diagnostics.is_empty());
1317    }
1318
1319    #[test]
1320    fn gives_each_split_ruby_reading_its_base_grapheme_anchor() {
1321        let result = get_mdi_text_blocks("{東京|とう.きょう}");
1322        let annotations = &result.blocks[0].annotations;
1323        assert_eq!(annotations.len(), 2);
1324        assert_eq!(annotations[0].text, "とう");
1325        assert_eq!(position(&annotations[0].anchor), (1, 1, 1, 2));
1326        assert_eq!(annotations[1].text, "きょう");
1327        assert_eq!(position(&annotations[1].anchor), (1, 2, 1, 3));
1328        assert!(
1329            annotations
1330                .iter()
1331                .all(|annotation| annotation.source_map.unmapped.is_empty())
1332        );
1333    }
1334
1335    #[test]
1336    fn mismatched_split_ruby_degrades_to_a_mapped_group_warning() {
1337        let result = get_mdi_text_blocks("{東京|とう.きょ.う}");
1338        let annotation = &result.blocks[0].annotations[0];
1339        assert_eq!(annotation.text, "とうきょう");
1340        assert_eq!(position(&annotation.anchor), (1, 1, 1, 3));
1341        assert!(annotation.source_map.unmapped.is_empty());
1342        assert!(
1343            result
1344                .diagnostics
1345                .iter()
1346                .any(|diagnostic| diagnostic.code == "mdi.textProjection.rubySplitMismatch")
1347        );
1348    }
1349
1350    #[test]
1351    fn collects_leaf_blocks_without_parent_text_duplication() {
1352        let source = "- first\n\n  second\n  - nested\n\n> quote one\n>\n> quote two\n\n| a | b |\n| - | - |\n| c | d |\n\n```mdi\ncode\nline\n```\n\nbody[^n]\n\n[^n]: note\n\n---";
1353        let result = get_mdi_text_blocks(source);
1354        let summaries: Vec<_> = result
1355            .blocks
1356            .iter()
1357            .map(|block| (block.kind, block.text.as_str()))
1358            .collect();
1359        assert_eq!(
1360            summaries,
1361            vec![
1362                (MdiTextBlockKind::ListItem, "first\n\nsecond"),
1363                (MdiTextBlockKind::ListItem, "nested"),
1364                (MdiTextBlockKind::Blockquote, "quote one"),
1365                (MdiTextBlockKind::Blockquote, "quote two"),
1366                (MdiTextBlockKind::Table, "a\tb\nc\td"),
1367                (MdiTextBlockKind::Code, "code\nline"),
1368                (MdiTextBlockKind::Paragraph, "body"),
1369                (MdiTextBlockKind::Footnote, "note"),
1370            ]
1371        );
1372        assert_eq!(result.blocks[0].source_map.synthetic.len(), 1);
1373        assert_eq!(result.blocks[4].source_map.synthetic.len(), 3);
1374        assert!(
1375            result
1376                .blocks
1377                .iter()
1378                .all(|block| block.source_map.unmapped.is_empty())
1379        );
1380
1381        let fenced = get_mdi_text_blocks("```rust\nrust\n```");
1382        assert_eq!(fenced.blocks[0].text, "rust");
1383        assert_eq!(fenced.blocks[0].source_map.runs[0].source_boundaries[0], 8);
1384    }
1385
1386    #[test]
1387    fn projection_json_is_deterministic_and_keeps_the_parse_envelope() {
1388        let source = "---\nmdi: '2.0'\ntitle: x\n---\n\n# heading\n\ntext";
1389        let first = get_mdi_text_blocks_json(source);
1390        assert_eq!(first, get_mdi_text_blocks_json(source));
1391        let value: serde_json::Value = serde_json::from_str(&first).unwrap();
1392        assert_eq!(value["projectionVersion"], "1.0");
1393        assert_eq!(
1394            value["positionEncoding"],
1395            "unicode-grapheme-cluster-1-based"
1396        );
1397        assert_eq!(value["irVersion"], MDI_IR_VERSION);
1398        assert_eq!(value["document"]["frontmatter"]["entries"][0]["key"], "mdi");
1399    }
1400
1401    #[test]
1402    fn supported_inline_and_wrapped_markdown_is_fully_mapped() {
1403        let source = "**強調** [label](https://example.test) ![代替](image.png) `code` \\* &amp; [[no-break:禁則]][[warichu:割注]][[kern:-0.1em:詰め]][[em:傍点]]\n\n> first\n> continued\n\n- item\n  continued";
1404        let result = get_mdi_text_blocks(source);
1405        assert_eq!(
1406            result
1407                .blocks
1408                .iter()
1409                .map(|block| block.text.as_str())
1410                .collect::<Vec<_>>(),
1411            vec![
1412                "強調 label 代替 code * & 禁則割注詰め傍点",
1413                "first\ncontinued",
1414                "item\ncontinued",
1415            ]
1416        );
1417        for block in &result.blocks {
1418            assert_complete_mapping(block, source);
1419        }
1420    }
1421
1422    #[test]
1423    fn malformed_literals_remain_searchable_and_precisely_mapped() {
1424        for source in [
1425            "{東京|とうきょう",
1426            "[[em:未閉",
1427            "《《未閉",
1428            "^1234567^ ^12^",
1429            "<custom>literal</custom>",
1430        ] {
1431            let result = get_mdi_text_blocks(source);
1432            assert!(!result.blocks.is_empty(), "{source:?}");
1433            for block in &result.blocks {
1434                assert_complete_mapping(block, source);
1435            }
1436        }
1437        let frontmatter = get_mdi_text_blocks("---\ntitle: hidden\n---\n\nvisible");
1438        assert_eq!(frontmatter.blocks.len(), 1);
1439        assert_eq!(frontmatter.blocks[0].text, "visible");
1440    }
1441}