1use std::collections::BTreeMap;
4
5use cpd_core::models::{DetectionToken, Location, Token, TokenKind};
6
7use crate::embedded::blank_ranges_preserve_newlines;
8use crate::formats::resolve_format;
9use crate::line_index::LineIndex;
10use crate::tokenizer::{Mode, TokenMap, TokenizeOptions, push_token, tokenize_format_to_detection};
11
12pub struct LineSpan {
13 pub start: usize,
14 pub end: usize,
15 pub next_start: usize,
16}
17
18pub fn line_spans(content: &str) -> Vec<LineSpan> {
19 if content.is_empty() {
20 return Vec::new();
21 }
22 let mut spans = Vec::new();
23 let bytes = content.as_bytes();
24 let len = bytes.len();
25 let mut pos = 0usize;
26 while pos <= len {
27 let line_end = bytes[pos..]
28 .iter()
29 .position(|&b| b == b'\n')
30 .map(|i| pos + i)
31 .unwrap_or(len);
32 let content_end = if line_end > pos && bytes[line_end - 1] == b'\r' {
33 line_end - 1
34 } else {
35 line_end
36 };
37 let next_start = if line_end < len { line_end + 1 } else { len };
38 spans.push(LineSpan {
39 start: pos,
40 end: content_end,
41 next_start,
42 });
43 if next_start <= pos {
44 break;
45 }
46 pos = next_start;
47 if pos >= len && (len == 0 || bytes[len - 1] != b'\n') {
48 break;
49 }
50 }
51 spans
52}
53
54#[derive(Debug, Clone)]
55struct MarkdownFence {
56 format: String,
57 #[allow(dead_code)]
58 front_matter: bool,
59 block_start: usize,
60 inner_start: usize,
61 inner_end: usize,
62 block_end: usize,
63}
64
65struct FenceOpen {
66 marker: u8,
67 len: usize,
68 info: String,
69}
70
71fn parse_opening_fence(line: &str) -> Option<FenceOpen> {
72 let bytes = line.as_bytes();
73 let marker = *bytes.first()?;
74 if !matches!(marker, b'`' | b'~') {
75 return None;
76 }
77 let len = bytes.iter().take_while(|&&b| b == marker).count();
78 if len < 3 {
79 return None;
80 }
81 Some(FenceOpen {
82 marker,
83 len,
84 info: line[len..].trim().to_string(),
85 })
86}
87
88fn is_closing_fence(line: &str, open: &FenceOpen) -> bool {
89 let trimmed = line.trim();
90 let bytes = trimmed.as_bytes();
91 if bytes.is_empty() {
92 return false;
93 }
94 let len = bytes.iter().take_while(|&&b| b == open.marker).count();
95 len >= open.len && bytes[len..].iter().all(|&b| b == b' ' || b == b'\t')
96}
97
98fn resolve_fence_format(info: &str) -> Option<&'static str> {
99 let tag = info.split_whitespace().next()?;
100 resolve_format(tag)
101}
102
103fn fence_bounds(
104 content: &str,
105 lines: &[LineSpan],
106 close_idx: usize,
107 inner_start: usize,
108) -> (usize, usize) {
109 let inner_end = content[..lines[close_idx].start]
110 .strip_suffix('\n')
111 .map(|prefix| prefix.len())
112 .unwrap_or(lines[close_idx].start)
113 .max(inner_start);
114 let block_end = lines[close_idx].next_start.min(content.len());
115 (inner_end, block_end)
116}
117
118fn extract_code_fences(content: &str) -> Vec<MarkdownFence> {
119 let lines = line_spans(content);
120 let mut fences = Vec::new();
121 let mut idx = 0usize;
122 while idx < lines.len() {
123 let line_text = &content[lines[idx].start..lines[idx].end];
124 let Some(open) = parse_opening_fence(line_text) else {
125 idx += 1;
126 continue;
127 };
128 let resolved = resolve_fence_format(&open.info);
129 let close_idx = lines[idx + 1..]
130 .iter()
131 .position(|span| {
132 let candidate = &content[span.start..span.end];
133 is_closing_fence(candidate, &open)
134 })
135 .map(|p| idx + 1 + p);
136 let Some(close_idx) = close_idx else {
137 idx += 1;
138 continue;
139 };
140 let inner_start = lines
141 .get(idx + 1)
142 .map(|s| s.start)
143 .unwrap_or(lines[idx].next_start);
144 let (inner_end, block_end) = fence_bounds(content, &lines, close_idx, inner_start);
145 let format = resolved.map(|r| r.to_string()).unwrap_or_else(|| {
146 open.info
147 .split_whitespace()
148 .next()
149 .unwrap_or("")
150 .to_string()
151 });
152 fences.push(MarkdownFence {
153 format,
154 front_matter: false,
155 block_start: lines[idx].start,
156 inner_start,
157 inner_end,
158 block_end,
159 });
160 idx = close_idx + 1;
161 }
162 fences
163}
164
165fn extract_front_matter(content: &str) -> Option<MarkdownFence> {
166 if !(content.starts_with("---\n") || content.starts_with("---\r\n")) {
167 return None;
168 }
169 let lines = line_spans(content);
170 let close_idx = lines
171 .iter()
172 .enumerate()
173 .skip(1)
174 .find(|(_, span)| {
175 let line = content[span.start..span.end].trim();
176 line == "---" || line == "..."
177 })
178 .map(|(idx, _)| idx)?;
179 let inner_start = lines.get(1)?.start;
180 let (inner_end, block_end) = fence_bounds(content, &lines, close_idx, inner_start);
181 Some(MarkdownFence {
182 format: "yaml".to_string(),
183 front_matter: true,
184 block_start: 0,
185 inner_start,
186 inner_end,
187 block_end,
188 })
189}
190
191fn collect_ignore_byte_ranges(content: &str) -> Vec<[usize; 2]> {
192 let lines = line_spans(content);
193 let mut ranges = Vec::new();
194 let mut in_ignore = false;
195 let mut ignore_start: usize = 0;
196 for span in &lines {
197 let line = &content[span.start..span.end];
198 if line.contains("jscpd:ignore-start") {
199 in_ignore = true;
200 ignore_start = span.start;
201 } else if line.contains("jscpd:ignore-end") && in_ignore {
202 let end = span.next_start.min(content.len());
203 ranges.push([ignore_start, end]);
204 in_ignore = false;
205 }
206 }
207 ranges
208}
209
210pub fn tokens_to_detection(tokens: Vec<Token>, options: &TokenizeOptions) -> Vec<DetectionToken> {
211 let mut detection = Vec::with_capacity(tokens.len());
212 for t in tokens {
213 let byte_start = t.start.offset as usize;
214 let byte_end = t.end.offset as usize;
215 push_token(
216 &mut detection,
217 t.kind,
218 &t.value,
219 byte_start,
220 byte_end,
221 t.start,
222 t.end,
223 options,
224 );
225 }
226 detection
227}
228
229pub fn offset_detection_tokens(
230 tokens: &mut [DetectionToken],
231 byte_offset: usize,
232 start_location: &Location,
233) {
234 let line_offset = start_location.line.saturating_sub(1);
235 let col_offset = start_location.column;
236 for t in tokens.iter_mut() {
237 t.start.line += line_offset;
238 t.end.line += line_offset;
239 t.start.offset += byte_offset as u32;
240 t.end.offset += byte_offset as u32;
241 t.range[0] += byte_offset;
242 t.range[1] += byte_offset;
243 if t.start.line == start_location.line {
244 t.start.column += col_offset;
245 }
246 if t.end.line == start_location.line {
247 t.end.column += col_offset;
248 }
249 }
250}
251
252pub fn tokenize_markdown_maps(source: &str, options: &TokenizeOptions) -> Vec<TokenMap> {
253 if source.is_empty() {
254 return Vec::new();
255 }
256
257 let ignore_ranges = collect_ignore_byte_ranges(source);
258
259 let mut fences = extract_code_fences(source);
260 if let Some(fm) = extract_front_matter(source) {
261 fences.push(fm);
262 fences.sort_by_key(|f| f.block_start);
263 }
264
265 let sanitized = blank_ranges_preserve_newlines(
266 source,
267 &fences
268 .iter()
269 .map(|f| [f.block_start, f.block_end])
270 .collect::<Vec<_>>(),
271 );
272
273 let line_index = LineIndex::new(source.as_bytes());
274
275 let body_tokens = crate::generic::tokenize_generic(&sanitized, "markdown");
276 let mut markdown_detection = tokens_to_detection(body_tokens, options);
277 markdown_detection.retain(|t| t.range[0] < t.range[1]);
278
279 let mut maps = Vec::new();
280 if !markdown_detection.is_empty() {
281 maps.push(TokenMap {
282 format: "markdown".to_string(),
283 tokens: markdown_detection,
284 });
285 }
286
287 let mut embedded_maps: BTreeMap<String, Vec<DetectionToken>> = BTreeMap::new();
288 for fence in &fences {
289 let inner = &source[fence.inner_start..fence.inner_end];
290 let resolved = resolve_format(&fence.format).unwrap_or("text");
291 let outer_ignored = ignore_ranges
292 .iter()
293 .any(|[rs, re]| fence.inner_start < *re && fence.inner_end > *rs);
294
295 let mut inner_tokens = tokenize_to_detection_inner(resolved, inner, options);
296
297 if outer_ignored {
298 for t in &mut inner_tokens {
299 t.range = [0, 0]; }
301 continue;
302 }
303
304 let inner_start_loc = line_index.location(fence.inner_start);
305 offset_detection_tokens(&mut inner_tokens, fence.inner_start, &inner_start_loc);
306
307 embedded_maps
308 .entry(resolved.to_string())
309 .or_default()
310 .extend(inner_tokens);
311 }
312
313 for (format, tokens) in embedded_maps {
314 maps.push(TokenMap { format, tokens });
315 }
316
317 maps
318}
319
320fn tokenize_to_detection_inner(
321 format: &str,
322 source: &str,
323 options: &TokenizeOptions,
324) -> Vec<DetectionToken> {
325 tokenize_format_to_detection(format, source, options)
326}
327
328pub fn tokenize_markdown(source: &str, mode: Mode) -> Vec<Token> {
329 if source.is_empty() {
330 return Vec::new();
331 }
332
333 let mut in_ignore = false;
334 let mut ignore_ranges: Vec<(u32, u32)> = Vec::new();
335 let mut ignore_start = 0u32;
336
337 for (line_idx, line) in source.lines().enumerate() {
338 let line_num = line_idx as u32 + 1;
339 if line.contains("jscpd:ignore-start") {
340 in_ignore = true;
341 ignore_start = line_num;
342 } else if line.contains("jscpd:ignore-end") && in_ignore {
343 ignore_ranges.push((ignore_start, line_num));
344 in_ignore = false;
345 }
346 }
347
348 let fences = extract_fences(source);
349 let mut all_tokens = Vec::new();
350
351 for fence in &fences {
352 let in_outer_ignore = ignore_ranges
353 .iter()
354 .any(|(start, end)| fence.start_line >= *start && fence.start_line <= *end);
355
356 let format = fence.language.as_deref().unwrap_or("text");
357 let mut fence_tokens = crate::tokenizer::tokenize(format, &fence.content, mode);
358
359 let line_offset = fence.start_line.saturating_sub(1);
360 for token in &mut fence_tokens {
361 token.start.line += line_offset;
362 token.end.line += line_offset;
363 if in_outer_ignore {
364 token.kind = TokenKind::Ignore;
365 }
366 }
367
368 all_tokens.extend(fence_tokens);
369 }
370
371 all_tokens
372}
373
374struct CodeFence {
376 language: Option<String>,
377 content: String,
378 start_line: u32,
379}
380
381fn extract_fences(source: &str) -> Vec<CodeFence> {
382 let mut fences = Vec::new();
383 let mut in_fence = false;
384 let mut fence_char = '`';
385 let mut fence_lang: Option<String> = None;
386 let mut fence_content = String::new();
387 let mut fence_start_line = 0u32;
388
389 for (line_idx, line) in source.lines().enumerate() {
390 let line_num = line_idx as u32 + 1;
391 let trimmed = line.trim_start();
392
393 if !in_fence {
394 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
395 let fc = trimmed.chars().next().unwrap_or('`');
396 let rest = trimmed.trim_start_matches(fc).trim();
397 fence_lang = if rest.is_empty() {
398 None
399 } else {
400 Some(rest.to_string())
401 };
402 fence_char = fc;
403 in_fence = true;
404 fence_content.clear();
405 fence_start_line = line_num + 1;
406 }
407 } else {
408 let close_trimmed = trimmed.trim_end();
409 if is_closing_fence_legacy(close_trimmed, fence_char) {
410 fences.push(CodeFence {
411 language: fence_lang.take(),
412 content: fence_content.clone(),
413 start_line: fence_start_line,
414 });
415 fence_content.clear();
416 in_fence = false;
417 } else {
418 fence_content.push_str(line);
419 fence_content.push('\n');
420 }
421 }
422 }
423
424 fences
425}
426
427fn is_closing_fence_legacy(line: &str, fence_char: char) -> bool {
428 if !line.starts_with(fence_char) {
429 return false;
430 }
431 let count = line.chars().take_while(|&c| c == fence_char).count();
432 if count < 3 {
433 return false;
434 }
435 line.chars().skip(count).all(|c| c == ' ' || c == '\t')
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::tokenizer::Mode;
442
443 const MD_WITH_JS: &str = "# Header\n\nSome prose.\n\n```javascript\nfunction hello() { return 42; }\n```\n\nMore prose.\n";
446 const MD_NO_FENCES: &str = "# Just a Header\n\nSome plain text with no code.\n";
447 const MD_UNKNOWN_LANG: &str = "```unknownlang999\nhello world\n```\n";
448 const MD_WITH_IGNORE: &str = "<!-- jscpd:ignore-start -->\n```javascript\nconst x = 1;\n```\n<!-- jscpd:ignore-end -->\n```javascript\nconst y = 2;\n```\n";
449
450 #[test]
451 fn js_fence_produces_tokens() {
452 let tokens = tokenize_markdown(MD_WITH_JS, Mode::Mild);
453 assert!(!tokens.is_empty(), "JS code fence must produce tokens");
454 }
455
456 #[test]
457 fn no_fences_produces_empty() {
458 let tokens = tokenize_markdown(MD_NO_FENCES, Mode::Mild);
459 assert!(
460 tokens.is_empty(),
461 "Markdown with no fences must produce no tokens"
462 );
463 }
464
465 #[test]
466 fn unknown_lang_fence_does_not_panic() {
467 let result = std::panic::catch_unwind(|| tokenize_markdown(MD_UNKNOWN_LANG, Mode::Mild));
468 assert!(result.is_ok(), "unknown language fence must not panic");
469 }
470
471 #[test]
472 fn empty_markdown_returns_empty() {
473 let tokens = tokenize_markdown("", Mode::Mild);
474 assert!(tokens.is_empty());
475 }
476
477 #[test]
478 fn ignore_region_suppresses_fence_tokens() {
479 let tokens = tokenize_markdown(MD_WITH_IGNORE, Mode::Mild);
480 let non_ignore = tokens
481 .iter()
482 .filter(|t| t.kind != TokenKind::Ignore)
483 .count();
484 let ignore_count = tokens
485 .iter()
486 .filter(|t| t.kind == TokenKind::Ignore)
487 .count();
488 assert!(
489 ignore_count > 0,
490 "tokens in ignore region must be Ignore kind"
491 );
492 assert!(
493 non_ignore > 0,
494 "tokens outside ignore region must NOT be Ignore kind"
495 );
496 }
497
498 fn default_options() -> TokenizeOptions {
501 TokenizeOptions::new(Mode::Mild)
502 }
503
504 fn find_map<'a>(maps: &'a [TokenMap], format: &str) -> Option<&'a TokenMap> {
505 maps.iter().find(|m| m.format == format)
506 }
507
508 fn assert_has_format(maps: &[TokenMap], format: &str, msg: &str) {
509 assert!(find_map(maps, format).is_some(), "{}", msg);
510 }
511
512 #[test]
513 fn maps_empty_source_returns_empty() {
514 let maps = tokenize_markdown_maps("", &default_options());
515 assert!(maps.is_empty());
516 }
517
518 #[test]
519 fn maps_js_fence_produces_javascript_entry() {
520 let source = "# Title\n\n```javascript\nfunction hello() { return 42; }\n```\n";
521 let maps = tokenize_markdown_maps(source, &default_options());
522 let js_map = find_map(&maps, "javascript");
523 assert!(js_map.is_some(), "must have a javascript TokenMap");
524 assert!(
525 !js_map.unwrap().tokens.is_empty(),
526 "javascript tokens must be non-empty"
527 );
528 }
529
530 #[test]
531 fn maps_multi_format_returns_separate_token_maps() {
532 let source = "```javascript\nvar x = 1;\n```\n```python\ny = 2\n```\n";
533 let maps = tokenize_markdown_maps(source, &default_options());
534 assert_has_format(&maps, "javascript", "must have javascript TokenMap");
535 assert_has_format(&maps, "python", "must have python TokenMap");
536 }
537
538 #[test]
539 fn maps_no_fences_produces_markdown_prose_only() {
540 let source = "# Just prose\n\nNo code here.\n";
541 let maps = tokenize_markdown_maps(source, &default_options());
542 assert_has_format(&maps, "markdown", "must have markdown TokenMap for prose");
543 assert!(
544 maps.iter().all(|m| m.format == "markdown"),
545 "no other formats expected"
546 );
547 }
548
549 #[test]
550 fn maps_unknown_language_skipped() {
551 let source = "```xyzunknown999\nhello world\n```\n";
552 let maps = tokenize_markdown_maps(source, &default_options());
553 assert!(
554 maps.iter().all(|m| m.format != "xyzunknown999"),
555 "unknown language should not produce its own format map"
556 );
557 }
558
559 #[test]
560 fn maps_tilde_fences_supported() {
561 let source = "~~~javascript\nconst x = 1;\n~~~\n";
562 let maps = tokenize_markdown_maps(source, &default_options());
563 assert!(
564 find_map(&maps, "javascript").is_some(),
565 "tilde fences must produce javascript TokenMap"
566 );
567 }
568
569 #[test]
570 fn maps_yaml_front_matter() {
571 let source = "---\ntitle: Hello\nauthor: World\n---\n\nSome prose.\n";
572 let maps = tokenize_markdown_maps(source, &default_options());
573 assert!(
574 find_map(&maps, "yaml").is_some(),
575 "YAML front matter must produce yaml TokenMap"
576 );
577 }
578
579 #[test]
580 fn maps_front_matter_with_ellipsis_terminator() {
581 let source = "---\ntitle: Hello\n...\n\nMore text.\n";
582 let maps = tokenize_markdown_maps(source, &default_options());
583 assert!(
584 find_map(&maps, "yaml").is_some(),
585 "... must terminate front matter as yaml"
586 );
587 }
588
589 #[test]
590 fn maps_front_matter_without_closing_is_prose() {
591 let source = "---\nthis is not front matter\nit has no closing marker\n";
592 let maps = tokenize_markdown_maps(source, &default_options());
593 assert!(
594 find_map(&maps, "yaml").is_none(),
595 "unclosed --- must not be treated as yaml"
596 );
597 }
598
599 #[test]
600 fn maps_ignore_region_suppresses_fence_tokens() {
601 let source = "<!-- jscpd:ignore-start -->\n```javascript\nconst x = 1;\n```\n<!-- jscpd:ignore-end -->\n```javascript\nconst y = 2;\n```\n";
602 let maps = tokenize_markdown_maps(source, &default_options());
603 let js_map = find_map(&maps, "javascript");
604 assert!(js_map.is_some(), "javascript map must exist");
605 let non_ignored_count = js_map.unwrap().tokens.len();
606 assert!(
607 non_ignored_count > 0,
608 "second fence must yield non-ignored tokens"
609 );
610 }
611
612 #[test]
613 fn maps_backtick_tilde_do_not_close_each_other() {
614 let source = "```javascript\nconst a = 1;\n~~~\nconst b = 2;\n```\n";
615 let maps = tokenize_markdown_maps(source, &default_options());
616 assert!(
617 find_map(&maps, "javascript").is_some(),
618 "backtick fence should not be closed by tilde"
619 );
620 }
621
622 #[test]
623 fn maps_closing_fence_length_must_match() {
624 let source = "````javascript\nconst x = 1;\n````\n";
625 let maps = tokenize_markdown_maps(source, &default_options());
626 assert_has_format(&maps, "javascript", "4-backtick fence must work");
627 }
628
629 #[test]
630 fn maps_fence_with_info_string_space() {
631 let source = "```javascript extra info\nconst x = 1;\n```\n";
632 let maps = tokenize_markdown_maps(source, &default_options());
633 assert!(
634 find_map(&maps, "javascript").is_some(),
635 "first whitespace-delimited token is the language"
636 );
637 }
638
639 #[test]
640 fn maps_returns_markdown_prose_tokens() {
641 let source = "# Header\n\nSome prose.\n\n```javascript\nvar x;\n```\n";
642 let maps = tokenize_markdown_maps(source, &default_options());
643 let md_map = find_map(&maps, "markdown");
644 assert!(md_map.is_some(), "must have markdown TokenMap for prose");
645 assert!(
646 !md_map.unwrap().tokens.is_empty(),
647 "prose must produce tokens"
648 );
649 }
650
651 #[test]
652 fn maps_detection_tokens_have_valid_positions() {
653 let source = "```javascript\nconst x = 1;\n```\n";
654 let maps = tokenize_markdown_maps(source, &default_options());
655 let js_map = find_map(&maps, "javascript");
656 assert!(js_map.is_some());
657 for t in &js_map.unwrap().tokens {
658 assert!(t.start.line >= 1, "line must be 1-based");
659 assert!(t.start.offset as i32 >= 0, "offset must be non-negative");
660 }
661 }
662
663 #[test]
664 fn line_spans_basic() {
665 let content = "hello\nworld\n";
666 let spans = line_spans(content);
667 assert_eq!(spans.len(), 3);
668 assert_eq!(&content[spans[0].start..spans[0].end], "hello");
669 assert_eq!(&content[spans[1].start..spans[1].end], "world");
670 }
671
672 #[test]
673 fn line_spans_empty_content() {
674 let spans = line_spans("");
675 assert!(spans.is_empty());
676 }
677
678 #[test]
679 fn line_spans_no_trailing_newline() {
680 let content = "one\ntwo";
681 let spans = line_spans(content);
682 assert_eq!(spans.len(), 2);
683 assert_eq!(&content[spans[0].start..spans[0].end], "one");
684 assert_eq!(&content[spans[1].start..spans[1].end], "two");
685 }
686
687 #[test]
688 fn opening_fence_detection() {
689 assert!(parse_opening_fence("```javascript").is_some());
690 assert!(parse_opening_fence("~~~python").is_some());
691 assert!(parse_opening_fence("``").is_none());
692 assert!(parse_opening_fence("not a fence").is_none());
693 }
694
695 #[test]
696 fn closing_fence_detection() {
697 let open = FenceOpen {
698 marker: b'`',
699 len: 3,
700 info: String::new(),
701 };
702 assert!(is_closing_fence("```", &open));
703 assert!(is_closing_fence("````", &open));
704 assert!(!is_closing_fence("~~", &open));
705 assert!(!is_closing_fence("```javascript", &open));
706 }
707
708 #[test]
709 fn byte_offsets_are_correct_for_front_matter() {
710 let source = "---\ntitle: Hello\n---\n\nText.\n";
711 let fm = extract_front_matter(source).unwrap();
712 assert_eq!(fm.format, "yaml");
713 assert!(fm.front_matter);
714 assert_eq!(fm.block_start, 0);
715 assert_eq!(&source[fm.inner_start..fm.inner_end], "title: Hello");
716 }
717
718 #[test]
719 fn byte_offsets_are_correct_for_code_block() {
720 let source = "# Header\n\n```javascript\nconst x = 1;\n```\n";
721 let fences = extract_code_fences(source);
722 assert_eq!(fences.len(), 1);
723 let f = &fences[0];
724 assert_eq!(f.format, "javascript");
725 assert!(!f.front_matter);
726 let inner = &source[f.inner_start..f.inner_end];
727 assert!(inner.contains("const x = 1;"));
728 }
729
730 #[test]
731 fn resolve_format_js() {
732 assert_eq!(resolve_fence_format("javascript"), Some("javascript"));
733 }
734
735 #[test]
736 fn resolve_format_unknown() {
737 assert!(resolve_fence_format("xyzunknown999").is_none());
738 }
739
740 #[test]
741 fn maps_synonym_resolution() {
742 let source = "```node\nconst x = 1;\n```\n";
743 let maps = tokenize_markdown_maps(source, &default_options());
744 assert!(
745 find_map(&maps, "javascript").is_some(),
746 "node must resolve to javascript"
747 );
748 }
749
750 #[test]
751 fn maps_shell_resolves_to_bash() {
752 let source = "```shell\necho hello\n```\n";
753 let maps = tokenize_markdown_maps(source, &default_options());
754 assert!(
755 find_map(&maps, "bash").is_some(),
756 "shell must resolve to bash"
757 );
758 }
759}