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