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