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}