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