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    // ponytail: split on first byte so the marker check and run-length are separate phases
73    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    // ponytail: front-matter opener is `---` on its own line; reject anything else
168    // up front so the line scan only runs on real candidates.
169    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    // Body begins right after the opener line.
179    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
192/// Index of the first line after the opener that holds a front-matter closer
193/// (`---` or `...`). Returns `None` if no closer is found.
194fn front_matter_closer(content: &str, lines: &[LineSpan]) -> Option<usize> {
195    // ponytail: skip the opener line (idx 0) and walk the rest with a numeric
196    // counter so we avoid the `.enumerate().skip(1)` chain shape used in jscpd-rs.
197    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]; // mark as ignored by zeroing range
318            }
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    // Prose body: blank out fenced code blocks and front matter, then
370    // tokenize the remainder as markdown — mirrors tokenize_markdown_maps so
371    // prose-only files (no code fences) still produce display tokens and are
372    // counted as analyzed.
373    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    // Body and fence tokens are collected separately — restore source order.
414    all_tokens.sort_by_key(|t| (t.start.line, t.start.column));
415    all_tokens
416}
417
418// Legacy line-based fence extraction used by the display-path tokenize_markdown()
419struct 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    // --- legacy display-path tests ---
488
489    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    // --- tokenize_markdown_maps tests ---
543
544    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}