Skip to main content

mdi_core/
text_projection.rs

1#[cfg(any(test, feature = "wasm"))]
2use crate::MDI_MDAST_PROVENANCE_VERSION;
3use crate::{
4    Diagnostic, DiagnosticSeverity, Document, MDI_IR_VERSION, MDI_SPEC_VERSION, ParserCapabilities,
5    SourceSpan, diagnostics, parse_document_without_provenance,
6};
7use serde::Serialize;
8#[cfg(test)]
9use std::cell::Cell;
10use std::fmt;
11use unicode_segmentation::UnicodeSegmentation;
12
13#[cfg(test)]
14thread_local! {
15    static PROVENANCE_QUERY_VISITS: Cell<usize> = const { Cell::new(0) };
16}
17
18pub(crate) enum PlainInline<'a> {
19    Value(&'a str),
20    Break,
21    Skip,
22    Children,
23}
24
25/// The single plaintext rule table used by both `render_text` and the mapped
26/// block projection. Source-map construction is layered on top of this value.
27pub(crate) fn plain_inline(node: &serde_json::Value) -> PlainInline<'_> {
28    match node_type(node) {
29        "text" | "inlineCode" | "code" | "html" | "tcy" => PlainInline::Value(
30            node.get("value")
31                .and_then(serde_json::Value::as_str)
32                .unwrap_or_default(),
33        ),
34        "ruby" => PlainInline::Value(
35            node.get("base")
36                .and_then(serde_json::Value::as_str)
37                .unwrap_or_default(),
38        ),
39        "image" => PlainInline::Value(
40            node.get("alt")
41                .and_then(serde_json::Value::as_str)
42                .unwrap_or_default(),
43        ),
44        "break" => PlainInline::Break,
45        "footnoteReference" | "comment" => PlainInline::Skip,
46        _ if node.get("children").is_some() => PlainInline::Children,
47        _ => node
48            .get("value")
49            .and_then(serde_json::Value::as_str)
50            .map_or(PlainInline::Skip, PlainInline::Value),
51    }
52}
53
54pub const MDI_TEXT_PROJECTION_VERSION: &str = "1.0";
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct MdiTextBlocksResult {
59    pub projection_version: &'static str,
60    pub position_encoding: &'static str,
61    pub ir_version: &'static str,
62    pub syntax_version: &'static str,
63    pub capabilities: ParserCapabilities,
64    pub blocks: Vec<MdiTextBlock>,
65    pub document: Document,
66    pub diagnostics: Vec<Diagnostic>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct MdiTextBlock {
72    pub index: u32,
73    pub kind: MdiTextBlockKind,
74    pub text: String,
75    pub range: MdiTextRange,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub span: Option<SourceSpan>,
78    pub source_map: MdiTextSourceMap,
79    pub annotations: Vec<MdiTextAnnotation>,
80    pub node: serde_json::Value,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub enum MdiTextBlockKind {
86    Heading,
87    Paragraph,
88    ListItem,
89    Blockquote,
90    Code,
91    Table,
92    Footnote,
93    Html,
94    Other,
95}
96
97/// A one-based block/Unicode-grapheme position, serialized as `3:18`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct MdiTextPosition {
100    pub block: u32,
101    pub character: u32,
102}
103
104impl Serialize for MdiTextPosition {
105    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
106    where
107        S: serde::Serializer,
108    {
109        serializer.serialize_str(&format!("{}:{}", self.block, self.character))
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct MdiTextRange {
115    pub start: MdiTextPosition,
116    pub end: MdiTextPosition,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
120pub struct MdiTextSourceMap {
121    pub runs: Vec<MdiTextSourceRun>,
122    pub synthetic: Vec<MdiTextRange>,
123    pub unmapped: Vec<MdiTextRange>,
124}
125
126/// Annotation text uses the containing block number and its own one-based
127/// character offsets. This keeps the run format identical in both channels.
128pub type MdiAnnotationSourceMap = MdiTextSourceMap;
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct MdiTextSourceRun {
133    pub range: MdiTextRange,
134    pub source_boundaries: Vec<u32>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "camelCase")]
139pub struct MdiTextAnnotation {
140    pub kind: &'static str,
141    pub text: String,
142    pub anchor: MdiTextRange,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub span: Option<SourceSpan>,
145    pub source_map: MdiAnnotationSourceMap,
146}
147
148/// Result of resolving one half-open UTF-8 source span back to canonical text.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150#[serde(rename_all = "camelCase")]
151pub struct MdiSourceSpanTextResolution {
152    pub projection_version: &'static str,
153    pub source_span: SourceSpan,
154    pub coverage: MdiSourceSpanCoverage,
155    pub matches: Vec<MdiSourceSpanTextMatch>,
156}
157
158/// How much of a non-empty source span belongs to mapped graphemes.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub enum MdiSourceSpanCoverage {
162    Complete,
163    Partial,
164    None,
165}
166
167/// Relationship between a canonical match's forward source coverage and the
168/// requested source span.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub enum MdiSourceSpanRelation {
172    Exact,
173    Overlap,
174}
175
176/// A maximal adjacent canonical range in either block text or one annotation.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178#[serde(
179    tag = "kind",
180    rename_all = "camelCase",
181    rename_all_fields = "camelCase"
182)]
183pub enum MdiSourceSpanTextMatch {
184    BlockText {
185        block_index: u32,
186        range: MdiTextRange,
187        relation: MdiSourceSpanRelation,
188    },
189    Annotation {
190        block_index: u32,
191        annotation_index: u32,
192        range: MdiTextRange,
193        relation: MdiSourceSpanRelation,
194    },
195}
196
197/// Validation error returned by [`resolve_mdi_source_span`].
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum MdiSourceSpanResolutionError {
200    Reversed,
201    OutOfBounds,
202    NotUtf8Boundary,
203}
204
205impl fmt::Display for MdiSourceSpanResolutionError {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str(match self {
208            Self::Reversed => "source span startByte must not exceed endByte",
209            Self::OutOfBounds => "source span falls outside the UTF-8 source length",
210            Self::NotUtf8Boundary => "source span endpoints must be UTF-8 code-point boundaries",
211        })
212    }
213}
214
215impl std::error::Error for MdiSourceSpanResolutionError {}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum UnitMap {
219    Mapped(SourceSpan),
220    Synthetic,
221    Unmapped,
222}
223
224struct AnnotationDraft {
225    text: String,
226    anchor_start: usize,
227    anchor_end: usize,
228    span: Option<SourceSpan>,
229    units: Vec<UnitMap>,
230}
231
232struct BlockDraft {
233    kind: MdiTextBlockKind,
234    text: String,
235    units: Vec<UnitMap>,
236    unit_texts: Vec<String>,
237    annotations: Vec<AnnotationDraft>,
238    span: Option<SourceSpan>,
239    node: serde_json::Value,
240    mapping_warning: bool,
241    source_cursor: u32,
242    source_end: u32,
243}
244
245impl BlockDraft {
246    fn new(kind: MdiTextBlockKind, node: &serde_json::Value) -> Self {
247        let span = node_span(node);
248        Self {
249            kind,
250            text: String::new(),
251            units: Vec::new(),
252            unit_texts: Vec::new(),
253            annotations: Vec::new(),
254            span,
255            node: node.clone(),
256            mapping_warning: false,
257            source_cursor: span.map_or(0, |span| span.start_byte),
258            source_end: span.map_or(0, |span| span.end_byte),
259        }
260    }
261
262    fn grapheme_len(&self) -> usize {
263        self.text.graphemes(true).count()
264    }
265
266    fn append_synthetic(&mut self, value: &str) {
267        self.text.push_str(value);
268        for grapheme in value.graphemes(true) {
269            self.unit_texts.push(grapheme.to_owned());
270            self.units.push(UnitMap::Synthetic);
271        }
272    }
273
274    fn append_unmapped(&mut self, value: &str) {
275        if value.is_empty() {
276            return;
277        }
278        self.text.push_str(value);
279        for grapheme in value.graphemes(true) {
280            self.unit_texts.push(grapheme.to_owned());
281            self.units.push(UnitMap::Unmapped);
282        }
283        self.mapping_warning = true;
284    }
285
286    fn append_mapped(&mut self, value: &str, spans: Option<Vec<SourceSpan>>) {
287        if value.is_empty() {
288            return;
289        }
290        let count = value.graphemes(true).count();
291        match spans {
292            Some(spans) if spans.len() == count => {
293                self.text.push_str(value);
294                for (grapheme, span) in value.graphemes(true).zip(spans) {
295                    self.unit_texts.push(grapheme.to_owned());
296                    self.units.push(UnitMap::Mapped(span));
297                }
298            }
299            _ => self.append_unmapped(value),
300        }
301    }
302}
303
304struct Collector<'a> {
305    source: &'a str,
306    blocks: Vec<MdiTextBlock>,
307    diagnostics: Vec<Diagnostic>,
308}
309
310/// Parse once and produce the complete IR envelope plus the Rust-owned text
311/// projection and its UTF-8 source map.
312pub fn get_mdi_text_blocks(source: &str) -> MdiTextBlocksResult {
313    get_mdi_text_blocks_with_options(source, crate::ParseOptions::default())
314}
315
316pub fn get_mdi_text_blocks_with_options(
317    source: &str,
318    options: crate::ParseOptions,
319) -> MdiTextBlocksResult {
320    let mut document = parse_document_without_provenance(source);
321    let mut collector = Collector {
322        source,
323        blocks: Vec::new(),
324        diagnostics: diagnostics(&document),
325    };
326    for node in &document.children {
327        collector.collect(node, false);
328    }
329    collector
330        .diagnostics
331        .extend(crate::comments::Comments::scan(source).diagnostics);
332    if !options.include_comments {
333        crate::comments::filter_nodes(&mut document.children);
334        for block in &mut collector.blocks {
335            if let Some(children) = block
336                .node
337                .get_mut("children")
338                .and_then(serde_json::Value::as_array_mut)
339            {
340                crate::comments::filter_nodes(children);
341            }
342        }
343    }
344    MdiTextBlocksResult {
345        projection_version: MDI_TEXT_PROJECTION_VERSION,
346        position_encoding: "unicode-grapheme-cluster-1-based",
347        ir_version: if options.include_comments {
348            crate::MDI_COMMENT_IR_VERSION
349        } else {
350            MDI_IR_VERSION
351        },
352        syntax_version: MDI_SPEC_VERSION,
353        capabilities: ParserCapabilities {
354            mdi: true,
355            common_mark: true,
356            gfm: true,
357            front_matter: true,
358            source_spans: true,
359        },
360        blocks: collector.blocks,
361        document,
362        diagnostics: collector.diagnostics,
363    }
364}
365
366/// Attach adapter-only provenance to every Rust IR construct.  The mdast
367/// adapter transports this exact record; it must not reconstruct projection
368/// ranges, source maps, or identities in JavaScript.
369#[cfg(any(test, feature = "wasm"))]
370pub(crate) fn attach_mdast_provenance(document: &mut Document, source: &str) {
371    let mut collector = Collector {
372        source,
373        blocks: Vec::new(),
374        diagnostics: Vec::new(),
375    };
376    for node in &document.children {
377        collector.collect(node, false);
378    }
379    let provenance_index = ProvenanceIndex::new(&collector.blocks);
380    for (node_index, node) in document.children.iter_mut().enumerate() {
381        attach_node_provenance(node, &provenance_index, &node_index.to_string());
382    }
383}
384
385#[cfg(any(test, feature = "wasm"))]
386fn attach_node_provenance(node: &mut serde_json::Value, index: &ProvenanceIndex, path: &str) {
387    let Some(object) = node.as_object_mut() else {
388        return;
389    };
390    let node_type = object
391        .get("type")
392        .and_then(serde_json::Value::as_str)
393        .unwrap_or("");
394    let span = object
395        .get("span")
396        .and_then(|value| serde_json::from_value::<SourceSpan>(value.clone()).ok());
397    let role = if is_text_bearing(node_type) {
398        "textBearing"
399    } else {
400        "container"
401    };
402    let targets = if role == "textBearing" {
403        span.map(|span| index.targets(span)).unwrap_or_default()
404    } else {
405        Vec::new()
406    };
407    let status = if span.is_none() {
408        "synthetic"
409    } else if role == "textBearing" && targets.is_empty() {
410        "unmapped"
411    } else {
412        "sourceBacked"
413    };
414    object.insert(
415        "mdiProvenance".to_owned(),
416        serde_json::json!({
417            "version": MDI_MDAST_PROVENANCE_VERSION,
418            "construct": { "path": path, "type": node_type },
419            "span": span,
420            "role": role,
421            "status": status,
422            "targets": targets,
423        }),
424    );
425    if let Some(children) = object
426        .get_mut("children")
427        .and_then(serde_json::Value::as_array_mut)
428    {
429        for (child_index, child) in children.iter_mut().enumerate() {
430            attach_node_provenance(child, index, &format!("{path}.{child_index}"));
431        }
432    }
433}
434
435#[cfg(any(test, feature = "wasm"))]
436fn is_text_bearing(node_type: &str) -> bool {
437    matches!(
438        node_type,
439        "text" | "inlineCode" | "code" | "html" | "ruby" | "tcy" | "image" | "break"
440    )
441}
442
443#[cfg(any(test, feature = "wasm"))]
444#[derive(Clone, Copy, PartialEq, Eq)]
445enum ProvenanceChannel {
446    BlockText,
447    Annotation(u32),
448}
449
450#[cfg(any(test, feature = "wasm"))]
451struct ProvenanceUnit {
452    span: SourceSpan,
453    block_index: u32,
454    channel: ProvenanceChannel,
455    character: u32,
456    ordinal: usize,
457}
458
459/// An immutable interval index built once for one mdast parse. `prefix_max_end`
460/// makes the first possible overlap a binary search even when source intervals
461/// nest or share their starting byte.
462#[cfg(any(test, feature = "wasm"))]
463struct ProvenanceIndex {
464    units: Vec<ProvenanceUnit>,
465    prefix_max_end: Vec<u32>,
466}
467
468#[cfg(any(test, feature = "wasm"))]
469impl ProvenanceIndex {
470    fn new(blocks: &[MdiTextBlock]) -> Self {
471        let mut units = Vec::new();
472        for block in blocks {
473            Self::push_channel(
474                &mut units,
475                block.index,
476                ProvenanceChannel::BlockText,
477                &block.source_map,
478            );
479            for (annotation_index, annotation) in block.annotations.iter().enumerate() {
480                Self::push_channel(
481                    &mut units,
482                    block.index,
483                    ProvenanceChannel::Annotation(annotation_index as u32),
484                    &annotation.source_map,
485                );
486            }
487        }
488        units.sort_by_key(|unit| (unit.span.start_byte, unit.span.end_byte, unit.ordinal));
489        let mut maximum = 0;
490        let prefix_max_end = units
491            .iter()
492            .map(|unit| {
493                maximum = maximum.max(unit.span.end_byte);
494                maximum
495            })
496            .collect();
497        Self {
498            units,
499            prefix_max_end,
500        }
501    }
502
503    fn push_channel(
504        units: &mut Vec<ProvenanceUnit>,
505        block_index: u32,
506        channel: ProvenanceChannel,
507        map: &MdiTextSourceMap,
508    ) {
509        for run in &map.runs {
510            for (offset, boundaries) in run.source_boundaries.windows(2).enumerate() {
511                units.push(ProvenanceUnit {
512                    span: SourceSpan {
513                        start_byte: boundaries[0],
514                        end_byte: boundaries[1],
515                    },
516                    block_index,
517                    channel,
518                    character: run.range.start.character + offset as u32,
519                    ordinal: units.len(),
520                });
521            }
522        }
523    }
524
525    fn targets(&self, span: SourceSpan) -> Vec<serde_json::Value> {
526        if span.start_byte == span.end_byte || self.units.is_empty() {
527            return Vec::new();
528        }
529        let first = self
530            .prefix_max_end
531            .partition_point(|&end| end <= span.start_byte);
532        let after_last = self
533            .units
534            .partition_point(|unit| unit.span.start_byte < span.end_byte);
535        let mut targets = Vec::new();
536        let mut pending: Option<(u32, ProvenanceChannel, u32, u32)> = None;
537        for unit in self.units.get(first..after_last).unwrap_or_default() {
538            #[cfg(test)]
539            PROVENANCE_QUERY_VISITS.with(|visits| visits.set(visits.get() + 1));
540            if unit.span.end_byte <= span.start_byte {
541                continue;
542            }
543            match pending.as_mut() {
544                Some((block, channel, _, end))
545                    if *block == unit.block_index
546                        && *channel == unit.channel
547                        && *end == unit.character =>
548                {
549                    *end = unit.character + 1;
550                }
551                _ => {
552                    if let Some(target) = pending.take() {
553                        targets.push(provenance_target(target));
554                    }
555                    pending = Some((
556                        unit.block_index,
557                        unit.channel,
558                        unit.character,
559                        unit.character + 1,
560                    ));
561                }
562            }
563        }
564        if let Some(target) = pending {
565            targets.push(provenance_target(target));
566        }
567        targets
568    }
569}
570
571#[cfg(any(test, feature = "wasm"))]
572fn provenance_target(
573    (block_index, channel, start, end): (u32, ProvenanceChannel, u32, u32),
574) -> serde_json::Value {
575    let range = MdiTextRange {
576        start: MdiTextPosition {
577            block: block_index,
578            character: start,
579        },
580        end: MdiTextPosition {
581            block: block_index,
582            character: end,
583        },
584    };
585    match channel {
586        ProvenanceChannel::BlockText => serde_json::json!({
587            "blockIndex": block_index, "channel": "blockText", "range": range,
588        }),
589        ProvenanceChannel::Annotation(annotation_index) => serde_json::json!({
590            "blockIndex": block_index, "channel": "annotation", "annotationIndex": annotation_index, "range": range,
591        }),
592    }
593}
594
595#[cfg(test)]
596pub(crate) fn reset_provenance_query_visits() {
597    PROVENANCE_QUERY_VISITS.with(|visits| visits.set(0));
598}
599
600#[cfg(test)]
601pub(crate) fn provenance_query_visits() -> usize {
602    PROVENANCE_QUERY_VISITS.with(Cell::get)
603}
604
605pub fn get_mdi_text_blocks_json(source: &str) -> String {
606    serde_json::to_string(&get_mdi_text_blocks(source))
607        .expect("serializing the MDI text projection cannot fail")
608}
609
610/// Resolve a half-open UTF-8 source span to every mapped canonical grapheme
611/// range. Block text and annotation text are independent channels.
612pub fn resolve_mdi_source_span(
613    source: &str,
614    span: SourceSpan,
615) -> Result<MdiSourceSpanTextResolution, MdiSourceSpanResolutionError> {
616    validate_source_span(source, span)?;
617    let projection = get_mdi_text_blocks(source);
618    Ok(resolve_mdi_source_span_in_projection(&projection, span))
619}
620
621/// Resolve many source spans after parsing and projecting `source` exactly
622/// once. Input order is preserved in the returned resolutions.
623pub fn resolve_mdi_source_spans(
624    source: &str,
625    spans: &[SourceSpan],
626) -> Result<Vec<MdiSourceSpanTextResolution>, MdiSourceSpanResolutionError> {
627    for &span in spans {
628        validate_source_span(source, span)?;
629    }
630    if spans.is_empty() {
631        return Ok(Vec::new());
632    }
633    let projection = get_mdi_text_blocks(source);
634    Ok(spans
635        .iter()
636        .map(|&span| resolve_mdi_source_span_in_projection(&projection, span))
637        .collect())
638}
639
640fn resolve_mdi_source_span_in_projection(
641    projection: &MdiTextBlocksResult,
642    span: SourceSpan,
643) -> MdiSourceSpanTextResolution {
644    if span.start_byte == span.end_byte {
645        return MdiSourceSpanTextResolution {
646            projection_version: MDI_TEXT_PROJECTION_VERSION,
647            source_span: span,
648            coverage: MdiSourceSpanCoverage::None,
649            matches: Vec::new(),
650        };
651    }
652
653    let mut matches = Vec::new();
654    let mut covered = Vec::new();
655    for block in &projection.blocks {
656        resolve_source_map_channel(
657            block.index,
658            None,
659            &block.source_map,
660            span,
661            &mut matches,
662            &mut covered,
663        );
664        for (annotation_index, annotation) in block.annotations.iter().enumerate() {
665            resolve_source_map_channel(
666                block.index,
667                Some(annotation_index as u32),
668                &annotation.source_map,
669                span,
670                &mut matches,
671                &mut covered,
672            );
673        }
674    }
675
676    let covered = merged_intervals(covered);
677    let coverage = if covered.is_empty() {
678        MdiSourceSpanCoverage::None
679    } else if covered.len() == 1
680        && covered[0].start_byte == span.start_byte
681        && covered[0].end_byte == span.end_byte
682    {
683        MdiSourceSpanCoverage::Complete
684    } else {
685        MdiSourceSpanCoverage::Partial
686    };
687    MdiSourceSpanTextResolution {
688        projection_version: MDI_TEXT_PROJECTION_VERSION,
689        source_span: span,
690        coverage,
691        matches,
692    }
693}
694
695/// JSON boundary for language bindings.
696pub fn resolve_mdi_source_span_json(
697    source: &str,
698    span: SourceSpan,
699) -> Result<String, MdiSourceSpanResolutionError> {
700    let resolution = resolve_mdi_source_span(source, span)?;
701    Ok(serde_json::to_string(&resolution)
702        .expect("serializing an MDI source-span resolution cannot fail"))
703}
704
705/// Batched JSON boundary for language bindings. The source is parsed once for
706/// all spans, and the returned array follows input order.
707pub fn resolve_mdi_source_spans_json(
708    source: &str,
709    spans: &[SourceSpan],
710) -> Result<String, MdiSourceSpanResolutionError> {
711    let resolutions = resolve_mdi_source_spans(source, spans)?;
712    Ok(serde_json::to_string(&resolutions)
713        .expect("serializing MDI source-span resolutions cannot fail"))
714}
715
716fn validate_source_span(
717    source: &str,
718    span: SourceSpan,
719) -> Result<(), MdiSourceSpanResolutionError> {
720    if span.start_byte > span.end_byte {
721        return Err(MdiSourceSpanResolutionError::Reversed);
722    }
723    let start = span.start_byte as usize;
724    let end = span.end_byte as usize;
725    if end > source.len() {
726        return Err(MdiSourceSpanResolutionError::OutOfBounds);
727    }
728    if !source.is_char_boundary(start) || !source.is_char_boundary(end) {
729        return Err(MdiSourceSpanResolutionError::NotUtf8Boundary);
730    }
731    Ok(())
732}
733
734fn resolve_source_map_channel(
735    block_index: u32,
736    annotation_index: Option<u32>,
737    map: &MdiTextSourceMap,
738    requested: SourceSpan,
739    matches: &mut Vec<MdiSourceSpanTextMatch>,
740    covered: &mut Vec<SourceSpan>,
741) {
742    let mut current_start = None;
743    let mut current_end = 0;
744    let mut current_spans = Vec::new();
745
746    let flush = |start: &mut Option<u32>,
747                 end: &mut u32,
748                 spans: &mut Vec<SourceSpan>,
749                 matches: &mut Vec<MdiSourceSpanTextMatch>| {
750        let Some(start_character) = start.take() else {
751            return;
752        };
753        let relation = if intervals_equal_span(spans, requested) {
754            MdiSourceSpanRelation::Exact
755        } else {
756            MdiSourceSpanRelation::Overlap
757        };
758        let range = MdiTextRange {
759            start: MdiTextPosition {
760                block: block_index,
761                character: start_character,
762            },
763            end: MdiTextPosition {
764                block: block_index,
765                character: *end,
766            },
767        };
768        matches.push(match annotation_index {
769            Some(annotation_index) => MdiSourceSpanTextMatch::Annotation {
770                block_index,
771                annotation_index,
772                range,
773                relation,
774            },
775            None => MdiSourceSpanTextMatch::BlockText {
776                block_index,
777                range,
778                relation,
779            },
780        });
781        spans.clear();
782    };
783
784    for run in &map.runs {
785        let run_start = run.range.start.character;
786        for (offset, boundaries) in run.source_boundaries.windows(2).enumerate() {
787            let character = run_start + offset as u32;
788            let unit_span = SourceSpan {
789                start_byte: boundaries[0],
790                end_byte: boundaries[1],
791            };
792            if unit_span.start_byte < requested.end_byte
793                && requested.start_byte < unit_span.end_byte
794            {
795                if current_start.is_some() && character != current_end {
796                    flush(
797                        &mut current_start,
798                        &mut current_end,
799                        &mut current_spans,
800                        matches,
801                    );
802                }
803                current_start.get_or_insert(character);
804                current_end = character + 1;
805                current_spans.push(unit_span);
806                covered.push(SourceSpan {
807                    start_byte: unit_span.start_byte.max(requested.start_byte),
808                    end_byte: unit_span.end_byte.min(requested.end_byte),
809                });
810            } else if current_start.is_some() && character == current_end {
811                flush(
812                    &mut current_start,
813                    &mut current_end,
814                    &mut current_spans,
815                    matches,
816                );
817            }
818        }
819    }
820    flush(
821        &mut current_start,
822        &mut current_end,
823        &mut current_spans,
824        matches,
825    );
826}
827
828fn intervals_equal_span(intervals: &[SourceSpan], span: SourceSpan) -> bool {
829    let merged = merged_intervals(intervals.to_vec());
830    merged.len() == 1 && merged[0] == span
831}
832
833fn merged_intervals(mut intervals: Vec<SourceSpan>) -> Vec<SourceSpan> {
834    intervals.sort_unstable_by_key(|span| (span.start_byte, span.end_byte));
835    let mut merged: Vec<SourceSpan> = Vec::new();
836    for interval in intervals {
837        if let Some(previous) = merged.last_mut()
838            && interval.start_byte <= previous.end_byte
839        {
840            previous.end_byte = previous.end_byte.max(interval.end_byte);
841        } else {
842            merged.push(interval);
843        }
844    }
845    merged
846}
847
848impl Collector<'_> {
849    fn collect(&mut self, node: &serde_json::Value, quoted: bool) {
850        let kind = node_type(node);
851        match kind {
852            "heading" => self.inline_block(MdiTextBlockKind::Heading, node),
853            "paragraph" => self.inline_block(
854                if quoted {
855                    MdiTextBlockKind::Blockquote
856                } else {
857                    MdiTextBlockKind::Paragraph
858                },
859                node,
860            ),
861            "blockquote" => {
862                for child in children(node) {
863                    self.collect(child, true);
864                }
865            }
866            "list" => {
867                for child in children(node) {
868                    self.collect(child, quoted);
869                }
870            }
871            "listItem" => self.list_item(node, quoted),
872            "code" => self.scalar_block(MdiTextBlockKind::Code, node, "value"),
873            "html" => self.scalar_block(MdiTextBlockKind::Html, node, "value"),
874            "table" => self.table(node),
875            "footnoteDefinition" => self.footnote(node),
876            "yaml" | "definition" | "blank" | "pagebreak" | "thematicBreak" | "comment" => {}
877            _ => {
878                if node_span(node).is_some() {
879                    let mut draft = BlockDraft::new(MdiTextBlockKind::Other, node);
880                    self.project_inline(node, &mut draft);
881                    self.finish(draft);
882                }
883            }
884        }
885    }
886
887    fn inline_block(&mut self, kind: MdiTextBlockKind, node: &serde_json::Value) {
888        let mut draft = BlockDraft::new(kind, node);
889        self.project_children(node, &mut draft);
890        self.finish(draft);
891    }
892
893    fn scalar_block(&mut self, kind: MdiTextBlockKind, node: &serde_json::Value, field: &str) {
894        let mut draft = BlockDraft::new(kind, node);
895        if let Some(value) = node.get(field).and_then(serde_json::Value::as_str) {
896            // markdown-rs preserves the source line ending inside fenced code.
897            // Projection coordinates use a single `\n` text unit regardless of
898            // whether that unit came from LF or CRLF source bytes.
899            let normalized;
900            let value = if kind == MdiTextBlockKind::Code && value.contains('\r') {
901                normalized = value.replace("\r\n", "\n").replace('\r', "\n");
902                normalized.as_str()
903            } else {
904                value
905            };
906            let spans = if kind == MdiTextBlockKind::Code {
907                self.map_code_block(value, &mut draft)
908            } else {
909                self.map_value_in_block(value, &mut draft)
910            };
911            draft.append_mapped(value, spans);
912        }
913        self.finish(draft);
914    }
915
916    fn list_item(&mut self, node: &serde_json::Value, quoted: bool) {
917        let paragraphs: Vec<_> = children(node)
918            .filter(|child| node_type(child) == "paragraph")
919            .collect();
920        if !paragraphs.is_empty() {
921            let mut draft = BlockDraft::new(MdiTextBlockKind::ListItem, node);
922            for (index, paragraph) in paragraphs.into_iter().enumerate() {
923                if index > 0 {
924                    draft.append_synthetic("\n\n");
925                }
926                self.project_children(paragraph, &mut draft);
927            }
928            self.finish(draft);
929        }
930        for child in children(node) {
931            if node_type(child) != "paragraph" {
932                self.collect(child, quoted);
933            }
934        }
935    }
936
937    fn footnote(&mut self, node: &serde_json::Value) {
938        let mut draft = BlockDraft::new(MdiTextBlockKind::Footnote, node);
939        for (index, child) in children(node).enumerate() {
940            if index > 0 {
941                draft.append_synthetic("\n\n");
942            }
943            if node_type(child) == "paragraph" {
944                self.project_children(child, &mut draft);
945            } else {
946                self.project_inline(child, &mut draft);
947            }
948        }
949        self.finish(draft);
950    }
951
952    fn table(&mut self, node: &serde_json::Value) {
953        let mut draft = BlockDraft::new(MdiTextBlockKind::Table, node);
954        for (row_index, row) in children(node).enumerate() {
955            if row_index > 0 {
956                draft.append_synthetic("\n");
957            }
958            for (cell_index, cell) in children(row).enumerate() {
959                if cell_index > 0 {
960                    draft.append_synthetic("\t");
961                }
962                self.project_children(cell, &mut draft);
963            }
964        }
965        self.finish(draft);
966    }
967
968    fn project_children(&mut self, node: &serde_json::Value, draft: &mut BlockDraft) {
969        for child in children(node) {
970            self.project_inline(child, draft);
971        }
972    }
973
974    fn project_inline(&mut self, node: &serde_json::Value, draft: &mut BlockDraft) {
975        match plain_inline(node) {
976            PlainInline::Value(value) => {
977                if node_type(node) == "ruby" {
978                    self.project_ruby(node, draft);
979                    return;
980                }
981                let spans = match node_type(node) {
982                    "tcy" => self.map_delimited_in_block(value, &mut *draft, '^', '^'),
983                    "image" => self.map_image_in_block(value, draft),
984                    "inlineCode" => self.map_inline_code_in_block(value, draft),
985                    _ => self.map_value_from_node(value, node, draft),
986                };
987                draft.append_mapped(value, spans);
988            }
989            PlainInline::Break => {
990                let spans = self.map_break_in_block(draft);
991                draft.append_mapped("\n", spans);
992            }
993            PlainInline::Skip => {}
994            PlainInline::Children => {
995                self.project_children(node, draft);
996                self.advance_after_container(node, draft);
997            }
998        }
999    }
1000
1001    fn project_ruby(&mut self, node: &serde_json::Value, draft: &mut BlockDraft) {
1002        let base = node
1003            .get("base")
1004            .and_then(serde_json::Value::as_str)
1005            .unwrap_or_default();
1006        let base_start = draft.grapheme_len();
1007        let reading_value = node
1008            .pointer("/ruby/value")
1009            .map(|value| match value {
1010                serde_json::Value::String(value) => value.clone(),
1011                serde_json::Value::Array(values) => values
1012                    .iter()
1013                    .filter_map(serde_json::Value::as_str)
1014                    .collect::<String>(),
1015                _ => String::new(),
1016            })
1017            .unwrap_or_default();
1018        let parts = find_ruby_parts(
1019            base,
1020            &reading_value,
1021            self.source,
1022            draft.source_cursor,
1023            draft.source_end,
1024        );
1025        if let Some(parts) = &parts {
1026            draft.source_cursor = parts.token_end;
1027        }
1028        let base_spans = parts
1029            .as_ref()
1030            .and_then(|parts| map_decoded(base, parts.base, parts.base_start));
1031        draft.append_mapped(base, base_spans);
1032        let base_end = draft.grapheme_len();
1033
1034        let ruby = node.get("ruby");
1035        let ruby_type = ruby
1036            .and_then(|value| value.get("type"))
1037            .and_then(serde_json::Value::as_str)
1038            .unwrap_or("group");
1039        if ruby_type == "split" {
1040            let readings = ruby
1041                .and_then(|value| value.get("value"))
1042                .and_then(serde_json::Value::as_array);
1043            if let Some(readings) = readings {
1044                let raw_parts = parts.as_ref().map(|value| split_raw_reading(value));
1045                for (index, reading) in readings
1046                    .iter()
1047                    .filter_map(serde_json::Value::as_str)
1048                    .enumerate()
1049                {
1050                    let raw = raw_parts.as_ref().and_then(|parts| parts.get(index));
1051                    let units = annotation_units(reading, raw.copied());
1052                    draft.annotations.push(AnnotationDraft {
1053                        text: reading.to_owned(),
1054                        anchor_start: base_start + index,
1055                        anchor_end: base_start + index + 1,
1056                        span: raw.map(|part| SourceSpan {
1057                            start_byte: part.1,
1058                            end_byte: part.1 + part.0.len() as u32,
1059                        }),
1060                        units,
1061                    });
1062                }
1063                return;
1064            }
1065        }
1066
1067        let reading = ruby
1068            .and_then(|value| value.get("value"))
1069            .and_then(serde_json::Value::as_str)
1070            .unwrap_or_default();
1071        if reading.is_empty() {
1072            return;
1073        }
1074        let raw = parts
1075            .as_ref()
1076            .map(|parts| (parts.reading, parts.reading_start));
1077        let had_split_syntax = parts
1078            .as_ref()
1079            .is_some_and(|parts| split_unescaped_offsets(parts.reading, '.').len() > 1);
1080        if had_split_syntax {
1081            self.diagnostics.push(Diagnostic {
1082                severity: DiagnosticSeverity::Warning,
1083                code: "mdi.textProjection.rubySplitMismatch".to_owned(),
1084                message: "split ruby component count does not match the base grapheme count; the reading was anchored to the complete base".to_owned(),
1085                span: parts.as_ref().map(|parts| SourceSpan {
1086                    start_byte: parts.token_start,
1087                    end_byte: parts.token_end,
1088                }),
1089            });
1090        }
1091        draft.annotations.push(AnnotationDraft {
1092            text: reading.to_owned(),
1093            anchor_start: base_start,
1094            anchor_end: base_end,
1095            span: raw.map(|(raw, start)| SourceSpan {
1096                start_byte: start,
1097                end_byte: start + raw.len() as u32,
1098            }),
1099            units: annotation_ruby_units(reading, raw),
1100        });
1101    }
1102
1103    fn finish(&mut self, draft: BlockDraft) {
1104        if draft.text.is_empty() {
1105            return;
1106        }
1107        let index = self.blocks.len() as u32 + 1;
1108        let grapheme_count = draft.text.graphemes(true).count();
1109        let units = normalize_units(&draft.text, &draft.unit_texts, &draft.units);
1110        let mapping_warning = draft.mapping_warning
1111            || units.contains(&UnitMap::Unmapped)
1112            || draft
1113                .annotations
1114                .iter()
1115                .any(|annotation| annotation.units.contains(&UnitMap::Unmapped));
1116        if mapping_warning {
1117            self.diagnostics.push(Diagnostic {
1118                severity: DiagnosticSeverity::Warning,
1119                code: "mdi.textProjection.unmapped".to_owned(),
1120                message: format!(
1121                    "text block {index} contains text that could not be mapped precisely"
1122                ),
1123                span: draft.span,
1124            });
1125        }
1126        let annotations = draft
1127            .annotations
1128            .into_iter()
1129            .map(|annotation| MdiTextAnnotation {
1130                kind: "rubyReading",
1131                text: annotation.text,
1132                anchor: text_range(index, annotation.anchor_start, annotation.anchor_end),
1133                span: annotation.span,
1134                source_map: source_map(index, &annotation.units),
1135            })
1136            .collect();
1137        self.blocks.push(MdiTextBlock {
1138            index,
1139            kind: draft.kind,
1140            text: draft.text,
1141            range: text_range(index, 0, grapheme_count),
1142            span: draft.span,
1143            source_map: source_map(index, &units),
1144            annotations,
1145            node: draft.node,
1146        });
1147    }
1148
1149    fn map_value_in_block(&self, value: &str, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
1150        let mapped = find_mapped_value(value, self.source, draft.source_cursor, draft.source_end)?;
1151        draft.source_cursor = mapped.consumed_end;
1152        Some(mapped.spans)
1153    }
1154
1155    fn map_value_from_node(
1156        &self,
1157        value: &str,
1158        node: &serde_json::Value,
1159        draft: &mut BlockDraft,
1160    ) -> Option<Vec<SourceSpan>> {
1161        if let Some(span) = node_span(node) {
1162            let mut suggested = span.start_byte;
1163            if suggested > draft.source_cursor
1164                && self.source.as_bytes().get(suggested as usize - 1) == Some(&b'\\')
1165            {
1166                suggested -= 1;
1167            }
1168            if suggested >= draft.source_cursor && suggested <= draft.source_end {
1169                draft.source_cursor = suggested;
1170            }
1171        }
1172        self.map_value_in_block(value, draft)
1173    }
1174
1175    fn map_delimited_in_block(
1176        &self,
1177        value: &str,
1178        draft: &mut BlockDraft,
1179        open: char,
1180        close: char,
1181    ) -> Option<Vec<SourceSpan>> {
1182        let needle = format!("{open}{value}{close}");
1183        let raw = self
1184            .source
1185            .get(draft.source_cursor as usize..draft.source_end as usize)?;
1186        let offset = raw.find(&needle)?;
1187        let token_start = draft.source_cursor + offset as u32;
1188        draft.source_cursor = token_start + needle.len() as u32;
1189        map_direct(value, token_start + open.len_utf8() as u32)
1190    }
1191
1192    fn map_image_in_block(&self, value: &str, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
1193        let raw = self
1194            .source
1195            .get(draft.source_cursor as usize..draft.source_end as usize)?;
1196        let image_start = raw.find("![")?;
1197        let alt_start = image_start + 2;
1198        let alt_end = first_unescaped(&raw[alt_start..], ']')? + alt_start;
1199        let mapped = map_decoded(
1200            value,
1201            &raw[alt_start..alt_end],
1202            draft.source_cursor + alt_start as u32,
1203        )?;
1204        let consumed = raw[alt_end..]
1205            .find(')')
1206            .map_or(alt_end + 1, |end| alt_end + end + 1);
1207        draft.source_cursor += consumed as u32;
1208        Some(mapped)
1209    }
1210
1211    fn map_inline_code_in_block(
1212        &self,
1213        value: &str,
1214        draft: &mut BlockDraft,
1215    ) -> Option<Vec<SourceSpan>> {
1216        let raw = self
1217            .source
1218            .get(draft.source_cursor as usize..draft.source_end as usize)?;
1219        for (offset, _) in raw.match_indices('`') {
1220            let opening = raw[offset..]
1221                .chars()
1222                .take_while(|character| *character == '`')
1223                .count();
1224            let delimiter = "`".repeat(opening);
1225            let inner_start = offset + opening;
1226            let Some(close_offset) = raw[inner_start..].find(&delimiter) else {
1227                continue;
1228            };
1229            let inner_end = inner_start + close_offset;
1230            let inner = &raw[inner_start..inner_end];
1231            let mut normalized = String::new();
1232            let mut spans = Vec::new();
1233            for (grapheme_offset, grapheme) in inner.grapheme_indices(true) {
1234                normalized.push_str(if grapheme == "\n" || grapheme == "\r\n" {
1235                    " "
1236                } else {
1237                    grapheme
1238                });
1239                spans.push(SourceSpan {
1240                    start_byte: draft.source_cursor + inner_start as u32 + grapheme_offset as u32,
1241                    end_byte: draft.source_cursor
1242                        + inner_start as u32
1243                        + grapheme_offset as u32
1244                        + grapheme.len() as u32,
1245                });
1246            }
1247            if normalized.starts_with(' ')
1248                && normalized.ends_with(' ')
1249                && normalized.chars().any(|character| character != ' ')
1250            {
1251                normalized.remove(0);
1252                normalized.pop();
1253                spans.remove(0);
1254                spans.pop();
1255            }
1256            if normalized == value {
1257                draft.source_cursor += (inner_end + opening) as u32;
1258                return Some(spans);
1259            }
1260        }
1261        None
1262    }
1263
1264    fn map_code_block(&self, value: &str, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
1265        let raw = self
1266            .source
1267            .get(draft.source_cursor as usize..draft.source_end as usize)?;
1268        let trimmed = raw.trim_start_matches([' ', '\t']);
1269        let fenced = trimmed.starts_with("```") || trimmed.starts_with("~~~");
1270        if fenced {
1271            let opening_end = raw.find('\n')? + 1;
1272            draft.source_cursor += opening_end as u32;
1273        }
1274        self.map_value_in_block(value, draft)
1275    }
1276
1277    fn advance_after_container(&self, node: &serde_json::Value, draft: &mut BlockDraft) {
1278        let Some(raw) = self
1279            .source
1280            .get(draft.source_cursor as usize..draft.source_end as usize)
1281        else {
1282            return;
1283        };
1284        let consumed = match node_type(node) {
1285            "link" => raw.find(']').map(|label_end| {
1286                let after_label = label_end + 1;
1287                if raw[after_label..].starts_with('(') {
1288                    raw[after_label + 1..]
1289                        .find(')')
1290                        .map_or(after_label, |end| after_label + end + 2)
1291                } else if raw[after_label..].starts_with('[') {
1292                    raw[after_label + 1..]
1293                        .find(']')
1294                        .map_or(after_label, |end| after_label + end + 2)
1295                } else {
1296                    after_label
1297                }
1298            }),
1299            "noBreak" | "warichu" | "kern" => raw.find("\x5d\x5d").map(|offset| offset + 2),
1300            "em" => raw
1301                .find("\x5d\x5d")
1302                .map(|offset| offset + 2)
1303                .or_else(|| raw.find("》》").map(|offset| offset + "》》".len())),
1304            "emphasis" | "strong" | "delete" => {
1305                let width = if node_type(node) == "emphasis" { 1 } else { 2 };
1306                raw.char_indices()
1307                    .find(|(_, character)| matches!(character, '*' | '_' | '~'))
1308                    .map(|(offset, character)| offset + character.len_utf8() * width)
1309            }
1310            _ => None,
1311        };
1312        if let Some(consumed) = consumed {
1313            draft.source_cursor += consumed as u32;
1314        }
1315    }
1316
1317    fn map_break_in_block(&self, draft: &mut BlockDraft) -> Option<Vec<SourceSpan>> {
1318        let raw = self
1319            .source
1320            .get(draft.source_cursor as usize..draft.source_end as usize)?;
1321        if let Some(offset) = raw.find("[[br]]") {
1322            let span = SourceSpan {
1323                start_byte: draft.source_cursor + offset as u32,
1324                end_byte: draft.source_cursor + offset as u32 + "[[br]]".len() as u32,
1325            };
1326            draft.source_cursor = span.end_byte;
1327            return Some(vec![span]);
1328        }
1329        let newline = raw.find('\n')?;
1330        let prefix = &raw[..newline];
1331        let marker_prefix = prefix.strip_suffix('\r').unwrap_or(prefix);
1332        let marker_start = marker_prefix
1333            .rfind('\\')
1334            .unwrap_or_else(|| marker_prefix.trim_end_matches(' ').len());
1335        let span = SourceSpan {
1336            start_byte: draft.source_cursor + marker_start as u32,
1337            end_byte: draft.source_cursor + newline as u32 + 1,
1338        };
1339        draft.source_cursor = span.end_byte;
1340        Some(vec![span])
1341    }
1342}
1343
1344fn annotation_units(value: &str, raw: Option<(&str, u32)>) -> Vec<UnitMap> {
1345    raw.and_then(|(raw, start)| map_decoded(value, raw, start))
1346        .map(|spans| spans.into_iter().map(UnitMap::Mapped).collect())
1347        .unwrap_or_else(|| vec![UnitMap::Unmapped; value.graphemes(true).count()])
1348}
1349
1350fn annotation_ruby_units(value: &str, raw: Option<(&str, u32)>) -> Vec<UnitMap> {
1351    let Some((raw, start)) = raw else {
1352        return vec![UnitMap::Unmapped; value.graphemes(true).count()];
1353    };
1354    let mut decoded = String::new();
1355    let mut spans = Vec::new();
1356    for (part_start, part_end) in split_unescaped_offsets(raw, '.') {
1357        let part = &raw[part_start..part_end];
1358        let part_decoded: String = decoded_atoms(part, start + part_start as u32)
1359            .iter()
1360            .map(|atom| atom.text.as_str())
1361            .collect();
1362        let Some(mut part_spans) = map_decoded(&part_decoded, part, start + part_start as u32)
1363        else {
1364            return vec![UnitMap::Unmapped; value.graphemes(true).count()];
1365        };
1366        decoded.push_str(&part_decoded);
1367        spans.append(&mut part_spans);
1368    }
1369    if decoded == value && spans.len() == value.graphemes(true).count() {
1370        spans.into_iter().map(UnitMap::Mapped).collect()
1371    } else {
1372        vec![UnitMap::Unmapped; value.graphemes(true).count()]
1373    }
1374}
1375
1376fn normalize_units(text: &str, unit_texts: &[String], units: &[UnitMap]) -> Vec<UnitMap> {
1377    if unit_texts.len() == units.len()
1378        && unit_texts
1379            .iter()
1380            .map(String::as_str)
1381            .eq(text.graphemes(true))
1382    {
1383        return units.to_vec();
1384    }
1385    let mut pieces = Vec::with_capacity(unit_texts.len());
1386    let mut offset = 0;
1387    for (unit_text, unit) in unit_texts.iter().zip(units) {
1388        let end = offset + unit_text.len();
1389        pieces.push((offset, end, *unit));
1390        offset = end;
1391    }
1392    if offset != text.len() {
1393        return vec![UnitMap::Unmapped; text.graphemes(true).count()];
1394    }
1395    text.grapheme_indices(true)
1396        .map(|(start, grapheme)| {
1397            let end = start + grapheme.len();
1398            let overlapping: Vec<_> = pieces
1399                .iter()
1400                .filter(|(piece_start, piece_end, _)| *piece_start < end && *piece_end > start)
1401                .map(|(_, _, unit)| *unit)
1402                .collect();
1403            if overlapping
1404                .iter()
1405                .all(|unit| matches!(unit, UnitMap::Mapped(_)))
1406            {
1407                let first = match overlapping.first() {
1408                    Some(UnitMap::Mapped(span)) => *span,
1409                    _ => return UnitMap::Unmapped,
1410                };
1411                let last = match overlapping.last() {
1412                    Some(UnitMap::Mapped(span)) => *span,
1413                    _ => return UnitMap::Unmapped,
1414                };
1415                UnitMap::Mapped(SourceSpan {
1416                    start_byte: first.start_byte,
1417                    end_byte: last.end_byte,
1418                })
1419            } else if overlapping
1420                .iter()
1421                .all(|unit| matches!(unit, UnitMap::Synthetic))
1422            {
1423                UnitMap::Synthetic
1424            } else {
1425                UnitMap::Unmapped
1426            }
1427        })
1428        .collect()
1429}
1430
1431fn source_map(block: u32, units: &[UnitMap]) -> MdiTextSourceMap {
1432    let mut map = MdiTextSourceMap::default();
1433    let mut index = 0;
1434    while index < units.len() {
1435        match units[index] {
1436            UnitMap::Mapped(first) => {
1437                let start = index;
1438                let mut boundaries = vec![first.start_byte, first.end_byte];
1439                index += 1;
1440                while let Some(UnitMap::Mapped(next)) = units.get(index).copied() {
1441                    if boundaries.last().copied() != Some(next.start_byte) {
1442                        break;
1443                    }
1444                    boundaries.push(next.end_byte);
1445                    index += 1;
1446                }
1447                map.runs.push(MdiTextSourceRun {
1448                    range: text_range(block, start, index),
1449                    source_boundaries: boundaries,
1450                });
1451            }
1452            UnitMap::Synthetic => {
1453                let start = index;
1454                while matches!(units.get(index), Some(UnitMap::Synthetic)) {
1455                    index += 1;
1456                }
1457                map.synthetic.push(text_range(block, start, index));
1458            }
1459            UnitMap::Unmapped => {
1460                let start = index;
1461                while matches!(units.get(index), Some(UnitMap::Unmapped)) {
1462                    index += 1;
1463                }
1464                map.unmapped.push(text_range(block, start, index));
1465            }
1466        }
1467    }
1468    map
1469}
1470
1471fn text_range(block: u32, start: usize, end: usize) -> MdiTextRange {
1472    MdiTextRange {
1473        start: MdiTextPosition {
1474            block,
1475            character: start as u32 + 1,
1476        },
1477        end: MdiTextPosition {
1478            block,
1479            character: end as u32 + 1,
1480        },
1481    }
1482}
1483
1484fn node_type(node: &serde_json::Value) -> &str {
1485    node.get("type")
1486        .and_then(serde_json::Value::as_str)
1487        .unwrap_or_default()
1488}
1489
1490fn children(node: &serde_json::Value) -> impl Iterator<Item = &serde_json::Value> {
1491    node.get("children")
1492        .and_then(serde_json::Value::as_array)
1493        .into_iter()
1494        .flatten()
1495}
1496
1497fn node_span(node: &serde_json::Value) -> Option<SourceSpan> {
1498    Some(SourceSpan {
1499        start_byte: node.pointer("/span/startByte")?.as_u64()? as u32,
1500        end_byte: node.pointer("/span/endByte")?.as_u64()? as u32,
1501    })
1502}
1503
1504struct MappedValue {
1505    spans: Vec<SourceSpan>,
1506    consumed_end: u32,
1507}
1508
1509fn find_mapped_value(value: &str, source: &str, start: u32, end: u32) -> Option<MappedValue> {
1510    if value.contains('\n') {
1511        let mut spans = Vec::new();
1512        let mut cursor = start;
1513        let lines: Vec<_> = value.split('\n').collect();
1514        for (index, line) in lines.iter().enumerate() {
1515            if !line.is_empty() {
1516                let mapped = find_mapped_value(line, source, cursor, end)?;
1517                cursor = mapped.consumed_end;
1518                spans.extend(mapped.spans);
1519            }
1520            if index + 1 < lines.len() {
1521                let remaining = source.get(cursor as usize..end as usize)?;
1522                let newline = remaining.find('\n')?;
1523                let newline_end = cursor + newline as u32 + 1;
1524                let newline_start = if newline > 0 && remaining.as_bytes()[newline - 1] == b'\r' {
1525                    newline_end - 2
1526                } else {
1527                    newline_end - 1
1528                };
1529                spans.push(SourceSpan {
1530                    start_byte: newline_start,
1531                    end_byte: newline_end,
1532                });
1533                cursor = newline_end;
1534            }
1535        }
1536        return Some(MappedValue {
1537            spans,
1538            consumed_end: cursor,
1539        });
1540    }
1541    let raw = source.get(start as usize..end as usize)?;
1542    let direct_offset = raw
1543        .find(value)
1544        .filter(|offset| direct_match_is_source_literal(raw, *offset, value));
1545    if direct_offset == Some(0) {
1546        return Some(MappedValue {
1547            spans: map_direct(value, start)?,
1548            consumed_end: start + value.len() as u32,
1549        });
1550    }
1551    for (candidate, _) in raw.char_indices() {
1552        if direct_offset.is_some_and(|offset| candidate > offset) {
1553            break;
1554        }
1555        if let Some((spans, consumed)) =
1556            map_decoded_prefix(value, &raw[candidate..], start + candidate as u32)
1557        {
1558            return Some(MappedValue {
1559                spans,
1560                consumed_end: start + candidate as u32 + consumed as u32,
1561            });
1562        }
1563    }
1564    None
1565}
1566
1567fn map_decoded_prefix(value: &str, raw: &str, start: u32) -> Option<(Vec<SourceSpan>, usize)> {
1568    let atoms = decoded_atoms(raw, start);
1569    let mut decoded = String::new();
1570    for atom in atoms {
1571        decoded.push_str(&atom.text);
1572        if decoded == value {
1573            let consumed = atom.span.end_byte.checked_sub(start)? as usize;
1574            return map_decoded(value, &raw[..consumed], start).map(|spans| (spans, consumed));
1575        }
1576        if !value.starts_with(&decoded) {
1577            return None;
1578        }
1579    }
1580    None
1581}
1582
1583fn direct_match_is_source_literal(raw: &str, offset: usize, value: &str) -> bool {
1584    if offset > 0 && raw.as_bytes()[offset - 1] == b'\\' {
1585        return false;
1586    }
1587    let candidate = &raw[offset..];
1588    if candidate.starts_with('&')
1589        && let Some(end) = candidate.find(';')
1590        && decode_reference(&candidate[1..end]).as_deref() == Some(value)
1591        && end + 1 != value.len()
1592    {
1593        return false;
1594    }
1595    true
1596}
1597
1598fn map_direct(value: &str, start: u32) -> Option<Vec<SourceSpan>> {
1599    Some(
1600        value
1601            .grapheme_indices(true)
1602            .map(|(offset, grapheme)| SourceSpan {
1603                start_byte: start + offset as u32,
1604                end_byte: start + offset as u32 + grapheme.len() as u32,
1605            })
1606            .collect(),
1607    )
1608}
1609
1610struct Atom {
1611    text: String,
1612    span: SourceSpan,
1613}
1614
1615fn map_decoded(value: &str, raw: &str, start: u32) -> Option<Vec<SourceSpan>> {
1616    if value == raw {
1617        return map_direct(value, start);
1618    }
1619    let atoms = decoded_atoms(raw, start);
1620    let decoded: String = atoms.iter().map(|atom| atom.text.as_str()).collect();
1621    if decoded != value {
1622        return None;
1623    }
1624    let mut atom_ranges = Vec::with_capacity(atoms.len());
1625    let mut decoded_offset = 0;
1626    for atom in &atoms {
1627        let end = decoded_offset + atom.text.len();
1628        atom_ranges.push((decoded_offset, end, atom.span));
1629        decoded_offset = end;
1630    }
1631    let mut result = Vec::new();
1632    for (offset, grapheme) in value.grapheme_indices(true) {
1633        let end = offset + grapheme.len();
1634        let overlapping: Vec<_> = atom_ranges
1635            .iter()
1636            .filter(|(atom_start, atom_end, _)| *atom_start < end && *atom_end > offset)
1637            .collect();
1638        let first = overlapping.first()?.2;
1639        let last = overlapping.last()?.2;
1640        result.push(SourceSpan {
1641            start_byte: first.start_byte,
1642            end_byte: last.end_byte,
1643        });
1644    }
1645    Some(result)
1646}
1647
1648fn decoded_atoms(raw: &str, start: u32) -> Vec<Atom> {
1649    let mut atoms = Vec::new();
1650    let mut index = 0;
1651    while index < raw.len() {
1652        let rest = &raw[index..];
1653        if rest.starts_with('\\')
1654            && let Some(next) = rest.chars().nth(1)
1655            && (next.is_ascii_punctuation() || "{}|^[]:《》\\.".contains(next))
1656        {
1657            let len = 1 + next.len_utf8();
1658            atoms.push(Atom {
1659                text: next.to_string(),
1660                span: SourceSpan {
1661                    start_byte: start + index as u32,
1662                    end_byte: start + (index + len) as u32,
1663                },
1664            });
1665            index += len;
1666            continue;
1667        }
1668        if rest.starts_with('&')
1669            && let Some(end) = rest.find(';')
1670            && let Some(decoded) = decode_reference(&rest[1..end])
1671        {
1672            atoms.push(Atom {
1673                text: decoded,
1674                span: SourceSpan {
1675                    start_byte: start + index as u32,
1676                    end_byte: start + (index + end + 1) as u32,
1677                },
1678            });
1679            index += end + 1;
1680            continue;
1681        }
1682        let character = rest.chars().next().expect("non-empty remainder");
1683        let len = character.len_utf8();
1684        atoms.push(Atom {
1685            text: character.to_string(),
1686            span: SourceSpan {
1687                start_byte: start + index as u32,
1688                end_byte: start + (index + len) as u32,
1689            },
1690        });
1691        index += len;
1692    }
1693    atoms
1694}
1695
1696fn decode_reference(body: &str) -> Option<String> {
1697    if let Some(hex) = body.strip_prefix("#x").or_else(|| body.strip_prefix("#X")) {
1698        return (!hex.is_empty() && hex.chars().all(|character| character.is_ascii_hexdigit()))
1699            .then(|| markdown::decode_numeric(hex, 16));
1700    }
1701    if let Some(decimal) = body.strip_prefix('#') {
1702        return (!decimal.is_empty()
1703            && decimal.chars().all(|character| character.is_ascii_digit()))
1704        .then(|| markdown::decode_numeric(decimal, 10));
1705    }
1706    markdown::decode_named(body, true)
1707}
1708
1709struct RubyParts<'a> {
1710    token_start: u32,
1711    base: &'a str,
1712    base_start: u32,
1713    reading: &'a str,
1714    reading_start: u32,
1715    token_end: u32,
1716}
1717
1718fn find_ruby_parts<'a>(
1719    base: &str,
1720    reading: &str,
1721    source: &'a str,
1722    start: u32,
1723    end: u32,
1724) -> Option<RubyParts<'a>> {
1725    let raw = source.get(start as usize..end as usize)?;
1726    for (offset, _) in raw.match_indices('{') {
1727        let candidate = &raw[offset..];
1728        let Some(close) = first_unescaped(&candidate[1..], '}').map(|close| close + 1) else {
1729            continue;
1730        };
1731        let body = &candidate[1..close];
1732        let Some(separator) = first_unescaped(body, '|') else {
1733            continue;
1734        };
1735        let raw_base = &body[..separator];
1736        let raw_reading = &body[separator + 1..];
1737        let decoded_base: String = decoded_atoms(raw_base, 0)
1738            .into_iter()
1739            .map(|atom| atom.text)
1740            .collect();
1741        let decoded_reading: String = split_unescaped_offsets(raw_reading, '.')
1742            .into_iter()
1743            .flat_map(|(part_start, part_end)| {
1744                decoded_atoms(&raw_reading[part_start..part_end], 0)
1745                    .into_iter()
1746                    .map(|atom| atom.text)
1747            })
1748            .collect();
1749        if decoded_base == base && decoded_reading == reading {
1750            let token_start = start + offset as u32;
1751            return Some(RubyParts {
1752                token_start,
1753                base: raw_base,
1754                base_start: token_start + 1,
1755                reading: raw_reading,
1756                reading_start: token_start + 1 + separator as u32 + 1,
1757                token_end: token_start + close as u32 + 1,
1758            });
1759        }
1760    }
1761    None
1762}
1763
1764fn split_raw_reading<'a>(parts: &'a RubyParts<'a>) -> Vec<(&'a str, u32)> {
1765    split_unescaped_offsets(parts.reading, '.')
1766        .into_iter()
1767        .map(|(start, end)| {
1768            (
1769                &parts.reading[start..end],
1770                parts.reading_start + start as u32,
1771            )
1772        })
1773        .collect()
1774}
1775
1776fn first_unescaped(value: &str, needle: char) -> Option<usize> {
1777    let mut escaped = false;
1778    for (index, character) in value.char_indices() {
1779        if escaped {
1780            escaped = false;
1781        } else if character == '\\' {
1782            escaped = true;
1783        } else if character == needle {
1784            return Some(index);
1785        }
1786    }
1787    None
1788}
1789
1790fn split_unescaped_offsets(value: &str, separator: char) -> Vec<(usize, usize)> {
1791    let mut result = Vec::new();
1792    let mut start = 0;
1793    let mut escaped = false;
1794    for (index, character) in value.char_indices() {
1795        if escaped {
1796            escaped = false;
1797        } else if character == '\\' {
1798            escaped = true;
1799        } else if character == separator {
1800            result.push((start, index));
1801            start = index + character.len_utf8();
1802        }
1803    }
1804    result.push((start, value.len()));
1805    result
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810    use super::*;
1811
1812    fn position(range: &MdiTextRange) -> (u32, u32, u32, u32) {
1813        (
1814            range.start.block,
1815            range.start.character,
1816            range.end.block,
1817            range.end.character,
1818        )
1819    }
1820
1821    fn assert_complete_mapping(block: &MdiTextBlock, source: &str) {
1822        let count = block.text.graphemes(true).count();
1823        let mut coverage = vec![0_u8; count];
1824        for run in &block.source_map.runs {
1825            let start = run.range.start.character as usize - 1;
1826            let end = run.range.end.character as usize - 1;
1827            assert_eq!(run.source_boundaries.len(), end - start + 1);
1828            for boundary in &run.source_boundaries {
1829                assert!((*boundary as usize) <= source.len());
1830                assert!(source.is_char_boundary(*boundary as usize));
1831            }
1832            for covered in &mut coverage[start..end] {
1833                *covered += 1;
1834            }
1835        }
1836        for range in &block.source_map.synthetic {
1837            let start = range.start.character as usize - 1;
1838            let end = range.end.character as usize - 1;
1839            for covered in &mut coverage[start..end] {
1840                *covered += 1;
1841            }
1842        }
1843        assert!(block.source_map.unmapped.is_empty(), "{block:#?}");
1844        assert!(coverage.iter().all(|covered| *covered == 1), "{block:#?}");
1845    }
1846
1847    #[test]
1848    fn projects_grapheme_positions_and_ruby_channels() {
1849        let result = get_mdi_text_blocks("# 序章\n\n我喜歡{東京|とうきょう}。\n\ne\u{301} 👩🏽‍💻");
1850        assert_eq!(result.blocks.len(), 3);
1851        assert_eq!(result.blocks[0].text, "序章");
1852        assert_eq!(position(&result.blocks[0].range), (1, 1, 1, 3));
1853        assert_eq!(result.blocks[1].text, "我喜歡東京。");
1854        assert_eq!(position(&result.blocks[1].range), (2, 1, 2, 7));
1855        let annotation = &result.blocks[1].annotations[0];
1856        assert_eq!(annotation.text, "とうきょう");
1857        assert_eq!(position(&annotation.anchor), (2, 4, 2, 6));
1858        assert_eq!(result.blocks[2].text.graphemes(true).count(), 3);
1859        assert_eq!(position(&result.blocks[2].range), (3, 1, 3, 4));
1860        assert!(result.diagnostics.is_empty());
1861
1862        let across_wrapper = get_mdi_text_blocks("e*\u{301}*");
1863        assert_eq!(across_wrapper.blocks[0].text, "e\u{301}");
1864        assert_eq!(position(&across_wrapper.blocks[0].range), (1, 1, 1, 2));
1865        assert!(across_wrapper.blocks[0].source_map.unmapped.is_empty());
1866
1867        let marker_text = get_mdi_text_blocks("# \\#\n\n- \\-\n\n> \\>");
1868        assert_eq!(
1869            marker_text
1870                .blocks
1871                .iter()
1872                .map(|block| block.source_map.runs[0].source_boundaries[0])
1873                .collect::<Vec<_>>(),
1874            vec![2, 8, 14]
1875        );
1876    }
1877
1878    #[test]
1879    fn maps_entities_escapes_and_mdi_delimiters_to_complete_source_tokens() {
1880        let source = r"&amp; \* {東京|とうきょう} ^12^ 前[[br]]次";
1881        let result = get_mdi_text_blocks(source);
1882        let block = &result.blocks[0];
1883        assert_eq!(block.text, "& * 東京 12 前\n次");
1884        assert!(block.source_map.unmapped.is_empty(), "{block:#?}");
1885        assert!(block.source_map.synthetic.is_empty());
1886
1887        let spans: Vec<_> = block
1888            .source_map
1889            .runs
1890            .iter()
1891            .flat_map(|run| run.source_boundaries.windows(2))
1892            .map(|pair| &source[pair[0] as usize..pair[1] as usize])
1893            .collect();
1894        assert!(spans.contains(&"&amp;"));
1895        assert!(spans.contains(&r"\*"));
1896        assert!(spans.contains(&"[[br]]"));
1897        assert!(!spans.contains(&"とうきょう"));
1898        assert!(result.diagnostics.is_empty());
1899    }
1900
1901    #[test]
1902    fn gives_each_split_ruby_reading_its_base_grapheme_anchor() {
1903        let result = get_mdi_text_blocks("{東京|とう.きょう}");
1904        let annotations = &result.blocks[0].annotations;
1905        assert_eq!(annotations.len(), 2);
1906        assert_eq!(annotations[0].text, "とう");
1907        assert_eq!(position(&annotations[0].anchor), (1, 1, 1, 2));
1908        assert_eq!(annotations[1].text, "きょう");
1909        assert_eq!(position(&annotations[1].anchor), (1, 2, 1, 3));
1910        assert!(
1911            annotations
1912                .iter()
1913                .all(|annotation| annotation.source_map.unmapped.is_empty())
1914        );
1915    }
1916
1917    #[test]
1918    fn mismatched_split_ruby_degrades_to_a_mapped_group_warning() {
1919        let result = get_mdi_text_blocks("{東京|とう.きょ.う}");
1920        let annotation = &result.blocks[0].annotations[0];
1921        assert_eq!(annotation.text, "とうきょう");
1922        assert_eq!(position(&annotation.anchor), (1, 1, 1, 3));
1923        assert!(annotation.source_map.unmapped.is_empty());
1924        assert!(
1925            result
1926                .diagnostics
1927                .iter()
1928                .any(|diagnostic| diagnostic.code == "mdi.textProjection.rubySplitMismatch")
1929        );
1930    }
1931
1932    #[test]
1933    fn collects_leaf_blocks_without_parent_text_duplication() {
1934        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---";
1935        let result = get_mdi_text_blocks(source);
1936        let summaries: Vec<_> = result
1937            .blocks
1938            .iter()
1939            .map(|block| (block.kind, block.text.as_str()))
1940            .collect();
1941        assert_eq!(
1942            summaries,
1943            vec![
1944                (MdiTextBlockKind::ListItem, "first\n\nsecond"),
1945                (MdiTextBlockKind::ListItem, "nested"),
1946                (MdiTextBlockKind::Blockquote, "quote one"),
1947                (MdiTextBlockKind::Blockquote, "quote two"),
1948                (MdiTextBlockKind::Table, "a\tb\nc\td"),
1949                (MdiTextBlockKind::Code, "code\nline"),
1950                (MdiTextBlockKind::Paragraph, "body"),
1951                (MdiTextBlockKind::Footnote, "note"),
1952            ]
1953        );
1954        assert_eq!(result.blocks[0].source_map.synthetic.len(), 1);
1955        assert_eq!(result.blocks[4].source_map.synthetic.len(), 3);
1956        assert!(
1957            result
1958                .blocks
1959                .iter()
1960                .all(|block| block.source_map.unmapped.is_empty())
1961        );
1962
1963        let fenced = get_mdi_text_blocks("```rust\nrust\n```");
1964        assert_eq!(fenced.blocks[0].text, "rust");
1965        assert_eq!(fenced.blocks[0].source_map.runs[0].source_boundaries[0], 8);
1966    }
1967
1968    #[test]
1969    fn projection_json_is_deterministic_and_keeps_the_parse_envelope() {
1970        let source = "---\nmdi: '2.0'\ntitle: x\n---\n\n# heading\n\ntext";
1971        let first = get_mdi_text_blocks_json(source);
1972        assert_eq!(first, get_mdi_text_blocks_json(source));
1973        let value: serde_json::Value = serde_json::from_str(&first).unwrap();
1974        assert_eq!(value["projectionVersion"], "1.0");
1975        assert_eq!(
1976            value["positionEncoding"],
1977            "unicode-grapheme-cluster-1-based"
1978        );
1979        assert_eq!(value["irVersion"], MDI_IR_VERSION);
1980        assert_eq!(value["document"]["frontmatter"]["entries"][0]["key"], "mdi");
1981    }
1982
1983    #[test]
1984    fn supported_inline_and_wrapped_markdown_is_fully_mapped() {
1985        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";
1986        let result = get_mdi_text_blocks(source);
1987        assert_eq!(
1988            result
1989                .blocks
1990                .iter()
1991                .map(|block| block.text.as_str())
1992                .collect::<Vec<_>>(),
1993            vec![
1994                "強調 label 代替 code * & 禁則割注詰め傍点",
1995                "first\ncontinued",
1996                "item\ncontinued",
1997            ]
1998        );
1999        for block in &result.blocks {
2000            assert_complete_mapping(block, source);
2001        }
2002    }
2003
2004    #[test]
2005    fn malformed_literals_remain_searchable_and_precisely_mapped() {
2006        for source in [
2007            "{東京|とうきょう",
2008            "[[em:未閉",
2009            "《《未閉",
2010            "^1234567^ ^12^",
2011            "<custom>literal</custom>",
2012        ] {
2013            let result = get_mdi_text_blocks(source);
2014            assert!(!result.blocks.is_empty(), "{source:?}");
2015            for block in &result.blocks {
2016                assert_complete_mapping(block, source);
2017            }
2018        }
2019        let frontmatter = get_mdi_text_blocks("---\ntitle: hidden\n---\n\nvisible");
2020        assert_eq!(frontmatter.blocks.len(), 1);
2021        assert_eq!(frontmatter.blocks[0].text, "visible");
2022    }
2023}