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