Skip to main content

cpd_tokenizer/
markdown.rs

1// markdown.rs
2// Attribution: Markdown fenced code block extraction; inspired by jscpd-rs approach; rewritten independently.
3
4use 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]; // mark as ignored by zeroing range
296            }
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
378// Legacy line-based fence extraction used by the display-path tokenize_markdown()
379struct 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    // --- legacy display-path tests ---
448
449    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    // --- tokenize_markdown_maps tests ---
503
504    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}