Skip to main content

merman_analysis/
document.rs

1use crate::{
2    AnalysisDiagnostic, AnalysisPayload, AnalysisResult, Analyzer, DiagnosticRelated,
3    DiagnosticSpan, SourceDescriptor, SourceKind, SourceMap,
4};
5use std::path::Path;
6use std::sync::Arc;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SharedTextSlice {
10    source: Arc<str>,
11    start: usize,
12    end: usize,
13}
14
15impl SharedTextSlice {
16    pub fn whole(source: Arc<str>) -> Self {
17        let end = source.len();
18        Self {
19            source,
20            start: 0,
21            end,
22        }
23    }
24
25    pub fn from_range(source: Arc<str>, start: usize, end: usize) -> Option<Self> {
26        if start > end
27            || end > source.len()
28            || !source.is_char_boundary(start)
29            || !source.is_char_boundary(end)
30        {
31            return None;
32        }
33        Some(Self { source, start, end })
34    }
35
36    pub(crate) fn new(source: Arc<str>, start: usize, end: usize) -> Self {
37        Self::from_range(source, start, end)
38            .expect("document extraction should produce valid UTF-8 slice bounds")
39    }
40
41    pub fn as_str(&self) -> &str {
42        &self.source[self.start..self.end]
43    }
44
45    pub fn source_arc(&self) -> Arc<str> {
46        Arc::clone(&self.source)
47    }
48
49    pub fn to_owned_text(&self) -> String {
50        self.as_str().to_owned()
51    }
52
53    pub const fn start(&self) -> usize {
54        self.start
55    }
56
57    pub const fn end(&self) -> usize {
58        self.end
59    }
60}
61
62impl AsRef<str> for SharedTextSlice {
63    fn as_ref(&self) -> &str {
64        self.as_str()
65    }
66}
67
68impl std::ops::Deref for SharedTextSlice {
69    type Target = str;
70
71    fn deref(&self) -> &Self::Target {
72        self.as_str()
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum DocumentDiagramKind {
78    WholeDocument,
79    MermaidFence,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum FenceMarker {
84    Backtick,
85    Tilde,
86    Colon,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct FenceDelimiter {
91    marker: FenceMarker,
92    len: usize,
93}
94
95impl FenceDelimiter {
96    pub const fn new(marker: FenceMarker, len: usize) -> Self {
97        Self { marker, len }
98    }
99
100    pub const fn marker(self) -> FenceMarker {
101        self.marker
102    }
103
104    pub const fn len(self) -> usize {
105        self.len
106    }
107
108    const fn marker_byte(self) -> u8 {
109        match self.marker {
110            FenceMarker::Backtick => b'`',
111            FenceMarker::Tilde => b'~',
112            FenceMarker::Colon => b':',
113        }
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct DocumentDiagram {
119    pub id: String,
120    pub index: usize,
121    pub kind: DocumentDiagramKind,
122    pub source: SourceDescriptor,
123    pub start: usize,
124    pub body_start: usize,
125    pub body_end: usize,
126    pub end: usize,
127    pub text: SharedTextSlice,
128    pub fence_delimiter: Option<FenceDelimiter>,
129}
130
131#[derive(Debug, Clone)]
132pub struct DocumentSource {
133    source: SourceDescriptor,
134    text: Arc<str>,
135    source_map: SourceMap,
136    diagrams: Vec<DocumentDiagram>,
137}
138
139impl DocumentSource {
140    pub fn new(text: impl Into<Arc<str>>, source: SourceDescriptor) -> Self {
141        let text = text.into();
142        let source_map = SourceMap::new(Arc::clone(&text));
143        let diagrams = match source.kind {
144            SourceKind::Markdown | SourceKind::Mdx => extract_markdown_diagrams(&text, &source),
145            SourceKind::Diagram => vec![whole_document_diagram(Arc::clone(&text), &source)],
146        };
147
148        Self {
149            source,
150            text,
151            source_map,
152            diagrams,
153        }
154    }
155
156    pub fn source(&self) -> &SourceDescriptor {
157        &self.source
158    }
159
160    pub fn text(&self) -> &str {
161        self.text.as_ref()
162    }
163
164    pub fn source_map(&self) -> &SourceMap {
165        &self.source_map
166    }
167
168    pub fn diagrams(&self) -> &[DocumentDiagram] {
169        &self.diagrams
170    }
171
172    pub fn remap_span_to_document(
173        &self,
174        diagram: &DocumentDiagram,
175        span: DiagnosticSpan,
176    ) -> Option<DiagnosticSpan> {
177        let start = diagram.body_start.checked_add(span.byte_start)?;
178        let end = diagram.body_start.checked_add(span.byte_end)?;
179        self.source_map.span(start, end).ok()
180    }
181
182    pub fn remap_diagnostic_to_document(
183        &self,
184        diagram: &DocumentDiagram,
185        mut diagnostic: AnalysisDiagnostic,
186    ) -> AnalysisDiagnostic {
187        let fence_span = diagram
188            .is_fence()
189            .then(|| self.source_map.span(diagram.start, diagram.end).ok())
190            .flatten();
191
192        diagnostic.span = diagnostic
193            .span
194            .and_then(|span| self.remap_span_to_document(diagram, span))
195            .or(fence_span.clone());
196
197        for related in &mut diagnostic.related {
198            related.span = related
199                .span
200                .take()
201                .and_then(|span| self.remap_span_to_document(diagram, span));
202        }
203
204        for fix in &mut diagnostic.fixes {
205            fix.edits = fix
206                .edits
207                .drain(..)
208                .filter_map(|mut edit| {
209                    edit.span = self.remap_span_to_document(diagram, edit.span)?;
210                    Some(edit)
211                })
212                .collect();
213        }
214        diagnostic.fixes.retain(|fix| !fix.edits.is_empty());
215
216        if let Some(span) = fence_span {
217            diagnostic.related.push(DiagnosticRelated {
218                message: format!("Mermaid fence {}", diagram.index + 1),
219                span: Some(span),
220            });
221        }
222
223        diagnostic
224    }
225}
226
227impl DocumentDiagram {
228    pub const fn is_fence(&self) -> bool {
229        matches!(self.kind, DocumentDiagramKind::MermaidFence)
230    }
231}
232
233pub fn source_descriptor_for_kind(path: Option<&str>, kind: SourceKind) -> SourceDescriptor {
234    SourceDescriptor {
235        kind,
236        path: path.map(ToString::to_string),
237        diagram_index: None,
238        language: source_language(kind).to_string(),
239    }
240}
241
242pub fn source_descriptor_for_uri(uri: &str) -> SourceDescriptor {
243    let path_without_fragment = uri.split(['?', '#']).next().unwrap_or(uri);
244    let kind = match Path::new(path_without_fragment)
245        .extension()
246        .and_then(|ext| ext.to_str())
247    {
248        Some(ext) if crate::markdown::is_mdx_extension(ext) => SourceKind::Mdx,
249        Some(ext) if crate::markdown::is_markdown_extension(ext) => SourceKind::Markdown,
250        _ => SourceKind::Diagram,
251    };
252    source_descriptor_for_kind(Some(uri), kind)
253}
254
255pub fn source_descriptor_for_markdown_path(path: Option<&str>) -> SourceDescriptor {
256    let path_without_fragment = path.map(|path| path.split(['?', '#']).next().unwrap_or(path));
257    let kind = match path_without_fragment
258        .and_then(|path| Path::new(path).extension())
259        .and_then(|ext| ext.to_str())
260    {
261        Some(ext) if crate::markdown::is_mdx_extension(ext) => SourceKind::Mdx,
262        _ => SourceKind::Markdown,
263    };
264    source_descriptor_for_kind(path, kind)
265}
266
267pub const fn source_language(kind: SourceKind) -> &'static str {
268    match kind {
269        SourceKind::Diagram => "mermaid",
270        SourceKind::Markdown => "markdown",
271        SourceKind::Mdx => "mdx",
272    }
273}
274
275pub fn analyze_document(
276    text: &str,
277    analyzer: &Analyzer,
278    source: SourceDescriptor,
279) -> AnalysisPayload {
280    if let Some(result) = analyzer.source_limit_result(text, source.clone()) {
281        return result.into_payload();
282    }
283
284    match source.kind {
285        SourceKind::Diagram => {
286            AnalysisPayload::new(source, analyzer.analyze_source_diagnostics(text))
287        }
288        SourceKind::Markdown | SourceKind::Mdx => {
289            let document = DocumentSource::new(text, source.clone());
290            AnalysisPayload::new(source, analyze_document_diagnostics(&document, analyzer))
291        }
292    }
293}
294
295pub fn analyze_document_facts(
296    text: &str,
297    analyzer: &Analyzer,
298    source: SourceDescriptor,
299) -> crate::AnalysisFactsPayload {
300    analyze_document_result(text, analyzer, source).to_facts_payload()
301}
302
303pub fn analyze_document_result(
304    text: &str,
305    analyzer: &Analyzer,
306    source: SourceDescriptor,
307) -> AnalysisResult {
308    analyze_document_result_shared(Arc::from(text), analyzer, source)
309}
310
311pub fn analyze_document_result_shared(
312    text: Arc<str>,
313    analyzer: &Analyzer,
314    source: SourceDescriptor,
315) -> AnalysisResult {
316    if let Some(result) = analyzer.source_limit_result(text.as_ref(), source.clone()) {
317        return result;
318    }
319
320    let document = DocumentSource::new(text, source.clone());
321
322    let mut diagnostics = Vec::new();
323    let mut analyzed_diagrams = Vec::new();
324    for diagram in document.diagrams() {
325        let analyzed = analyzer.analyze_diagram(diagram);
326        extend_document_diagnostics(
327            &mut diagnostics,
328            &document,
329            diagram,
330            analyzed.diagnostics.iter().cloned(),
331        );
332        analyzed_diagrams.push(analyzed);
333    }
334    AnalysisResult::new(
335        source,
336        document.source_map().clone(),
337        diagnostics,
338        analyzed_diagrams,
339    )
340}
341
342fn analyze_document_diagnostics(
343    document: &DocumentSource,
344    analyzer: &Analyzer,
345) -> Vec<AnalysisDiagnostic> {
346    let mut diagnostics = Vec::new();
347    for diagram in document.diagrams() {
348        let diagram_diagnostics = analyzer.analyze_diagram_diagnostics(diagram);
349        extend_document_diagnostics(&mut diagnostics, document, diagram, diagram_diagnostics);
350    }
351    diagnostics
352}
353
354fn extend_document_diagnostics(
355    diagnostics: &mut Vec<AnalysisDiagnostic>,
356    document: &DocumentSource,
357    diagram: &DocumentDiagram,
358    diagram_diagnostics: impl IntoIterator<Item = AnalysisDiagnostic>,
359) {
360    match document.source().kind {
361        SourceKind::Diagram => diagnostics.extend(diagram_diagnostics),
362        SourceKind::Markdown | SourceKind::Mdx => diagnostics.extend(
363            diagram_diagnostics
364                .into_iter()
365                .map(|diagnostic| document.remap_diagnostic_to_document(diagram, diagnostic)),
366        ),
367    }
368}
369
370pub(crate) fn whole_document_diagram(text: Arc<str>, source: &SourceDescriptor) -> DocumentDiagram {
371    let len = text.len();
372    DocumentDiagram {
373        id: "document".to_string(),
374        index: 0,
375        kind: DocumentDiagramKind::WholeDocument,
376        source: source.clone(),
377        start: 0,
378        body_start: 0,
379        body_end: len,
380        end: len,
381        text: SharedTextSlice::new(text, 0, len),
382        fence_delimiter: None,
383    }
384}
385
386fn extract_markdown_diagrams(text: &Arc<str>, source: &SourceDescriptor) -> Vec<DocumentDiagram> {
387    let mut diagrams = Vec::new();
388    let mut cursor = 0;
389    let document_text = text.as_ref();
390
391    while cursor < document_text.len() {
392        let line_end = next_line_end(document_text, cursor);
393        let line = trim_line_ending(&document_text[cursor..line_end]);
394
395        if let Some(opening) = markdown_fence_opening(line) {
396            if !opening.is_mermaid {
397                cursor = skip_markdown_fence(document_text, line_end, opening.delimiter);
398                continue;
399            }
400
401            let delimiter = opening.delimiter;
402            let body_start = line_end;
403            let mut body_end = document_text.len();
404            let mut search_start = body_start;
405
406            while search_start < document_text.len() {
407                let closing_end = next_line_end(document_text, search_start);
408                let closing_line = trim_line_ending(&document_text[search_start..closing_end]);
409                if is_matching_closing_fence(closing_line, delimiter) {
410                    body_end = search_start;
411                    push_markdown_diagram(
412                        &mut diagrams,
413                        Arc::clone(text),
414                        source,
415                        cursor,
416                        body_start,
417                        body_end,
418                        closing_end,
419                        delimiter,
420                    );
421                    cursor = closing_end;
422                    break;
423                }
424                search_start = closing_end;
425            }
426
427            if body_end == document_text.len() {
428                push_markdown_diagram(
429                    &mut diagrams,
430                    Arc::clone(text),
431                    source,
432                    cursor,
433                    body_start,
434                    body_end,
435                    document_text.len(),
436                    delimiter,
437                );
438                break;
439            }
440
441            continue;
442        }
443
444        cursor = if line_end == cursor {
445            document_text.len()
446        } else {
447            line_end
448        };
449    }
450
451    diagrams
452}
453
454fn push_markdown_diagram(
455    diagrams: &mut Vec<DocumentDiagram>,
456    text: Arc<str>,
457    document_source: &SourceDescriptor,
458    start: usize,
459    body_start: usize,
460    body_end: usize,
461    end: usize,
462    fence_delimiter: FenceDelimiter,
463) {
464    let index = diagrams.len();
465    diagrams.push(DocumentDiagram {
466        id: format!("mermaid-fence-{}", index + 1),
467        index,
468        kind: DocumentDiagramKind::MermaidFence,
469        source: document_source
470            .clone()
471            .with_diagram_index(index)
472            .with_language("mermaid"),
473        start,
474        body_start,
475        body_end,
476        end,
477        text: SharedTextSlice::new(text, body_start, body_end),
478        fence_delimiter: Some(fence_delimiter),
479    });
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483struct MarkdownFenceOpening {
484    delimiter: FenceDelimiter,
485    is_mermaid: bool,
486}
487
488fn markdown_fence_opening(line: &str) -> Option<MarkdownFenceOpening> {
489    let trimmed = trim_fence_indent(line)?;
490    let first = trimmed.as_bytes().first().copied()?;
491    let marker = match first {
492        b'`' => FenceMarker::Backtick,
493        b'~' => FenceMarker::Tilde,
494        b':' => FenceMarker::Colon,
495        _ => return None,
496    };
497    let len = repeated_marker_len(trimmed.as_bytes(), first);
498    if len < 3 {
499        return None;
500    }
501
502    let rest = trimmed[len..].trim_start();
503    if rest.is_empty() {
504        return Some(MarkdownFenceOpening {
505            delimiter: FenceDelimiter::new(marker, len),
506            is_mermaid: false,
507        });
508    }
509
510    let language = "mermaid";
511    let language_len = language.len();
512    if !rest
513        .as_bytes()
514        .get(..language_len)
515        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(language.as_bytes()))
516    {
517        return Some(MarkdownFenceOpening {
518            delimiter: FenceDelimiter::new(marker, len),
519            is_mermaid: false,
520        });
521    }
522    let tail = &rest[language_len..];
523    let is_mermaid = tail.is_empty() || tail.starts_with(char::is_whitespace);
524    Some(MarkdownFenceOpening {
525        delimiter: FenceDelimiter::new(marker, len),
526        is_mermaid,
527    })
528}
529
530fn is_matching_closing_fence(line: &str, delimiter: FenceDelimiter) -> bool {
531    let Some(trimmed) = trim_fence_indent(line) else {
532        return false;
533    };
534    let marker = delimiter.marker_byte();
535    let len = repeated_marker_len(trimmed.as_bytes(), marker);
536    if len < delimiter.len() {
537        return false;
538    }
539    trimmed[len..].chars().all(|ch| ch.is_whitespace())
540}
541
542fn skip_markdown_fence(text: &str, mut cursor: usize, delimiter: FenceDelimiter) -> usize {
543    while cursor < text.len() {
544        let line_end = next_line_end(text, cursor);
545        let line = trim_line_ending(&text[cursor..line_end]);
546        if is_matching_closing_fence(line, delimiter) {
547            return line_end;
548        }
549        cursor = line_end;
550    }
551    text.len()
552}
553
554fn trim_fence_indent(line: &str) -> Option<&str> {
555    let mut spaces = 0usize;
556    for (index, byte) in line.bytes().enumerate() {
557        match byte {
558            b' ' if spaces < 3 => spaces += 1,
559            b' ' => return None,
560            b'\t' => return None,
561            _ => return Some(&line[index..]),
562        }
563    }
564    Some("")
565}
566
567fn repeated_marker_len(bytes: &[u8], marker: u8) -> usize {
568    bytes.iter().take_while(|byte| **byte == marker).count()
569}
570
571fn trim_line_ending(line: &str) -> &str {
572    line.strip_suffix('\n')
573        .map(|line| line.strip_suffix('\r').unwrap_or(line))
574        .unwrap_or_else(|| line.strip_suffix('\r').unwrap_or(line))
575}
576
577fn next_line_end(source: &str, start: usize) -> usize {
578    let bytes = source.as_bytes();
579    let mut index = start;
580    while index < bytes.len() {
581        match bytes[index] {
582            b'\n' => return index + 1,
583            b'\r' if bytes.get(index + 1) == Some(&b'\n') => return index + 2,
584            b'\r' => return index + 1,
585            _ => index += 1,
586        }
587    }
588    source.len()
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::{
595        AnalysisDiagnostic, Analyzer, DiagnosticCategory, DiagnosticFix, DiagnosticFixEdit,
596        DiagnosticRelated,
597    };
598
599    #[test]
600    fn plain_documents_use_plain_analysis_path() {
601        let analyzer = Analyzer::new();
602        let source = SourceDescriptor::diagram().with_path("file:///tmp/example.mmd");
603        let payload = analyze_document("flowchart TD\nA-->B\n", &analyzer, source.clone());
604
605        assert_eq!(payload.source, source);
606        assert!(payload.valid);
607        assert!(payload.diagnostics.is_empty());
608    }
609
610    #[test]
611    fn plain_document_source_creates_single_document_diagram() {
612        let source = SourceDescriptor::diagram().with_path("file:///tmp/example.mmd");
613        let document = DocumentSource::new("flowchart TD\nA-->B\n", source.clone());
614
615        assert_eq!(document.source(), &source);
616        assert_eq!(document.diagrams().len(), 1);
617        let diagram = &document.diagrams()[0];
618        assert_eq!(diagram.id, "document");
619        assert_eq!(diagram.kind, DocumentDiagramKind::WholeDocument);
620        assert_eq!(diagram.body_start, 0);
621        assert_eq!(diagram.body_end, document.text().len());
622        assert_eq!(diagram.source, source);
623    }
624
625    #[test]
626    fn markdown_documents_use_fence_analysis_path() {
627        let analyzer = Analyzer::new();
628        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
629        let payload = analyze_document(
630            "before\n```mermaid\nflowchart TD\nA-->B\n```\nafter\n",
631            &analyzer,
632            source.clone(),
633        );
634
635        assert_eq!(payload.source, source);
636        assert!(payload.valid);
637        assert!(payload.diagnostics.is_empty());
638    }
639
640    #[test]
641    fn markdown_document_source_extracts_stable_fence_sources() {
642        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.mdx"));
643        let document = DocumentSource::new(
644            "before\n```mermaid\nflowchart LR\nA-->B\n```\n~~~mermaid\nsequenceDiagram\nA->>B: Hi\n~~~~\n",
645            source.clone(),
646        );
647
648        assert_eq!(document.diagrams().len(), 2);
649        assert_eq!(document.diagrams()[0].id, "mermaid-fence-1");
650        assert_eq!(document.diagrams()[1].id, "mermaid-fence-2");
651        assert_eq!(document.diagrams()[0].source.kind, SourceKind::Mdx);
652        assert_eq!(document.diagrams()[0].source.diagram_index, Some(0));
653        assert_eq!(document.diagrams()[0].source.language, "mermaid");
654        assert_eq!(
655            document.diagrams()[1].fence_delimiter.unwrap().marker(),
656            FenceMarker::Tilde
657        );
658        assert!(document.diagrams()[0].text.contains("flowchart LR"));
659        assert!(document.diagrams()[1].text.contains("sequenceDiagram"));
660    }
661
662    #[test]
663    fn markdown_document_source_extracts_bare_cr_fences() {
664        let source = "before\r```mermaid\rflowchart LR\rA-->B\r```\rafter";
665        let document = DocumentSource::new(
666            source,
667            source_descriptor_for_markdown_path(Some("file:///tmp/example.md")),
668        );
669
670        assert_eq!(document.diagrams().len(), 1);
671        let diagram = &document.diagrams()[0];
672        assert_eq!(diagram.text.as_str(), "flowchart LR\rA-->B\r");
673
674        let local_map = SourceMap::new(diagram.text.as_str());
675        let start = local_map.source().find('A').unwrap();
676        let end = start + 1;
677        let diagnostic = AnalysisDiagnostic::error(
678            "merman.parse.diagram_parse",
679            DiagnosticCategory::Parse,
680            "boom",
681        )
682        .with_span(local_map.span(start, end).unwrap());
683
684        let remapped = document.remap_diagnostic_to_document(diagram, diagnostic);
685        let span = remapped.span.unwrap();
686        assert_eq!(&source[span.byte_start..span.byte_end], "A");
687        assert_eq!(span.line, 4);
688    }
689
690    #[test]
691    fn source_descriptor_for_uri_preserves_uri_and_uses_extension_before_fragment() {
692        let source = source_descriptor_for_uri("file:///tmp/example.mdx?rev=1#fence");
693
694        assert_eq!(source.kind, SourceKind::Mdx);
695        assert_eq!(
696            source.path.as_deref(),
697            Some("file:///tmp/example.mdx?rev=1#fence")
698        );
699        assert_eq!(source.language, "mdx");
700    }
701
702    #[test]
703    fn source_descriptor_for_uri_treats_markdown_extensions_case_insensitively() {
704        let markdown = source_descriptor_for_uri("file:///tmp/README.MD");
705        let mdx = source_descriptor_for_uri("file:///tmp/Story.MDX?rev=1#fence");
706
707        assert_eq!(markdown.kind, SourceKind::Markdown);
708        assert_eq!(markdown.language, "markdown");
709        assert_eq!(mdx.kind, SourceKind::Mdx);
710        assert_eq!(mdx.language, "mdx");
711    }
712
713    #[test]
714    fn markdown_document_source_accepts_commonmark_spaced_info_strings() {
715        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
716        let document = DocumentSource::new(
717            "before\n```` mermaid title=Main\nflowchart LR\nA-->B\n````\n~~~ Mermaid\nsequenceDiagram\nA->>B: Hi\n~~~\n:::MERMAID extra info\npie title Work\n:::\n",
718            source,
719        );
720
721        assert_eq!(document.diagrams().len(), 3);
722        assert!(document.diagrams()[0].text.contains("flowchart LR"));
723        assert_eq!(
724            document.diagrams()[1].fence_delimiter.unwrap().marker(),
725            FenceMarker::Tilde
726        );
727        assert_eq!(
728            document.diagrams()[2].fence_delimiter.unwrap().marker(),
729            FenceMarker::Colon
730        );
731        assert!(document.diagrams()[2].text.contains("pie title Work"));
732    }
733
734    #[test]
735    fn markdown_document_source_rejects_mermaid_prefix_without_language_boundary() {
736        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
737        let document = DocumentSource::new("```mermaidx\nflowchart LR\n```\n", source);
738
739        assert!(document.diagrams().is_empty());
740    }
741
742    #[test]
743    fn markdown_document_source_ignores_unicode_info_strings_without_panicking() {
744        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
745        let document = DocumentSource::new("```💡💡\nflowchart LR\n```\n", source);
746
747        assert!(document.diagrams().is_empty());
748    }
749
750    #[test]
751    fn markdown_document_source_ignores_mermaid_examples_inside_other_fences() {
752        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
753        let document = DocumentSource::new(
754            "````text\n```mermaid\nflowchart LR\nA-->B\n```\n````\n```mermaid\nflowchart TD\nC-->D\n```\n",
755            source,
756        );
757
758        assert_eq!(document.diagrams().len(), 1);
759        assert!(document.diagrams()[0].text.contains("flowchart TD"));
760        assert!(!document.diagrams()[0].text.contains("flowchart LR"));
761    }
762
763    #[test]
764    fn markdown_document_source_rejects_indented_mermaid_fences() {
765        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
766        let document = DocumentSource::new(
767            "    ```mermaid\n    flowchart LR\n    ```\n   ```mermaid\nflowchart TD\n```\n",
768            source,
769        );
770
771        assert_eq!(document.diagrams().len(), 1);
772        assert!(document.diagrams()[0].text.contains("flowchart TD"));
773    }
774
775    #[test]
776    fn unclosed_fences_still_create_deterministic_sources() {
777        let source = source_descriptor_for_markdown_path(Some("file:///tmp/example.md"));
778        let document = DocumentSource::new("before\n```mermaid\nflowchart TD\nA-->B\n", source);
779
780        assert_eq!(document.diagrams().len(), 1);
781        let diagram = &document.diagrams()[0];
782        assert_eq!(diagram.end, document.text().len());
783        assert_eq!(diagram.body_end, document.text().len());
784        assert!(diagram.text.contains("A-->B"));
785    }
786
787    #[test]
788    fn remaps_diagnostics_back_into_host_document_coordinates() {
789        let source = "before\n```mermaid\nflowchart TD\nA-->B\n```\nafter";
790        let document = DocumentSource::new(
791            source,
792            source_descriptor_for_markdown_path(Some("example.md")),
793        );
794        let diagram = &document.diagrams()[0];
795        let local_map = SourceMap::new(diagram.text.as_str());
796        let start = local_map.source().find('A').unwrap();
797        let end = local_map.source().find("-->").unwrap();
798        let diagnostic = AnalysisDiagnostic::error(
799            "merman.parse.diagram_parse",
800            DiagnosticCategory::Parse,
801            "boom",
802        )
803        .with_span(local_map.span(start, end).unwrap());
804
805        let remapped = document.remap_diagnostic_to_document(diagram, diagnostic);
806
807        assert_eq!(remapped.span.unwrap().line, 4);
808        assert_eq!(remapped.related.len(), 1);
809    }
810
811    #[test]
812    fn remaps_existing_related_spans_back_into_host_document_coordinates() {
813        let source = "before\n```mermaid\nflowchart TD\nA-->B\n```\nafter";
814        let document = DocumentSource::new(
815            source,
816            source_descriptor_for_markdown_path(Some("example.md")),
817        );
818        let diagram = &document.diagrams()[0];
819        let local_map = SourceMap::new(diagram.text.as_str());
820        let start = local_map.source().find('B').unwrap();
821        let end = start + 1;
822        let mut diagnostic = AnalysisDiagnostic::error(
823            "merman.parse.diagram_parse",
824            DiagnosticCategory::Parse,
825            "boom",
826        );
827        diagnostic.related.push(DiagnosticRelated {
828            message: "related node".to_string(),
829            span: Some(local_map.span(start, end).unwrap()),
830        });
831
832        let remapped = document.remap_diagnostic_to_document(diagram, diagnostic);
833
834        let related_span = remapped.related[0].span.as_ref().unwrap();
835        assert_eq!(&source[related_span.byte_start..related_span.byte_end], "B");
836        assert_eq!(related_span.line, 4);
837    }
838
839    #[test]
840    fn remaps_fix_edits_back_into_host_document_coordinates() {
841        let source = "before\n```mermaid\n%%{ initialize: {\"theme\":\"dark\"} }%%\nflowchart TD\nA-->B\n```\nafter";
842        let document = DocumentSource::new(
843            source,
844            source_descriptor_for_markdown_path(Some("example.md")),
845        );
846        let diagram = &document.diagrams()[0];
847        let local_map = SourceMap::new(diagram.text.as_str());
848        let start = local_map.source().find("initialize").unwrap();
849        let end = start + "initialize".len();
850        let local_span = local_map.span(start, end).unwrap();
851        let diagnostic = AnalysisDiagnostic::error(
852            crate::rules::PREFER_INIT_DIRECTIVE_RULE_ID,
853            DiagnosticCategory::Config,
854            "prefer init",
855        )
856        .with_span(local_span.clone())
857        .with_fix(DiagnosticFix::new(
858            "Replace `initialize` with `init`",
859            vec![DiagnosticFixEdit::new(local_span, "init")],
860        ));
861
862        let remapped = document.remap_diagnostic_to_document(diagram, diagnostic);
863        let edit_span = &remapped.fixes[0].edits[0].span;
864
865        assert_eq!(
866            &source[edit_span.byte_start..edit_span.byte_end],
867            "initialize"
868        );
869        assert_eq!(edit_span.line, 3);
870        assert_eq!(remapped.fixes[0].edits[0].replacement, "init");
871    }
872}