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