Skip to main content

rumdl_lib/utils/
skip_context.rs

1//! Utilities for determining if a position in markdown should be skipped from processing
2//!
3//! This module provides centralized context detection for various markdown constructs
4//! that should typically be skipped when processing rules.
5
6use crate::config::MarkdownFlavor;
7use crate::lint_context::{HtmlTag, LintContext};
8use crate::utils::mkdocs_admonitions;
9use crate::utils::mkdocs_critic;
10use crate::utils::mkdocs_extensions;
11use crate::utils::mkdocs_footnotes;
12use crate::utils::mkdocs_icons;
13use crate::utils::mkdocs_snippets;
14use crate::utils::mkdocs_tabs;
15use crate::utils::regex_cache::HTML_COMMENT_PATTERN;
16use regex::Regex;
17use std::sync::LazyLock;
18
19/// Enhanced inline math pattern that handles both single $ and double $$ delimiters.
20/// Matches:
21/// - Display math: $$...$$ (zero or more non-$ characters)
22/// - Inline math: $...$ (zero or more non-$ non-newline characters)
23///
24/// The display math pattern is tried first to correctly handle $$content$$.
25/// Critically, both patterns allow ZERO characters between delimiters,
26/// so empty math like $$ or $ $ is consumed and won't pair with other $ signs.
27static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
28
29/// Range representing a span of bytes (start inclusive, end exclusive)
30#[derive(Debug, Clone, Copy)]
31pub struct ByteRange {
32    pub start: usize,
33    pub end: usize,
34}
35
36/// Pre-compute all HTML comment ranges in the content
37/// Returns a sorted vector of byte ranges for efficient lookup
38pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
39    HTML_COMMENT_PATTERN
40        .find_iter(content)
41        .map(|m| ByteRange {
42            start: m.start(),
43            end: m.end(),
44        })
45        .collect()
46}
47
48/// Check if a byte position is within any of the pre-computed HTML comment ranges
49/// Uses binary search for O(log n) complexity
50pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
51    // Binary search to find a range that might contain byte_pos
52    ranges
53        .binary_search_by(|range| {
54            if byte_pos < range.start {
55                std::cmp::Ordering::Greater
56            } else if byte_pos >= range.end {
57                std::cmp::Ordering::Less
58            } else {
59                std::cmp::Ordering::Equal
60            }
61        })
62        .is_ok()
63}
64
65/// Check if a line is ENTIRELY within a single HTML comment
66/// Returns true only if both the line start AND end are within the same comment range
67pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], line_start: usize, line_end: usize) -> bool {
68    for range in ranges {
69        // If line start is within this range, check if line end is also within it
70        if line_start >= range.start && line_start < range.end {
71            return line_end <= range.end;
72        }
73    }
74    false
75}
76
77/// Check if a byte position is within a JSX expression (MDX: {expression})
78#[inline]
79pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
80    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
81}
82
83/// Check if a byte position is within an MDX comment ({/* ... */})
84#[inline]
85pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
86    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
87}
88
89/// Check if a line should be skipped due to MkDocs snippet syntax
90pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
91    flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
92}
93
94/// Check if a line is a MkDocs admonition marker
95pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
96    flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
97}
98
99/// Check if a line is a MkDocs footnote definition
100pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
101    flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
102}
103
104/// Check if a line is a MkDocs tab marker
105pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
106    flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
107}
108
109/// Check if a line contains MkDocs Critic Markup
110pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
111    flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
112}
113
114/// Check if a byte position is within an HTML comment
115pub fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
116    for m in HTML_COMMENT_PATTERN.find_iter(content) {
117        if m.start() <= byte_pos && byte_pos < m.end() {
118            return true;
119        }
120    }
121    false
122}
123
124/// Check if a byte position is within an HTML tag
125pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
126    for html_tag in ctx.html_tags().iter() {
127        if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
128            return true;
129        }
130    }
131    false
132}
133
134/// Check if a byte position is within a math context.
135///
136/// `$$...$$` display math is recognized only when it begins its line, via
137/// [`math_block_ranges`]; a mid-line or stray-prose `$$...$$` is a literal,
138/// not math. Single-`$` inline spans are recognized anywhere. This keeps
139/// every math-aware rule agreeing on what is math.
140pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
141    // Use the cached ranges on the context; recomputing math_byte_ranges(content)
142    // on every call made callers that invoke this per element O(elements * content).
143    ctx.math_byte_ranges()
144        .iter()
145        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
146}
147
148/// Paired `$$ ... $$` display-math byte ranges, half-open `[start, end)`.
149///
150/// A block only *opens* on a `$$` that begins its line, ignoring leading
151/// whitespace and blockquote markers (`>`); a stray `$$` mid-prose is a
152/// literal, not a block opener. This keeps the byte-level result consistent
153/// with the line-level [`compute_math_block_line_map`] guard. Once open, the
154/// block *closes* on the next `$$` anywhere - even when that closing `$$`
155/// shares its line with LaTeX content (`\end{cases}$$`) or trailing Markdown
156/// prose. An opener with no matching closer is dropped, not treated as an
157/// unterminated block that swallows the rest of the document.
158pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
159    let bytes = content.as_bytes();
160    let mut ranges = Vec::new();
161    let mut open: Option<usize> = None;
162    let mut line_start = 0usize;
163    let mut i = 0;
164    while i < bytes.len() {
165        match bytes[i] {
166            b'\n' => {
167                line_start = i + 1;
168                i += 1;
169            }
170            b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
171                match open {
172                    None => {
173                        // Open only when this `$$` is the first non-blank,
174                        // non-blockquote content on its line.
175                        let starts_line = bytes[line_start..i]
176                            .iter()
177                            .all(|&b| b == b' ' || b == b'\t' || b == b'>');
178                        if starts_line {
179                            open = Some(i);
180                        }
181                    }
182                    Some(start) => {
183                        ranges.push((start, i + 2));
184                        open = None;
185                    }
186                }
187                i += 2;
188            }
189            _ => i += 1,
190        }
191    }
192    ranges
193}
194
195/// Check if a byte position is within a `$$ ... $$` display-math block.
196///
197/// A block opens only on a `$$` that begins its line (see [`math_block_ranges`])
198/// and closes on the next `$$` anywhere, so the closing fence ends the block
199/// even when it shares its line with LaTeX content (e.g. `\end{cases}$$`) or
200/// trailing Markdown prose; bytes after the closing `$$` are not math.
201pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
202    math_block_ranges(content)
203        .iter()
204        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
205}
206
207/// Check if a byte position is within inline math (`$...$`).
208///
209/// Only single-`$` spans count here. A `$$...$$` token is display-math
210/// syntax, and whether it is actually math depends solely on whether it
211/// begins its line - that decision belongs to [`math_block_ranges`]. The
212/// regex still consumes `$$...$$` tokens so a single-`$` span cannot straddle
213/// them, but a mid-line `$$...$$` is a literal here, not inline math, keeping
214/// this function consistent with the line-start-gated block model.
215pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
216    for m in INLINE_MATH_REGEX.find_iter(content) {
217        if content[m.start()..m.end()].starts_with("$$") {
218            continue;
219        }
220        if m.start() <= byte_pos && byte_pos < m.end() {
221            return true;
222        }
223    }
224    false
225}
226
227/// All math byte ranges in `content`: line-start `$$...$$` display blocks
228/// plus single-`$` inline spans. Ranges are half-open `[start, end)` and may
229/// be unordered relative to each other; membership is by `any`-containment.
230///
231/// Precompute this once when classifying many positions in one document
232/// (e.g. every emphasis span). [`is_in_math_context`] is the single-shot
233/// equivalent and is defined in terms of the same two sources.
234pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
235    let mut ranges = math_block_ranges(content);
236    for m in INLINE_MATH_REGEX.find_iter(content) {
237        if content[m.start()..m.end()].starts_with("$$") {
238            continue;
239        }
240        ranges.push((m.start(), m.end()));
241    }
242    ranges
243}
244
245/// Check if a position is within a table cell
246pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
247    // Check if this line is part of a table
248    for table_row in ctx.table_rows().iter() {
249        if table_row.line == line_num {
250            // This line is part of a table
251            // For now, we'll skip the entire table row
252            // Future enhancement: check specific column boundaries
253            return true;
254        }
255    }
256    false
257}
258
259/// Check if a line contains table syntax
260pub fn is_table_line(line: &str) -> bool {
261    let trimmed = line.trim();
262
263    // Check for table separator line
264    if trimmed
265        .chars()
266        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
267        && trimmed.contains('|')
268        && trimmed.contains('-')
269    {
270        return true;
271    }
272
273    // Check for table content line (starts and/or ends with |)
274    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
275        return true;
276    }
277
278    false
279}
280
281/// Check if a byte position is within an MkDocs icon shortcode
282/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
283pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
284    // Only skip for MkDocs flavor, but check pattern for all flavors
285    // since emoji shortcodes are universal
286    mkdocs_icons::is_in_any_shortcode(line, position)
287}
288
289/// Check if a byte position is within PyMdown extension markup
290/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
291///
292/// For MkDocs flavor: supports all PyMdown extensions
293/// For Obsidian flavor: only supports Mark (==highlight==) syntax
294pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
295    match flavor {
296        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
297        MarkdownFlavor::Obsidian => {
298            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
299            mkdocs_extensions::is_in_mark(line, position)
300        }
301        _ => false,
302    }
303}
304
305/// Check whether a position on a line falls inside an inline HTML code-like element.
306///
307/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
308/// These are inline elements whose content should not be interpreted as markdown emphasis.
309pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
310    // Tags whose content should not be parsed as markdown
311    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
312
313    let bytes = line.as_bytes();
314
315    for tag in TAGS {
316        let open_bytes = format!("<{tag}").into_bytes();
317        let close_pattern = format!("</{tag}>").into_bytes();
318
319        let mut search_from = 0;
320        while search_from + open_bytes.len() <= bytes.len() {
321            // Find opening tag (case-insensitive byte search)
322            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
323                break;
324            };
325
326            let after_tag = open_abs + open_bytes.len();
327
328            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
329            if after_tag < bytes.len() {
330                let next = bytes[after_tag];
331                if next != b'>' && next != b' ' && next != b'\t' {
332                    search_from = after_tag;
333                    continue;
334                }
335            }
336
337            // Find the end of the opening tag
338            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
339                break;
340            };
341            let content_start = after_tag + tag_close + 1;
342
343            // Find the closing tag (case-insensitive)
344            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
345                break;
346            };
347            let content_end = close_start;
348
349            if position >= content_start && position < content_end {
350                return true;
351            }
352
353            search_from = close_start + close_pattern.len();
354        }
355    }
356    false
357}
358
359/// Case-insensitive byte search within a slice, starting at `from`.
360fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
361    if needle.is_empty() || from + needle.len() > haystack.len() {
362        return None;
363    }
364    for i in from..=haystack.len() - needle.len() {
365        if haystack[i..i + needle.len()]
366            .iter()
367            .zip(needle.iter())
368            .all(|(h, n)| h.eq_ignore_ascii_case(n))
369        {
370            return Some(i);
371        }
372    }
373    None
374}
375
376/// Check if a byte position is within flavor-specific markup
377/// For MkDocs: icon shortcodes and PyMdown extensions
378/// For Obsidian: highlight syntax (==text==)
379pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
380    if is_in_icon_shortcode(line, position, flavor) {
381        return true;
382    }
383    if is_in_pymdown_markup(line, position, flavor) {
384        return true;
385    }
386    false
387}
388
389/// Check if a byte position within a line is inside a backtick-delimited code span.
390///
391/// This is a line-level fallback for cases where pulldown-cmark's code span detection
392/// misses spans due to table parsing interference (e.g., pipes inside code spans
393/// in table rows cause pulldown-cmark to misidentify cell boundaries).
394fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
395    let bytes = line.as_bytes();
396    let mut i = 0;
397
398    while i < bytes.len() {
399        if bytes[i] == b'`' {
400            let open_start = i;
401            let mut backtick_count = 0;
402            while i < bytes.len() && bytes[i] == b'`' {
403                backtick_count += 1;
404                i += 1;
405            }
406
407            // Search for matching closing backticks
408            let mut j = i;
409            while j < bytes.len() {
410                if bytes[j] == b'`' {
411                    let mut close_count = 0;
412                    while j < bytes.len() && bytes[j] == b'`' {
413                        close_count += 1;
414                        j += 1;
415                    }
416                    if close_count == backtick_count {
417                        // Found matching pair: code span covers open_start..j
418                        if byte_pos >= open_start && byte_pos < j {
419                            return true;
420                        }
421                        i = j;
422                        break;
423                    }
424                } else {
425                    j += 1;
426                }
427            }
428
429            if j >= bytes.len() {
430                // No matching close found, remaining text is not a code span
431                break;
432            }
433        } else {
434            i += 1;
435        }
436    }
437
438    false
439}
440
441/// Check if a byte position is within an HTML tag. O(log n) via binary search.
442fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
443    let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
444    idx > 0 && byte_pos < html_tags[idx - 1].byte_end
445}
446
447/// Check if a byte position is within HTML code content (`<code>...</code>`).
448/// Uses pre-computed code ranges for O(log n) lookup via binary search.
449fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
450    let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
451    idx > 0 && byte_pos < code_ranges[idx - 1].1
452}
453
454/// Pre-compute ranges covered by `<code>...</code>` HTML tags.
455/// Returns sorted Vec of (start, end) byte ranges.
456pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
457    let mut ranges = Vec::new();
458    let mut open_code_end: Option<usize> = None;
459
460    for tag in html_tags {
461        if tag.tag_name == "code" {
462            if tag.is_self_closing {
463                continue;
464            } else if !tag.is_closing {
465                open_code_end = Some(tag.byte_end);
466            } else if tag.is_closing {
467                if let Some(start) = open_code_end {
468                    ranges.push((start, tag.byte_offset));
469                }
470                open_code_end = None;
471            }
472        }
473    }
474    // Handle unclosed <code> tag
475    if let Some(start) = open_code_end {
476        ranges.push((start, usize::MAX));
477    }
478    ranges
479}
480
481/// Determine whether an emphasis or strong span starting at `span_start` should be
482/// skipped because it falls inside a non-prose context: code blocks/spans, inline
483/// code, links, HTML tags or `<code>` content, MkDocs/PyMdown markup, math, JSX
484/// expressions, MDX comments, front matter, or mkdocstrings blocks.
485///
486/// `html_tags` and `html_code_ranges` are passed in so callers iterating many spans
487/// can compute them once via [`compute_html_code_ranges`].
488pub(crate) fn should_skip_emphasis_span(
489    ctx: &LintContext,
490    html_tags: &[HtmlTag],
491    html_code_ranges: &[(usize, usize)],
492    span_start: usize,
493) -> bool {
494    let lines = ctx.raw_lines();
495    let (line_num, col) = ctx.offset_to_line_col(span_start);
496
497    // Skip matches in front matter or mkdocstrings blocks
498    if ctx
499        .line_info(line_num)
500        .is_some_and(|info| info.in_front_matter || info.in_mkdocstrings)
501    {
502        return true;
503    }
504
505    // Check MkDocs markup
506    let in_mkdocs_markup = lines
507        .get(line_num.saturating_sub(1))
508        .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
509
510    // Line-level inline code fallback for cases pulldown-cmark misses
511    let in_inline_code = lines
512        .get(line_num.saturating_sub(1))
513        .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
514
515    ctx.is_in_code_block_or_span(span_start)
516        || in_inline_code
517        || ctx.is_in_link(span_start)
518        || is_byte_in_html_tag(html_tags, span_start)
519        || is_byte_in_html_code_content(html_code_ranges, span_start)
520        || in_mkdocs_markup
521        || is_in_math_context(ctx, span_start)
522        || is_in_jsx_expression(ctx, span_start)
523        || is_in_mdx_comment(ctx, span_start)
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_html_comment_detection() {
532        let content = "Text <!-- comment --> more text";
533        assert!(is_in_html_comment(content, 10)); // Inside comment
534        assert!(!is_in_html_comment(content, 0)); // Before comment
535        assert!(!is_in_html_comment(content, 25)); // After comment
536    }
537
538    #[test]
539    fn test_is_line_entirely_in_html_comment() {
540        // Test 1: Multi-line comment with content after closing
541        let content = "<!--\ncomment\n--> Content after comment";
542        let ranges = compute_html_comment_ranges(content);
543        // Line 0: "<!--" (bytes 0-4) - entirely in comment
544        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
545        // Line 1: "comment" (bytes 5-12) - entirely in comment
546        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
547        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
548        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
549
550        // Test 2: Single-line comment with content after
551        let content2 = "<!-- comment --> Not a comment";
552        let ranges2 = compute_html_comment_ranges(content2);
553        // The entire line is NOT entirely in the comment
554        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
555
556        // Test 3: Single-line comment alone
557        let content3 = "<!-- comment -->";
558        let ranges3 = compute_html_comment_ranges(content3);
559        // The entire line IS entirely in the comment
560        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
561
562        // Test 4: Content before comment
563        let content4 = "Text before <!-- comment -->";
564        let ranges4 = compute_html_comment_ranges(content4);
565        // Line start is NOT in the comment range
566        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
567    }
568
569    #[test]
570    fn test_math_block_detection() {
571        let content = "Text\n$$\nmath content\n$$\nmore text";
572        assert!(is_in_math_block(content, 8)); // On opening $$
573        assert!(is_in_math_block(content, 15)); // Inside math block
574        assert!(!is_in_math_block(content, 0)); // Before math block
575        assert!(!is_in_math_block(content, 30)); // After math block
576    }
577
578    #[test]
579    fn test_stray_double_dollar_in_prose_is_not_math() {
580        // Two `$$` tokens inside a prose line must NOT pair into a math block:
581        // a multi-line block only opens on a `$$` that begins its line. This
582        // keeps the byte-level result consistent with the line-level map.
583        let content = "Note: $$ is used for display math and $$ closes it";
584        let between = content.find("is used").unwrap();
585        assert!(
586            !is_in_math_block(content, between),
587            "stray paired `$$` in prose must not be treated as a math block"
588        );
589        assert!(math_block_ranges(content).is_empty());
590    }
591
592    #[test]
593    fn test_blockquoted_double_dollar_opens_block() {
594        // A `$$` opener is still recognized behind a blockquote prefix.
595        let content = "> $$\n> x = y\n> $$\n";
596        let inside = content.find("x = y").unwrap();
597        assert!(is_in_math_block(content, inside), "blockquoted math interior");
598    }
599
600    #[test]
601    fn test_self_contained_single_line_block_leaves_trailing_prose() {
602        // `$$ a $$` at line start is math; prose after the closing `$$` is not.
603        let content = "$$ a $$ and __not math__\n";
604        let in_math = content.find('a').unwrap();
605        assert!(is_in_math_block(content, in_math), "single-line math interior");
606        let after = content.find("not math").unwrap();
607        assert!(!is_in_math_block(content, after), "trailing prose is lintable");
608    }
609
610    #[test]
611    fn test_math_block_closes_with_content_before_fence() {
612        // A display-math block whose closing `$$` shares its line with
613        // content (e.g. `\end{aligned}$$`) must still close the block.
614        // Content after the block is prose, not math.
615        let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
616
617        let inside = content.find("x = y").unwrap();
618        assert!(is_in_math_block(content, inside), "interior must be math");
619
620        let after = content.find("after").unwrap();
621        assert!(
622            !is_in_math_block(content, after),
623            "content after a content-sharing closing fence must NOT be math"
624        );
625    }
626
627    #[test]
628    fn test_inline_math_detection() {
629        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
630        assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
631        // The mid-line `$$a^2 + b^2$$` is display syntax, not a line-start
632        // block, so it is a literal under the shared math model - neither the
633        // inline path nor `math_block_ranges` treats it as math.
634        assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
635        assert!(
636            !is_in_math_block(content, 20),
637            "mid-line $$...$$ is not a line-start display block"
638        );
639        assert!(!is_in_inline_math(content, 0), "before any math");
640        assert!(!is_in_inline_math(content, 35), "after the spans");
641    }
642
643    #[test]
644    fn test_table_line_detection() {
645        assert!(is_table_line("| Header | Column |"));
646        assert!(is_table_line("|--------|--------|"));
647        assert!(is_table_line("| Cell 1 | Cell 2 |"));
648        assert!(!is_table_line("Regular text"));
649        assert!(!is_table_line("Just a pipe | here"));
650    }
651
652    #[test]
653    fn test_is_in_icon_shortcode() {
654        let line = "Click :material-check: to confirm";
655        // Position 0-5 is "Click"
656        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
657        // Position 6-22 is ":material-check:"
658        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
659        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
660        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
661        // Position 22+ is " to confirm"
662        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
663    }
664
665    #[test]
666    fn test_is_in_pymdown_markup() {
667        // Test Keys notation
668        let line = "Press ++ctrl+c++ to copy";
669        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
670        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
671        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
672        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
673
674        // Test Mark notation
675        let line2 = "This is ==highlighted== text";
676        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
677        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
678        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
679        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
680
681        // Should not match for Standard flavor
682        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
683    }
684
685    #[test]
686    fn test_is_in_mkdocs_markup() {
687        // Should combine both icon and pymdown
688        let line = ":material-check: and ++ctrl++";
689        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
690        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
691        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
692    }
693
694    // ==================== Obsidian highlight tests ====================
695
696    #[test]
697    fn test_obsidian_highlight_basic() {
698        // Obsidian flavor should recognize ==highlight== syntax
699        let line = "This is ==highlighted== text";
700        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
701        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
702        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
703        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
704        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
705        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
706    }
707
708    #[test]
709    fn test_obsidian_highlight_multiple() {
710        // Multiple highlights on one line
711        let line = "Both ==one== and ==two== here";
712        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
713        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
714        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
715        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
716    }
717
718    #[test]
719    fn test_obsidian_highlight_not_standard_flavor() {
720        // Standard flavor should NOT recognize ==highlight== as special
721        let line = "This is ==highlighted== text";
722        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
723        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
724    }
725
726    #[test]
727    fn test_obsidian_highlight_with_spaces_inside() {
728        // Highlights can have spaces inside the content
729        let line = "This is ==text with spaces== here";
730        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
731        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
732        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
733    }
734
735    #[test]
736    fn test_obsidian_does_not_support_keys_notation() {
737        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
738        let line = "Press ++ctrl+c++ to copy";
739        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
740        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
741    }
742
743    #[test]
744    fn test_obsidian_mkdocs_markup_function() {
745        // is_in_mkdocs_markup should also work for Obsidian highlights
746        let line = "This is ==highlighted== text";
747        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
748        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
749    }
750
751    #[test]
752    fn test_obsidian_highlight_edge_cases() {
753        // Empty highlight (====) should not match
754        let line = "Test ==== here";
755        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
756        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
757
758        // Single character highlight
759        let line2 = "Test ==a== here";
760        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
761        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
762        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
763
764        // Triple equals (===) should not create highlight
765        let line3 = "a === b";
766        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
767    }
768
769    #[test]
770    fn test_obsidian_highlight_unclosed() {
771        // Unclosed highlight should not match
772        let line = "This ==starts but never ends";
773        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
774        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
775    }
776
777    #[test]
778    fn test_inline_html_code_basic() {
779        let line = "The formula is <code>a * b * c</code> in math.";
780        // Position inside <code> content
781        assert!(is_in_inline_html_code(line, 21)); // 'a'
782        assert!(is_in_inline_html_code(line, 25)); // '*'
783        // Position outside <code> content
784        assert!(!is_in_inline_html_code(line, 0)); // 'T'
785        assert!(!is_in_inline_html_code(line, 40)); // after </code>
786    }
787
788    #[test]
789    fn test_inline_html_code_multiple_tags() {
790        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
791        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
792        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
793        assert!(!is_in_inline_html_code(line, 16)); // '+'
794    }
795
796    #[test]
797    fn test_inline_html_code_with_attributes() {
798        let line = r#"<code class="lang">x * y</code>"#;
799        assert!(is_in_inline_html_code(line, 19)); // 'x'
800        assert!(is_in_inline_html_code(line, 23)); // '*'
801        assert!(!is_in_inline_html_code(line, 0)); // before tag
802    }
803
804    #[test]
805    fn test_inline_html_code_case_insensitive() {
806        let line = "<CODE>a * b</CODE>";
807        assert!(is_in_inline_html_code(line, 6)); // 'a'
808        assert!(is_in_inline_html_code(line, 8)); // '*'
809    }
810
811    #[test]
812    fn test_inline_html_code_var_and_pre() {
813        let line = "<var>x * y</var> and <pre>a * b</pre>";
814        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
815        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
816        assert!(!is_in_inline_html_code(line, 17)); // 'and'
817    }
818
819    #[test]
820    fn test_inline_html_code_unclosed() {
821        // Unclosed tag should not match
822        let line = "<code>a * b without closing";
823        assert!(!is_in_inline_html_code(line, 6));
824    }
825
826    #[test]
827    fn test_inline_html_code_no_substring_match() {
828        // <variable> should NOT be treated as <var>
829        let line = "<variable>a * b</variable>";
830        assert!(!is_in_inline_html_code(line, 11));
831
832        // <keyboard> should NOT be treated as <kbd>
833        let line2 = "<keyboard>x * y</keyboard>";
834        assert!(!is_in_inline_html_code(line2, 11));
835    }
836}