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::LintContext;
8use crate::utils::kramdown_utils::is_math_block_delimiter;
9use crate::utils::mkdocs_admonitions;
10use crate::utils::mkdocs_critic;
11use crate::utils::mkdocs_extensions;
12use crate::utils::mkdocs_footnotes;
13use crate::utils::mkdocs_icons;
14use crate::utils::mkdocs_snippets;
15use crate::utils::mkdocs_tabs;
16use crate::utils::regex_cache::HTML_COMMENT_PATTERN;
17use regex::Regex;
18use std::sync::LazyLock;
19
20/// Enhanced inline math pattern that handles both single $ and double $$ delimiters.
21/// Matches:
22/// - Display math: $$...$$ (zero or more non-$ characters)
23/// - Inline math: $...$ (zero or more non-$ non-newline characters)
24///
25/// The display math pattern is tried first to correctly handle $$content$$.
26/// Critically, both patterns allow ZERO characters between delimiters,
27/// so empty math like $$ or $ $ is consumed and won't pair with other $ signs.
28static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
29
30/// Range representing a span of bytes (start inclusive, end exclusive)
31#[derive(Debug, Clone, Copy)]
32pub struct ByteRange {
33    pub start: usize,
34    pub end: usize,
35}
36
37/// Pre-compute all HTML comment ranges in the content
38/// Returns a sorted vector of byte ranges for efficient lookup
39pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
40    HTML_COMMENT_PATTERN
41        .find_iter(content)
42        .map(|m| ByteRange {
43            start: m.start(),
44            end: m.end(),
45        })
46        .collect()
47}
48
49/// Check if a byte position is within any of the pre-computed HTML comment ranges
50/// Uses binary search for O(log n) complexity
51pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
52    // Binary search to find a range that might contain byte_pos
53    ranges
54        .binary_search_by(|range| {
55            if byte_pos < range.start {
56                std::cmp::Ordering::Greater
57            } else if byte_pos >= range.end {
58                std::cmp::Ordering::Less
59            } else {
60                std::cmp::Ordering::Equal
61            }
62        })
63        .is_ok()
64}
65
66/// Check if a line is ENTIRELY within a single HTML comment
67/// Returns true only if both the line start AND end are within the same comment range
68pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], line_start: usize, line_end: usize) -> bool {
69    for range in ranges {
70        // If line start is within this range, check if line end is also within it
71        if line_start >= range.start && line_start < range.end {
72            return line_end <= range.end;
73        }
74    }
75    false
76}
77
78/// Check if a line is within front matter (both YAML and TOML)
79pub fn is_in_front_matter(content: &str, line_num: usize) -> bool {
80    let lines: Vec<&str> = content.lines().collect();
81
82    // Check YAML front matter (---) at the beginning
83    if !lines.is_empty() && lines[0] == "---" {
84        for (i, line) in lines.iter().enumerate().skip(1) {
85            if *line == "---" {
86                return line_num <= i;
87            }
88        }
89    }
90
91    // Check TOML front matter (+++) at the beginning
92    if !lines.is_empty() && lines[0] == "+++" {
93        for (i, line) in lines.iter().enumerate().skip(1) {
94            if *line == "+++" {
95                return line_num <= i;
96            }
97        }
98    }
99
100    false
101}
102
103/// Check if a byte position is within a JSX expression (MDX: {expression})
104#[inline]
105pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
106    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
107}
108
109/// Check if a byte position is within an MDX comment ({/* ... */})
110#[inline]
111pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
112    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
113}
114
115/// Check if a line should be skipped due to MkDocs snippet syntax
116pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
117    flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
118}
119
120/// Check if a line is a MkDocs admonition marker
121pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
122    flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
123}
124
125/// Check if a line is a MkDocs footnote definition
126pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
127    flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
128}
129
130/// Check if a line is a MkDocs tab marker
131pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
132    flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
133}
134
135/// Check if a line contains MkDocs Critic Markup
136pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
137    flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
138}
139
140/// Check if a byte position is within an HTML comment
141pub fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
142    for m in HTML_COMMENT_PATTERN.find_iter(content) {
143        if m.start() <= byte_pos && byte_pos < m.end() {
144            return true;
145        }
146    }
147    false
148}
149
150/// Check if a byte position is within an HTML tag
151pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
152    for html_tag in ctx.html_tags().iter() {
153        if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
154            return true;
155        }
156    }
157    false
158}
159
160/// Check if a byte position is within a math context (block or inline)
161pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
162    let content = ctx.content;
163
164    // Check if we're in a math block
165    if is_in_math_block(content, byte_pos) {
166        return true;
167    }
168
169    // Check if we're in inline math
170    if is_in_inline_math(content, byte_pos) {
171        return true;
172    }
173
174    false
175}
176
177/// Check if a byte position is within a math block ($$...$$)
178pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
179    let mut in_math_block = false;
180    let mut current_pos = 0;
181
182    for line in content.lines() {
183        let line_start = current_pos;
184        let line_end = current_pos + line.len();
185
186        // Check if this line is a math block delimiter
187        if is_math_block_delimiter(line) {
188            if byte_pos >= line_start && byte_pos <= line_end {
189                // Position is on the delimiter line itself
190                return true;
191            }
192            in_math_block = !in_math_block;
193        } else if in_math_block && byte_pos >= line_start && byte_pos <= line_end {
194            // Position is inside a math block
195            return true;
196        }
197
198        current_pos = line_end + 1; // +1 for newline
199    }
200
201    false
202}
203
204/// Check if a byte position is within inline math ($...$)
205pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
206    // Find all inline math spans
207    for m in INLINE_MATH_REGEX.find_iter(content) {
208        if m.start() <= byte_pos && byte_pos < m.end() {
209            return true;
210        }
211    }
212    false
213}
214
215/// Check if a position is within a table cell
216pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
217    // Check if this line is part of a table
218    for table_row in ctx.table_rows().iter() {
219        if table_row.line == line_num {
220            // This line is part of a table
221            // For now, we'll skip the entire table row
222            // Future enhancement: check specific column boundaries
223            return true;
224        }
225    }
226    false
227}
228
229/// Check if a line contains table syntax
230pub fn is_table_line(line: &str) -> bool {
231    let trimmed = line.trim();
232
233    // Check for table separator line
234    if trimmed
235        .chars()
236        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
237        && trimmed.contains('|')
238        && trimmed.contains('-')
239    {
240        return true;
241    }
242
243    // Check for table content line (starts and/or ends with |)
244    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
245        return true;
246    }
247
248    false
249}
250
251/// Check if a byte position is within an MkDocs icon shortcode
252/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
253pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
254    // Only skip for MkDocs flavor, but check pattern for all flavors
255    // since emoji shortcodes are universal
256    mkdocs_icons::is_in_any_shortcode(line, position)
257}
258
259/// Check if a byte position is within PyMdown extension markup
260/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
261///
262/// For MkDocs flavor: supports all PyMdown extensions
263/// For Obsidian flavor: only supports Mark (==highlight==) syntax
264pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
265    match flavor {
266        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
267        MarkdownFlavor::Obsidian => {
268            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
269            mkdocs_extensions::is_in_mark(line, position)
270        }
271        _ => false,
272    }
273}
274
275/// Check whether a position on a line falls inside an inline HTML code-like element.
276///
277/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
278/// These are inline elements whose content should not be interpreted as markdown emphasis.
279pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
280    // Tags whose content should not be parsed as markdown
281    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
282
283    let bytes = line.as_bytes();
284
285    for tag in TAGS {
286        let open_bytes = format!("<{tag}").into_bytes();
287        let close_pattern = format!("</{tag}>").into_bytes();
288
289        let mut search_from = 0;
290        while search_from + open_bytes.len() <= bytes.len() {
291            // Find opening tag (case-insensitive byte search)
292            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
293                break;
294            };
295
296            let after_tag = open_abs + open_bytes.len();
297
298            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
299            if after_tag < bytes.len() {
300                let next = bytes[after_tag];
301                if next != b'>' && next != b' ' && next != b'\t' {
302                    search_from = after_tag;
303                    continue;
304                }
305            }
306
307            // Find the end of the opening tag
308            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
309                break;
310            };
311            let content_start = after_tag + tag_close + 1;
312
313            // Find the closing tag (case-insensitive)
314            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
315                break;
316            };
317            let content_end = close_start;
318
319            if position >= content_start && position < content_end {
320                return true;
321            }
322
323            search_from = close_start + close_pattern.len();
324        }
325    }
326    false
327}
328
329/// Case-insensitive byte search within a slice, starting at `from`.
330fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
331    if needle.is_empty() || from + needle.len() > haystack.len() {
332        return None;
333    }
334    for i in from..=haystack.len() - needle.len() {
335        if haystack[i..i + needle.len()]
336            .iter()
337            .zip(needle.iter())
338            .all(|(h, n)| h.eq_ignore_ascii_case(n))
339        {
340            return Some(i);
341        }
342    }
343    None
344}
345
346/// Check if a byte position is within flavor-specific markup
347/// For MkDocs: icon shortcodes and PyMdown extensions
348/// For Obsidian: highlight syntax (==text==)
349pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
350    if is_in_icon_shortcode(line, position, flavor) {
351        return true;
352    }
353    if is_in_pymdown_markup(line, position, flavor) {
354        return true;
355    }
356    false
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_html_comment_detection() {
365        let content = "Text <!-- comment --> more text";
366        assert!(is_in_html_comment(content, 10)); // Inside comment
367        assert!(!is_in_html_comment(content, 0)); // Before comment
368        assert!(!is_in_html_comment(content, 25)); // After comment
369    }
370
371    #[test]
372    fn test_is_line_entirely_in_html_comment() {
373        // Test 1: Multi-line comment with content after closing
374        let content = "<!--\ncomment\n--> Content after comment";
375        let ranges = compute_html_comment_ranges(content);
376        // Line 0: "<!--" (bytes 0-4) - entirely in comment
377        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
378        // Line 1: "comment" (bytes 5-12) - entirely in comment
379        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
380        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
381        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
382
383        // Test 2: Single-line comment with content after
384        let content2 = "<!-- comment --> Not a comment";
385        let ranges2 = compute_html_comment_ranges(content2);
386        // The entire line is NOT entirely in the comment
387        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
388
389        // Test 3: Single-line comment alone
390        let content3 = "<!-- comment -->";
391        let ranges3 = compute_html_comment_ranges(content3);
392        // The entire line IS entirely in the comment
393        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
394
395        // Test 4: Content before comment
396        let content4 = "Text before <!-- comment -->";
397        let ranges4 = compute_html_comment_ranges(content4);
398        // Line start is NOT in the comment range
399        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
400    }
401
402    #[test]
403    fn test_math_block_detection() {
404        let content = "Text\n$$\nmath content\n$$\nmore text";
405        assert!(is_in_math_block(content, 8)); // On opening $$
406        assert!(is_in_math_block(content, 15)); // Inside math block
407        assert!(!is_in_math_block(content, 0)); // Before math block
408        assert!(!is_in_math_block(content, 30)); // After math block
409    }
410
411    #[test]
412    fn test_inline_math_detection() {
413        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
414        assert!(is_in_inline_math(content, 7)); // Inside first math
415        assert!(is_in_inline_math(content, 20)); // Inside second math
416        assert!(!is_in_inline_math(content, 0)); // Before math
417        assert!(!is_in_inline_math(content, 35)); // After math
418    }
419
420    #[test]
421    fn test_table_line_detection() {
422        assert!(is_table_line("| Header | Column |"));
423        assert!(is_table_line("|--------|--------|"));
424        assert!(is_table_line("| Cell 1 | Cell 2 |"));
425        assert!(!is_table_line("Regular text"));
426        assert!(!is_table_line("Just a pipe | here"));
427    }
428
429    #[test]
430    fn test_is_in_front_matter() {
431        // Test YAML frontmatter
432        let yaml_content = r#"---
433title: "My Post"
434tags: ["test", "example"]
435---
436
437# Content"#;
438
439        assert!(
440            is_in_front_matter(yaml_content, 0),
441            "Line 1 should be in YAML front matter"
442        );
443        assert!(
444            is_in_front_matter(yaml_content, 2),
445            "Line 3 should be in YAML front matter"
446        );
447        assert!(
448            is_in_front_matter(yaml_content, 3),
449            "Line 4 should be in YAML front matter"
450        );
451        assert!(
452            !is_in_front_matter(yaml_content, 4),
453            "Line 5 should NOT be in front matter"
454        );
455
456        // Test TOML frontmatter
457        let toml_content = r#"+++
458title = "My Post"
459tags = ["test", "example"]
460+++
461
462# Content"#;
463
464        assert!(
465            is_in_front_matter(toml_content, 0),
466            "Line 1 should be in TOML front matter"
467        );
468        assert!(
469            is_in_front_matter(toml_content, 2),
470            "Line 3 should be in TOML front matter"
471        );
472        assert!(
473            is_in_front_matter(toml_content, 3),
474            "Line 4 should be in TOML front matter"
475        );
476        assert!(
477            !is_in_front_matter(toml_content, 4),
478            "Line 5 should NOT be in front matter"
479        );
480
481        // Test TOML blocks NOT at beginning (should not be considered front matter)
482        let mixed_content = r#"# Content
483
484+++
485title = "Not frontmatter"
486+++
487
488More content"#;
489
490        assert!(
491            !is_in_front_matter(mixed_content, 2),
492            "TOML block not at beginning should NOT be front matter"
493        );
494        assert!(
495            !is_in_front_matter(mixed_content, 3),
496            "TOML block not at beginning should NOT be front matter"
497        );
498        assert!(
499            !is_in_front_matter(mixed_content, 4),
500            "TOML block not at beginning should NOT be front matter"
501        );
502    }
503
504    #[test]
505    fn test_is_in_icon_shortcode() {
506        let line = "Click :material-check: to confirm";
507        // Position 0-5 is "Click"
508        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
509        // Position 6-22 is ":material-check:"
510        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
511        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
512        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
513        // Position 22+ is " to confirm"
514        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
515    }
516
517    #[test]
518    fn test_is_in_pymdown_markup() {
519        // Test Keys notation
520        let line = "Press ++ctrl+c++ to copy";
521        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
522        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
523        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
524        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
525
526        // Test Mark notation
527        let line2 = "This is ==highlighted== text";
528        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
529        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
530        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
531        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
532
533        // Should not match for Standard flavor
534        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
535    }
536
537    #[test]
538    fn test_is_in_mkdocs_markup() {
539        // Should combine both icon and pymdown
540        let line = ":material-check: and ++ctrl++";
541        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
542        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
543        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
544    }
545
546    // ==================== Obsidian highlight tests ====================
547
548    #[test]
549    fn test_obsidian_highlight_basic() {
550        // Obsidian flavor should recognize ==highlight== syntax
551        let line = "This is ==highlighted== text";
552        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
553        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
554        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
555        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
556        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
557        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
558    }
559
560    #[test]
561    fn test_obsidian_highlight_multiple() {
562        // Multiple highlights on one line
563        let line = "Both ==one== and ==two== here";
564        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
565        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
566        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
567        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
568    }
569
570    #[test]
571    fn test_obsidian_highlight_not_standard_flavor() {
572        // Standard flavor should NOT recognize ==highlight== as special
573        let line = "This is ==highlighted== text";
574        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
575        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
576    }
577
578    #[test]
579    fn test_obsidian_highlight_with_spaces_inside() {
580        // Highlights can have spaces inside the content
581        let line = "This is ==text with spaces== here";
582        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
583        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
584        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
585    }
586
587    #[test]
588    fn test_obsidian_does_not_support_keys_notation() {
589        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
590        let line = "Press ++ctrl+c++ to copy";
591        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
592        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
593    }
594
595    #[test]
596    fn test_obsidian_mkdocs_markup_function() {
597        // is_in_mkdocs_markup should also work for Obsidian highlights
598        let line = "This is ==highlighted== text";
599        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
600        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
601    }
602
603    #[test]
604    fn test_obsidian_highlight_edge_cases() {
605        // Empty highlight (====) should not match
606        let line = "Test ==== here";
607        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
608        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
609
610        // Single character highlight
611        let line2 = "Test ==a== here";
612        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
613        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
614        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
615
616        // Triple equals (===) should not create highlight
617        let line3 = "a === b";
618        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
619    }
620
621    #[test]
622    fn test_obsidian_highlight_unclosed() {
623        // Unclosed highlight should not match
624        let line = "This ==starts but never ends";
625        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
626        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
627    }
628
629    #[test]
630    fn test_inline_html_code_basic() {
631        let line = "The formula is <code>a * b * c</code> in math.";
632        // Position inside <code> content
633        assert!(is_in_inline_html_code(line, 21)); // 'a'
634        assert!(is_in_inline_html_code(line, 25)); // '*'
635        // Position outside <code> content
636        assert!(!is_in_inline_html_code(line, 0)); // 'T'
637        assert!(!is_in_inline_html_code(line, 40)); // after </code>
638    }
639
640    #[test]
641    fn test_inline_html_code_multiple_tags() {
642        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
643        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
644        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
645        assert!(!is_in_inline_html_code(line, 16)); // '+'
646    }
647
648    #[test]
649    fn test_inline_html_code_with_attributes() {
650        let line = r#"<code class="lang">x * y</code>"#;
651        assert!(is_in_inline_html_code(line, 19)); // 'x'
652        assert!(is_in_inline_html_code(line, 23)); // '*'
653        assert!(!is_in_inline_html_code(line, 0)); // before tag
654    }
655
656    #[test]
657    fn test_inline_html_code_case_insensitive() {
658        let line = "<CODE>a * b</CODE>";
659        assert!(is_in_inline_html_code(line, 6)); // 'a'
660        assert!(is_in_inline_html_code(line, 8)); // '*'
661    }
662
663    #[test]
664    fn test_inline_html_code_var_and_pre() {
665        let line = "<var>x * y</var> and <pre>a * b</pre>";
666        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
667        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
668        assert!(!is_in_inline_html_code(line, 17)); // 'and'
669    }
670
671    #[test]
672    fn test_inline_html_code_unclosed() {
673        // Unclosed tag should not match
674        let line = "<code>a * b without closing";
675        assert!(!is_in_inline_html_code(line, 6));
676    }
677
678    #[test]
679    fn test_inline_html_code_no_substring_match() {
680        // <variable> should NOT be treated as <var>
681        let line = "<variable>a * b</variable>";
682        assert!(!is_in_inline_html_code(line, 11));
683
684        // <keyboard> should NOT be treated as <kbd>
685        let line2 = "<keyboard>x * y</keyboard>";
686        assert!(!is_in_inline_html_code(line2, 11));
687    }
688}