Skip to main content

cpd_tokenizer/
markdown.rs

1// markdown.rs
2
3use 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]; // mark as ignored by zeroing range
295            }
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
377// Legacy line-based fence extraction used by the display-path tokenize_markdown()
378struct 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    // --- legacy display-path tests ---
447
448    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    // --- tokenize_markdown_maps tests ---
502
503    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}