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, SourcePositions};
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 regex::Regex;
16use std::cell::OnceCell;
17use std::collections::HashMap;
18use std::sync::{Arc, 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/// What a scan of the content found for HTML comments.
38#[derive(Debug, Default)]
39pub struct HtmlCommentScan {
40    /// Byte ranges of the complete comments, in document order.
41    pub ranges: Vec<ByteRange>,
42    /// Byte offset of a `<!--` that no `-->` closes. At most one is reported:
43    /// once the document has no closer left, no later opener can close either.
44    pub unterminated: Option<usize>,
45}
46
47/// Pre-compute all HTML comment ranges in the content.
48/// Returns a sorted vector of byte ranges for efficient lookup.
49pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
50    compute_html_comment_ranges_filtered(content, &[], &[])
51}
52
53/// Pre-compute HTML comment ranges, treating `<!--`/`-->` inside inline code
54/// spans or fenced/indented code blocks as literal text rather than comment
55/// delimiters.
56///
57/// Inside code (a backtick code span or a code block), `<!--` and `-->` are
58/// literal. A naive `<!--[\s\S]*?-->` scan would pair a `<!--` in one code
59/// region with a `-->` in a later region, spuriously marking every line between
60/// them as "inside an HTML comment". That made content invisible to rules that
61/// skip comment lines: MD032's blank-line check produced false positives and a
62/// non-converging `--fix` loop, and MD034 silently dropped a real bare URL that
63/// fell between a code-block `<!--` and a later `-->`.
64///
65/// To stay correct even when a literal delimiter precedes a genuine comment
66/// (`` `<!--` <!-- real --> ``), this scans for the next `<!--` opener that is
67/// not in code, then the next `-->` closer that is not in code - it does not
68/// filter completed regex matches. `code_span_ranges` and `code_block_ranges`
69/// are the parser's half-open `[start, end)` byte ranges (`ParseResult`). An
70/// opener pairs with the first following closer, and an opener with no closer
71/// yields no range. A closer may overlap the opener it closes, so `<!-->` and
72/// `<!--->` are whole comments, matching CommonMark.
73pub fn compute_html_comment_ranges_filtered(
74    content: &str,
75    code_span_ranges: &[(usize, usize)],
76    code_block_ranges: &[(usize, usize)],
77) -> Vec<ByteRange> {
78    scan_html_comments(content, code_span_ranges, code_block_ranges, 0).ranges
79}
80
81/// Scan for HTML comments, keeping the position of an opener that never closes.
82///
83/// Same scan as [`compute_html_comment_ranges_filtered`], which discards the
84/// unterminated opener. MD086 reports it, so it is carried out of the scan
85/// rather than re-derived by a second, possibly disagreeing pass.
86///
87/// `scan_from` is the byte offset the document's body starts at, so front
88/// matter can be excluded. A `<!--` in a YAML or TOML value is data, and
89/// pairing it with a `-->` in the body would mark everything in between as a
90/// comment, hiding it from every rule.
91pub fn scan_html_comments(
92    content: &str,
93    code_span_ranges: &[(usize, usize)],
94    code_block_ranges: &[(usize, usize)],
95    scan_from: usize,
96) -> HtmlCommentScan {
97    // Build the union only if a delimiter is queried. Callers may supply
98    // overlapping or unordered ranges (including appended hidden comments).
99    // Documents without comment openers never allocate or sort this index.
100    let code_ranges = OnceCell::new();
101    let in_code = |pos: usize| {
102        code_ranges
103            .get_or_init(|| SkipRegions::new(code_span_ranges.iter().chain(code_block_ranges).copied().collect()))
104            .contains(pos)
105    };
106
107    let mut ranges = Vec::new();
108    let mut search_from = scan_from.min(content.len());
109    while let Some(rel) = super::find_ascii_delimiter(&content[search_from..], "<!--") {
110        let open = search_from + rel;
111        if in_code(open) {
112            // Literal `<!--` inside code: not a comment opener.
113            search_from = open + "<!--".len();
114            continue;
115        }
116        // Find the next `-->` that is not itself inside code. The search starts
117        // two bytes into the opener, because the opener's own trailing dashes can
118        // serve as the closer's leading dashes: `<!-->` and `<!--->` are complete
119        // comments, and a line holding one ends an HTML block.
120        let mut close_from = open + 2;
121        let end = loop {
122            let Some(crel) = super::find_ascii_delimiter(&content[close_from..], "-->") else {
123                break None;
124            };
125            let close = close_from + crel;
126            if in_code(close) {
127                close_from = close + "-->".len();
128                continue;
129            }
130            break Some(close + "-->".len());
131        };
132        match end {
133            Some(end) => {
134                ranges.push(ByteRange { start: open, end });
135                search_from = end;
136            }
137            // Unterminated comment (no closer anywhere): it covers no range,
138            // and no opener after it can close either, so the scan is done.
139            None => {
140                return HtmlCommentScan {
141                    ranges,
142                    unterminated: Some(open),
143                };
144            }
145        }
146    }
147    HtmlCommentScan {
148        ranges,
149        unterminated: None,
150    }
151}
152
153#[cfg(test)]
154mod comment_tests;
155
156/// Re-resolve an unterminated `<!--` that an Obsidian comment hides.
157///
158/// [`scan_html_comments`] has to run before Obsidian comments are known,
159/// because detecting those needs the HTML comment ranges. A `<!--` inside a
160/// `%%` comment is hidden text rather than an opener, so when the reported one
161/// falls in a comment the scan is repeated with those comments among the ranges
162/// whose delimiters are literal. That resumes the search at the next opener
163/// instead of suppressing the finding, which would hide a real unclosed comment
164/// below the hidden one.
165///
166/// Only the opener is taken from the second scan. Skipping strictly more of the
167/// content cannot turn up a closer the first scan missed, so the complete
168/// comments it found still stand.
169pub fn unterminated_html_comment_outside(
170    reported: Option<usize>,
171    hidden_ranges: &[(usize, usize)],
172    content: &str,
173    code_span_ranges: &[(usize, usize)],
174    code_block_ranges: &[(usize, usize)],
175    scan_from: usize,
176) -> Option<usize> {
177    let offset = reported?;
178    if !hidden_ranges
179        .iter()
180        .any(|&(start, end)| offset >= start && offset < end)
181    {
182        return reported;
183    }
184    let mut literal_ranges = code_block_ranges.to_vec();
185    literal_ranges.extend_from_slice(hidden_ranges);
186    scan_html_comments(content, code_span_ranges, &literal_ranges, scan_from).unterminated
187}
188
189/// The range an unclosed `<!--` hides, if it hides anything.
190///
191/// Whether an unclosed opener swallows the text after it is a block-structure
192/// question, so it is answered by the parser rather than by inspecting the line.
193/// At the start of a line `<!--` opens an HTML block and everything to the end
194/// of that block is comment text; mid-paragraph CommonMark has no comment to
195/// open, so the marker renders literally and hides nothing.
196///
197/// The block is the unit because it is not always the rest of the document. An
198/// opener inside a blockquote or a list item ends with its container, leaving
199/// the content after it visible, and one indented four spaces is code and opens
200/// no block at all. `html_blocks` are the parser's ranges, so each of those
201/// falls out without a separate rule here.
202///
203/// The range starts at the opener, not at the block, because a block may have
204/// begun earlier with a tag: in `<div>` followed by `<!--`, only the text from
205/// the marker on is inside the comment.
206pub fn unterminated_comment_range(opener: usize, html_blocks: &[(usize, usize)]) -> Option<ByteRange> {
207    html_blocks
208        .iter()
209        .find(|&&(start, end)| opener >= start && opener < end)
210        .map(|&(_, end)| ByteRange { start: opener, end })
211}
212
213/// Check if a byte position is within any of the pre-computed HTML comment ranges
214/// Uses binary search for O(log n) complexity
215pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
216    // Binary search to find a range that might contain byte_pos
217    ranges
218        .binary_search_by(|range| {
219            if byte_pos < range.start {
220                std::cmp::Ordering::Greater
221            } else if byte_pos >= range.end {
222                std::cmp::Ordering::Less
223            } else {
224                std::cmp::Ordering::Equal
225            }
226        })
227        .is_ok()
228}
229
230/// Check if a line's content is ENTIRELY within a single HTML comment.
231///
232/// Callers must pass the byte range of the line's *content* (leading and
233/// trailing whitespace trimmed off), not the raw line bounds: an indented
234/// comment begins at the `<!--` after the indent, so passing the column-0
235/// line start would place `content_start` before the comment range and the
236/// line would never be recognised as being inside the comment.
237///
238/// Returns true only if both `content_start` AND `content_end` fall within the
239/// same comment range.
240pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], content_start: usize, content_end: usize) -> bool {
241    for range in ranges {
242        // If the content start is within this range, check if the content end is also within it
243        if content_start >= range.start && content_start < range.end {
244            return content_end <= range.end;
245        }
246    }
247    false
248}
249
250/// The same containment check for the scanner's sorted, non-overlapping
251/// comment ranges. Keep the public scalar helper's first-match semantics for
252/// callers supplying arbitrary ranges.
253pub(crate) fn is_line_entirely_in_sorted_html_comment(
254    ranges: &[ByteRange],
255    content_start: usize,
256    content_end: usize,
257) -> bool {
258    let index = ranges.partition_point(|range| range.start <= content_start);
259    index > 0 && content_start < ranges[index - 1].end && content_end <= ranges[index - 1].end
260}
261
262/// Check if a byte position is within a JSX expression (MDX: {expression})
263#[inline]
264pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
265    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
266}
267
268/// Check if a byte position is within an MDX comment ({/* ... */})
269#[inline]
270pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
271    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
272}
273
274/// Check if a line should be skipped due to MkDocs snippet syntax
275pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
276    flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
277}
278
279/// Check if a line is a MkDocs admonition marker
280pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
281    flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
282}
283
284/// Check if a line is a MkDocs footnote definition
285pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
286    flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
287}
288
289/// Check if a line is a MkDocs tab marker
290pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
291    flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
292}
293
294/// Check if a line contains MkDocs Critic Markup
295pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
296    flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
297}
298
299/// Check if a byte position is within an HTML tag
300pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
301    for html_tag in ctx.html_tags().iter() {
302        if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
303            return true;
304        }
305    }
306    false
307}
308
309/// Check if a byte position is within a math context.
310///
311/// `$$...$$` display math is recognized only when it begins its line; a
312/// mid-line or stray-prose `$$...$$` is a literal, not math. Single-`$` inline
313/// spans are recognized anywhere. This keeps every math-aware rule agreeing on
314/// what is math.
315pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
316    // Use the cached ranges on the context; recomputing math_byte_ranges(content)
317    // on every call made callers that invoke this per element O(elements * content).
318    ctx.math_byte_ranges()
319        .iter()
320        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
321}
322
323/// Paired `$$ ... $$` display-math byte ranges, half-open `[start, end)`.
324///
325/// A block only *opens* on a `$$` that begins its line, ignoring leading
326/// whitespace and blockquote markers (`>`); a stray `$$` mid-prose is a
327/// literal, not a block opener. This keeps the byte-level result consistent
328/// with the line-level `compute_math_block_line_map` guard. Once open, the
329/// block *closes* on the next `$$` anywhere - even when that closing `$$`
330/// shares its line with LaTeX content (`\end{cases}$$`) or trailing Markdown
331/// prose. An opener with no matching closer is dropped, not treated as an
332/// unterminated block that swallows the rest of the document.
333pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
334    if !content.contains("$$") {
335        return Vec::new();
336    }
337    let bytes = content.as_bytes();
338    let mut ranges = Vec::new();
339    let mut open: Option<usize> = None;
340    let mut line_start = 0usize;
341    let mut i = 0;
342    while i < bytes.len() {
343        match bytes[i] {
344            b'\n' => {
345                line_start = i + 1;
346                i += 1;
347            }
348            b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
349                match open {
350                    None => {
351                        // Open only when this `$$` is the first non-blank,
352                        // non-blockquote content on its line.
353                        let starts_line = bytes[line_start..i]
354                            .iter()
355                            .all(|&b| b == b' ' || b == b'\t' || b == b'>');
356                        if starts_line {
357                            open = Some(i);
358                        }
359                    }
360                    Some(start) => {
361                        ranges.push((start, i + 2));
362                        open = None;
363                    }
364                }
365                i += 2;
366            }
367            _ => i += 1,
368        }
369    }
370    ranges
371}
372
373/// Check if a byte position is within a `$$ ... $$` display-math block.
374///
375/// A block opens only on a `$$` that begins its line and closes on the next
376/// `$$` anywhere, so the closing fence ends the block even when it shares its
377/// line with LaTeX content (e.g. `\end{cases}$$`) or trailing Markdown prose;
378/// bytes after the closing `$$` are not math.
379pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
380    math_block_ranges(content)
381        .iter()
382        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
383}
384
385/// Check if a byte position is within inline math (`$...$`).
386///
387/// Only single-`$` spans count here. A `$$...$$` token is display-math
388/// syntax, and whether it is actually math depends solely on whether it
389/// begins its line - that decision belongs to [`is_in_math_block`]. The
390/// regex still consumes `$$...$$` tokens so a single-`$` span cannot straddle
391/// them, but a mid-line `$$...$$` is a literal here, not inline math, keeping
392/// this function consistent with the line-start-gated block model.
393pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
394    for m in INLINE_MATH_REGEX.find_iter(content) {
395        if content[m.start()..m.end()].starts_with("$$") {
396            continue;
397        }
398        if m.start() <= byte_pos && byte_pos < m.end() {
399            return true;
400        }
401    }
402    false
403}
404
405/// All math byte ranges in `content`: line-start `$$...$$` display blocks
406/// plus single-`$` inline spans. Ranges are half-open `[start, end)` and may
407/// be unordered relative to each other; membership is by `any`-containment.
408///
409/// Precompute this once when classifying many positions in one document
410/// (e.g. every emphasis span). [`is_in_math_context`] is the single-shot
411/// equivalent and is defined in terms of the same two sources.
412pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
413    if !content.contains('$') {
414        return Vec::new();
415    }
416    let mut ranges = math_block_ranges(content);
417    for m in INLINE_MATH_REGEX.find_iter(content) {
418        if content[m.start()..m.end()].starts_with("$$") {
419            continue;
420        }
421        ranges.push((m.start(), m.end()));
422    }
423    ranges
424}
425
426/// Check if a line contains table syntax
427pub fn is_table_line(line: &str) -> bool {
428    let trimmed = line.trim();
429
430    // Check for table separator line
431    if trimmed
432        .chars()
433        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
434        && trimmed.contains('|')
435        && trimmed.contains('-')
436    {
437        return true;
438    }
439
440    // Check for table content line (starts and/or ends with |)
441    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
442        return true;
443    }
444
445    false
446}
447
448/// Check if a byte position is within an MkDocs icon shortcode
449/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
450pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
451    // Only skip for MkDocs flavor, but check pattern for all flavors
452    // since emoji shortcodes are universal
453    mkdocs_icons::is_in_any_shortcode(line, position)
454}
455
456/// Check if a byte position is within PyMdown extension markup
457/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
458///
459/// For MkDocs flavor: supports all PyMdown extensions
460/// For Obsidian flavor: only supports Mark (==highlight==) syntax
461pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
462    match flavor {
463        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
464        MarkdownFlavor::Obsidian => {
465            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
466            mkdocs_extensions::is_in_mark(line, position)
467        }
468        _ => false,
469    }
470}
471
472/// Check whether a position on a line falls inside an inline HTML code-like element.
473///
474/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
475/// These are inline elements whose content should not be interpreted as markdown emphasis.
476pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
477    visit_inline_html_code_ranges(line, |start, end| start <= position && position < end)
478}
479
480fn visit_inline_html_code_ranges(line: &str, mut visit: impl FnMut(usize, usize) -> bool) -> bool {
481    if !line.contains('<') {
482        return false;
483    }
484    // Tags whose content should not be parsed as markdown
485    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
486
487    let bytes = line.as_bytes();
488
489    for tag in TAGS {
490        let open_bytes = format!("<{tag}").into_bytes();
491        let close_pattern = format!("</{tag}>").into_bytes();
492
493        let mut search_from = 0;
494        while search_from + open_bytes.len() <= bytes.len() {
495            // Find opening tag (case-insensitive byte search)
496            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
497                break;
498            };
499
500            let after_tag = open_abs + open_bytes.len();
501
502            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
503            if after_tag < bytes.len() {
504                let next = bytes[after_tag];
505                if next != b'>' && next != b' ' && next != b'\t' {
506                    search_from = after_tag;
507                    continue;
508                }
509            }
510
511            // Find the end of the opening tag
512            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
513                break;
514            };
515            let content_start = after_tag + tag_close + 1;
516
517            // Find the closing tag (case-insensitive)
518            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
519                break;
520            };
521            let content_end = close_start;
522
523            if visit(content_start, content_end) {
524                return true;
525            }
526
527            search_from = close_start + close_pattern.len();
528        }
529    }
530    false
531}
532
533/// Case-insensitive byte search within a slice, starting at `from`.
534fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
535    if needle.is_empty() || from + needle.len() > haystack.len() {
536        return None;
537    }
538    for i in from..=haystack.len() - needle.len() {
539        if haystack[i..i + needle.len()]
540            .iter()
541            .zip(needle.iter())
542            .all(|(h, n)| h.eq_ignore_ascii_case(n))
543        {
544            return Some(i);
545        }
546    }
547    None
548}
549
550/// Check if a byte position is within flavor-specific markup
551/// For MkDocs: icon shortcodes and PyMdown extensions
552/// For Obsidian: highlight syntax (==text==)
553pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
554    if is_in_icon_shortcode(line, position, flavor) {
555        return true;
556    }
557    if is_in_pymdown_markup(line, position, flavor) {
558        return true;
559    }
560    false
561}
562
563/// Check if a byte position within a line is inside a backtick-delimited code span.
564///
565/// This is a line-level fallback for cases where pulldown-cmark's code span detection
566/// misses spans due to table parsing interference (e.g., pipes inside code spans
567/// in table rows cause pulldown-cmark to misidentify cell boundaries).
568fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
569    visit_inline_code_ranges(line, |start, end| start <= byte_pos && byte_pos < end)
570}
571
572/// Visit the fallback's non-overlapping code ranges, stopping when requested.
573fn visit_inline_code_ranges(line: &str, mut visit: impl FnMut(usize, usize) -> bool) -> bool {
574    let bytes = line.as_bytes();
575    let mut i = 0;
576
577    while i < bytes.len() {
578        if bytes[i] == b'`' {
579            let open_start = i;
580            let mut backtick_count = 0;
581            while i < bytes.len() && bytes[i] == b'`' {
582                backtick_count += 1;
583                i += 1;
584            }
585
586            // Search for matching closing backticks
587            let mut j = i;
588            while j < bytes.len() {
589                if bytes[j] == b'`' {
590                    let mut close_count = 0;
591                    while j < bytes.len() && bytes[j] == b'`' {
592                        close_count += 1;
593                        j += 1;
594                    }
595                    if close_count == backtick_count {
596                        // Found matching pair: code span covers open_start..j
597                        if visit(open_start, j) {
598                            return true;
599                        }
600                        i = j;
601                        break;
602                    }
603                } else {
604                    j += 1;
605                }
606            }
607
608            if j >= bytes.len() {
609                // No matching close found, remaining text is not a code span
610                break;
611            }
612        } else {
613            i += 1;
614        }
615    }
616
617    false
618}
619
620/// Check if a byte position is within an HTML tag. O(log n) via binary search.
621fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
622    let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
623    idx > 0 && byte_pos < html_tags[idx - 1].byte_end
624}
625
626/// Check if a byte position is within HTML code content (`<code>...</code>`).
627/// Uses pre-computed code ranges for O(log n) lookup via binary search.
628fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
629    let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
630    idx > 0 && byte_pos < code_ranges[idx - 1].1
631}
632
633/// Pre-compute ranges covered by `<code>...</code>` HTML tags.
634/// Returns sorted Vec of (start, end) byte ranges.
635pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
636    let mut ranges = Vec::new();
637    let mut open_code_end: Option<usize> = None;
638
639    for tag in html_tags {
640        if tag.tag_name == "code" {
641            if tag.is_self_closing {
642                continue;
643            } else if !tag.is_closing {
644                open_code_end = Some(tag.byte_end);
645            } else if tag.is_closing {
646                if let Some(start) = open_code_end {
647                    ranges.push((start, tag.byte_offset));
648                }
649                open_code_end = None;
650            }
651        }
652    }
653    // Handle unclosed <code> tag
654    if let Some(start) = open_code_end {
655        ranges.push((start, usize::MAX));
656    }
657    ranges
658}
659
660/// Hide cached MDX code from inline marker discovery without changing offsets.
661/// Callers must reject fixes overlapping these ranges so masking cannot enter
662/// replacement text. Lines without MDX code borrow their original source.
663pub(crate) fn mask_mdx_inline_code<'a>(ctx: &LintContext, source: &'a str, start: usize) -> std::borrow::Cow<'a, str> {
664    let end = start + source.len();
665    let mut ranges = ctx.mdx_inline_code_ranges(start, end).peekable();
666    if ranges.peek().is_none() {
667        return std::borrow::Cow::Borrowed(source);
668    }
669    let mut bytes = source.as_bytes().to_vec();
670    for (range_start, range_end) in ranges {
671        bytes[range_start.max(start) - start..range_end.min(end) - start].fill(b'X');
672    }
673    // Whole source ranges are replaced with ASCII, retaining valid UTF-8.
674    std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII masking preserves UTF-8"))
675}
676
677/// Point membership in possibly overlapping regions. Sorted starts and prefix
678/// maximum ends preserve the union while allowing predecessor queries.
679#[derive(Default)]
680pub(crate) struct SkipRegions<T = usize>(Vec<(T, T)>);
681
682impl<T: Ord + Copy> SkipRegions<T> {
683    pub(crate) fn new(mut ranges: Vec<(T, T)>) -> Self {
684        ranges.sort_unstable_by_key(|&(start, _)| start);
685        let mut end = None;
686        for range in &mut ranges {
687            let maximum = end.map_or(range.1, |previous: T| previous.max(range.1));
688            range.1 = maximum;
689            end = Some(maximum);
690        }
691        Self(ranges)
692    }
693
694    /// Strict containment preserves consumers that exclude both endpoints.
695    pub(crate) fn contains_interior(&self, position: T) -> bool {
696        let index = self.0.partition_point(|&(start, _)| start < position);
697        index > 0 && position < self.0[index - 1].1
698    }
699
700    fn contains(&self, position: T) -> bool {
701        let index = self.0.partition_point(|&(start, _)| start <= position);
702        index > 0 && position < self.0[index - 1].1
703    }
704}
705
706impl SkipRegions {
707    fn on_line(line: &str, flavor: MarkdownFlavor) -> Self {
708        let mut ranges = Vec::new();
709        visit_inline_code_ranges(line, |start, end| {
710            ranges.push((start, end));
711            false
712        });
713        append_markup_ranges(&mut ranges, line, flavor);
714        Self::new(ranges)
715    }
716}
717
718fn append_markup_ranges(ranges: &mut Vec<(usize, usize)>, line: &str, flavor: MarkdownFlavor) {
719    ranges.extend(mkdocs_icons::shortcode_spans(line));
720    match flavor {
721        MarkdownFlavor::MkDocs => ranges.extend(mkdocs_extensions::pymdown_spans(line)),
722        MarkdownFlavor::Obsidian => ranges.extend(mkdocs_extensions::find_mark_spans(line)),
723        _ => {}
724    }
725}
726
727/// Per-check markup membership using the caller's line-relative byte positions.
728/// Only queried long lines allocate an index; short lines retain scalar checks.
729pub(crate) struct MarkupExclusions<'ctx, 'src> {
730    ctx: &'ctx LintContext<'src>,
731    lines: HashMap<usize, SkipRegions>,
732}
733
734impl<'ctx, 'src> MarkupExclusions<'ctx, 'src> {
735    pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
736        Self {
737            ctx,
738            lines: HashMap::new(),
739        }
740    }
741
742    pub(crate) fn contains(&mut self, index: usize, byte: usize) -> bool {
743        let Some(line) = self.ctx.raw_lines().get(index) else {
744            return false;
745        };
746        if line.len() < 256 {
747            return is_in_mkdocs_markup(line, byte, self.ctx.flavor);
748        }
749        self.lines
750            .entry(index)
751            .or_insert_with(|| {
752                let mut ranges = Vec::new();
753                append_markup_ranges(&mut ranges, line, self.ctx.flavor);
754                SkipRegions::new(ranges)
755            })
756            .contains(byte)
757    }
758}
759
760/// Cached equivalents of MD037's individual exclusion predicates. Code spans
761/// use character positions; inline HTML and markup use line-relative bytes.
762pub(crate) struct SpacingExclusions<'ctx, 'src> {
763    ctx: &'ctx LintContext<'src>,
764    lines: HashMap<usize, SkipRegions>,
765    math: OnceCell<SkipRegions>,
766    images: OnceCell<SkipRegions>,
767    code: OnceCell<SkipRegions<(usize, usize)>>,
768}
769
770impl<'ctx, 'src> SpacingExclusions<'ctx, 'src> {
771    pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
772        Self {
773            ctx,
774            lines: HashMap::new(),
775            math: OnceCell::new(),
776            images: OnceCell::new(),
777            code: OnceCell::new(),
778        }
779    }
780
781    pub(crate) fn in_image(&self, byte: usize) -> bool {
782        self.images
783            .get_or_init(|| {
784                SkipRegions::new(
785                    self.ctx
786                        .images()
787                        .iter()
788                        .map(|image| (image.byte_offset, image.byte_end))
789                        .collect(),
790                )
791            })
792            .contains(byte)
793    }
794
795    pub(crate) fn in_math(&self, byte: usize) -> bool {
796        self.math
797            .get_or_init(|| SkipRegions::new(self.ctx.math_byte_ranges().to_vec()))
798            .contains(byte)
799    }
800
801    pub(crate) fn in_code_span(&self, line: usize, column: usize) -> bool {
802        if line == 0 || line > self.ctx.lines.len() {
803            return false;
804        }
805        self.code
806            .get_or_init(|| {
807                SkipRegions::new(
808                    self.ctx
809                        .source_code_spans()
810                        .iter()
811                        .map(|span| ((span.line, span.start_col), (span.end_line, span.end_col)))
812                        .collect(),
813                )
814            })
815            .contains((line, column.saturating_sub(1)))
816    }
817
818    pub(crate) fn in_html_code_or_markup(&mut self, index: usize, byte: usize) -> bool {
819        let Some(line) = self.ctx.raw_lines().get(index) else {
820            return false;
821        };
822        if line.len() < 256 {
823            return is_in_inline_html_code(line, byte) || is_in_mkdocs_markup(line, byte, self.ctx.flavor);
824        }
825        self.lines
826            .entry(index)
827            .or_insert_with(|| {
828                let mut ranges = Vec::new();
829                visit_inline_html_code_ranges(line, |start, end| {
830                    ranges.push((start, end));
831                    false
832                });
833                append_markup_ranges(&mut ranges, line, self.ctx.flavor);
834                SkipRegions::new(ranges)
835            })
836            .contains(byte)
837    }
838}
839
840/// Per-check skip facts and source columns shared by emphasis-style passes.
841/// Short lines retain direct checks; only queried long lines allocate indexes.
842pub(crate) struct EmphasisContext<'ctx, 'src> {
843    ctx: &'ctx LintContext<'src>,
844    positions: SourcePositions<'ctx>,
845    html_tags: Arc<Vec<HtmlTag>>,
846    html_code_ranges: Vec<(usize, usize)>,
847    long_lines: HashMap<usize, SkipRegions>,
848    math: OnceCell<SkipRegions>,
849}
850
851impl<'ctx, 'src> EmphasisContext<'ctx, 'src> {
852    pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
853        let html_tags = ctx.html_tags();
854        let html_code_ranges = compute_html_code_ranges(&html_tags);
855        Self {
856            ctx,
857            positions: SourcePositions::new(ctx.content, &ctx.lines),
858            html_tags,
859            html_code_ranges,
860            long_lines: HashMap::new(),
861            math: OnceCell::new(),
862        }
863    }
864
865    /// Match MD050's diagnostic convention: clip a multiline span to its
866    /// starting line, while fixes continue to use the full source byte range.
867    pub(crate) fn single_line_range(&mut self, start: usize, end: usize) -> (usize, usize, usize, usize) {
868        let (index, line, column) = self.positions.position(start);
869        (line, column + 1, line, self.positions.column_on_line(index, end) + 1)
870    }
871
872    /// Preserve the existing code, link, markup, math and flavor exclusions.
873    pub(crate) fn should_skip(&mut self, span_start: usize, span_end: usize) -> bool {
874        let ctx = self.ctx;
875        let (line_index, line_num, column) = self.positions.position(span_start);
876        if ctx.line_info(line_num).is_some_and(|info| {
877            info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_structural_line()
878        }) {
879            return true;
880        }
881        // The legacy helpers receive a character column even though their
882        // ranges are byte-based. Keep that behavior here; changing Unicode
883        // eligibility is separate from indexing the existing predicates.
884        let in_line_markup = ctx.raw_lines().get(line_index).is_some_and(|line| {
885            if line.len() < 256 {
886                is_in_mkdocs_markup(line, column, ctx.flavor) || is_in_inline_code_on_line(line, column)
887            } else {
888                self.long_lines
889                    .entry(line_index)
890                    .or_insert_with(|| SkipRegions::on_line(line, ctx.flavor))
891                    .contains(column)
892            }
893        });
894        ctx.is_in_jinja_string(span_start)
895            || ctx.is_in_jinja_string(span_end.saturating_sub(1))
896            || ctx.is_in_shortcode(span_start)
897            || ctx.is_in_shortcode(span_end.saturating_sub(1))
898            || ctx.is_in_code_block_or_span(span_start)
899            || in_line_markup
900            || ctx.is_in_link(span_start)
901            || ctx.is_in_unresolved_autoref_identifier(span_start)
902            || is_byte_in_html_tag(&self.html_tags, span_start)
903            || ctx.is_in_jsx_component_tag(span_start)
904            || ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
905            || is_byte_in_html_code_content(&self.html_code_ranges, span_start)
906            || self
907                .math
908                .get_or_init(|| SkipRegions::new(ctx.math_byte_ranges().to_vec()))
909                .contains(span_start)
910            || is_in_jsx_expression(ctx, span_start)
911            || is_in_mdx_comment(ctx, span_start)
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn strict_regions_preserve_endpoints_and_extreme_offsets() {
921        let ranges = vec![
922            (20, 30),
923            (4, 4),
924            (10, 3),
925            (0, 2),
926            (2, 5),
927            (1, 8),
928            (20, 22),
929            (usize::MAX - 2, usize::MAX),
930        ];
931        let index = SkipRegions::new(ranges.clone());
932        for position in (0..=32).chain([usize::MAX - 2, usize::MAX - 1, usize::MAX]) {
933            assert_eq!(
934                index.contains_interior(position),
935                ranges.iter().any(|&(start, end)| start < position && position < end)
936            );
937        }
938    }
939
940    proptest::proptest! {
941        #[test]
942        fn strict_regions_match_scalar_membership(ranges in proptest::collection::vec((0usize..128, 0usize..128), 0..60)) {
943            let index = SkipRegions::new(ranges.clone());
944            for position in 0..=129 {
945                proptest::prop_assert_eq!(index.contains_interior(position), ranges.iter().any(|&(start, end)| start < position && position < end));
946            }
947        }
948    }
949
950    #[test]
951    fn markup_exclusions_match_scalar_at_each_byte() {
952        for padding in [0, 40] {
953            let prefix = "é日本🙂e\u{301}\t".repeat(padding);
954            let source = format!(
955                "{prefix}:smile: :material-check: :icon_with_underscore: ^^insert^^ ^super^ ~sub~ ~~strike~~\n\
956                 {prefix}==mark== ++ctrl+alt++ {{--delete--}} {{++add++}} \\==escaped== ^unclosed\n"
957            );
958            for flavor in crate::format_proof::ALL_FLAVORS {
959                for ending in ["\n", "\r\n"] {
960                    let content = source.replace('\n', ending);
961                    let ctx = LintContext::new(&content, flavor, None);
962                    let mut index = MarkupExclusions::new(&ctx);
963                    for (line_index, line) in ctx.raw_lines().iter().enumerate().rev() {
964                        for byte in (0..=line.len() + 1).rev() {
965                            assert_eq!(
966                                index.contains(line_index, byte),
967                                is_in_mkdocs_markup(line, byte, flavor),
968                                "{flavor:?}, line={line_index}, byte={byte}",
969                            );
970                        }
971                    }
972                    assert!(!index.contains(ctx.raw_lines().len(), 0));
973                    if padding == 0 {
974                        assert!(index.lines.is_empty(), "short lines should not allocate indexes");
975                    }
976                }
977            }
978        }
979    }
980
981    // Frozen scalar reference for EmphasisContext::should_skip. Changes to
982    // eligibility must explicitly update this oracle as well as the index.
983    fn legacy_should_skip_emphasis_span(
984        ctx: &LintContext,
985        html_tags: &[HtmlTag],
986        html_code_ranges: &[(usize, usize)],
987        span_start: usize,
988        span_end: usize,
989    ) -> bool {
990        let lines = ctx.raw_lines();
991        let (line_num, col) = ctx.offset_to_line_col(span_start);
992
993        // Skip matches in front matter, mkdocstrings blocks, MDX module code, or MyST comments
994        if ctx.line_info(line_num).is_some_and(|info| {
995            info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_structural_line()
996        }) {
997            return true;
998        }
999
1000        // Check MkDocs markup
1001        let in_mkdocs_markup = lines
1002            .get(line_num.saturating_sub(1))
1003            .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
1004
1005        // Line-level inline code fallback for cases pulldown-cmark misses
1006        let in_inline_code = lines
1007            .get(line_num.saturating_sub(1))
1008            .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
1009
1010        ctx.is_in_jinja_string(span_start)
1011            || ctx.is_in_jinja_string(span_end.saturating_sub(1))
1012            || ctx.is_in_shortcode(span_start)
1013            || ctx.is_in_shortcode(span_end.saturating_sub(1))
1014            || ctx.is_in_code_block_or_span(span_start)
1015            || in_inline_code
1016            || ctx.is_in_link(span_start)
1017            || is_byte_in_html_tag(html_tags, span_start)
1018            || ctx.is_in_jsx_component_tag(span_start)
1019            || ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
1020            || is_byte_in_html_code_content(html_code_ranges, span_start)
1021            || in_mkdocs_markup
1022            || is_in_math_context(ctx, span_start)
1023            || is_in_jsx_expression(ctx, span_start)
1024            || is_in_mdx_comment(ctx, span_start)
1025    }
1026
1027    fn legacy_inline_html_code(line: &str, position: usize) -> bool {
1028        // Tags whose content should not be parsed as markdown
1029        const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
1030
1031        let bytes = line.as_bytes();
1032
1033        for tag in TAGS {
1034            let open_bytes = format!("<{tag}").into_bytes();
1035            let close_pattern = format!("</{tag}>").into_bytes();
1036
1037            let mut search_from = 0;
1038            while search_from + open_bytes.len() <= bytes.len() {
1039                // Find opening tag (case-insensitive byte search)
1040                let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
1041                    break;
1042                };
1043
1044                let after_tag = open_abs + open_bytes.len();
1045
1046                // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
1047                if after_tag < bytes.len() {
1048                    let next = bytes[after_tag];
1049                    if next != b'>' && next != b' ' && next != b'\t' {
1050                        search_from = after_tag;
1051                        continue;
1052                    }
1053                }
1054
1055                // Find the end of the opening tag
1056                let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
1057                    break;
1058                };
1059                let content_start = after_tag + tag_close + 1;
1060
1061                // Find the closing tag (case-insensitive)
1062                let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
1063                    break;
1064                };
1065                let content_end = close_start;
1066
1067                if position >= content_start && position < content_end {
1068                    return true;
1069                }
1070
1071                search_from = close_start + close_pattern.len();
1072            }
1073        }
1074        false
1075    }
1076
1077    #[test]
1078    fn spacing_exclusions_preserve_scalar_membership() {
1079        let prefix = "é日本🙂e\u{301} ".repeat(40);
1080        let source = format!(
1081            "{prefix}<code>a <code>b</code> c</code> <VAR x='>'>literal</VAR> :smile: ^^insert^^ ==mark== ++ctrl+alt++ ^super^ ~sub~ ~~strike~~ {{--delete--}}
1082
1083{prefix}![a](x) [![b](y)](z) $x$ `é` ``a ` b``
1084
1085`multi
1086line
1087code` text
1088"
1089        );
1090        for flavor in crate::format_proof::ALL_FLAVORS {
1091            for ending in ["\n", "\r\n"] {
1092                let content = source.replace('\n', ending);
1093                let ctx = LintContext::new(&content, flavor, None);
1094                let mut index = SpacingExclusions::new(&ctx);
1095                for byte in (0..=content.len() + 1).rev() {
1096                    assert_eq!(index.in_math(byte), is_in_math_context(&ctx, byte));
1097                    assert_eq!(
1098                        index.in_image(byte),
1099                        ctx.images().iter().any(|s| s.byte_offset <= byte && byte < s.byte_end)
1100                    );
1101                }
1102                for line in 0..=ctx.lines.len() + 1 {
1103                    let width = line
1104                        .checked_sub(1)
1105                        .and_then(|i| ctx.raw_lines().get(i))
1106                        .map_or(0, |s| s.chars().count());
1107                    for column in (0..=width + 2).rev() {
1108                        assert_eq!(
1109                            index.in_code_span(line, column),
1110                            ctx.is_in_code_span(line, column),
1111                            "line={line}, column={column}"
1112                        );
1113                    }
1114                }
1115                for (i, line) in ctx.raw_lines().iter().enumerate().rev() {
1116                    for byte in (0..=line.len() + 1).rev() {
1117                        assert_eq!(
1118                            index.in_html_code_or_markup(i, byte),
1119                            legacy_inline_html_code(line, byte) || is_in_mkdocs_markup(line, byte, flavor)
1120                        );
1121                    }
1122                }
1123            }
1124        }
1125    }
1126
1127    proptest::proptest! {
1128        #![proptest_config(proptest::test_runner::Config::with_cases(128))]
1129        #[test]
1130        fn indexed_inline_html_matches_legacy(
1131            tokens in proptest::collection::vec(proptest::sample::select(vec!["text", "é日🙂", "<code>", "</code>", "<CODE x='>'>", "<codeword>", "<code/>", "<code", "<pre>", "</pre>", "<var>", "</VAR>", "<samp>", "</samp>", "<kbd\t>", "</kbd>", ">", "<", "\n"]), 0..60),
1132        ) {
1133            let source = tokens.concat();
1134            let mut ranges = Vec::new();
1135            visit_inline_html_code_ranges(&source, |start, end| { ranges.push((start, end)); false });
1136            let index = SkipRegions::new(ranges);
1137            for byte in 0..=source.len() + 1 {
1138                proptest::prop_assert_eq!(index.contains(byte), legacy_inline_html_code(&source, byte));
1139                proptest::prop_assert_eq!(is_in_inline_html_code(&source, byte), legacy_inline_html_code(&source, byte));
1140            }
1141        }
1142    }
1143
1144    #[test]
1145    fn inline_code_range_visitor_preserves_boundaries() {
1146        for (line, expected) in [
1147            ("plain", vec![]),
1148            ("`x`", vec![(0, 3)]),
1149            ("a `x` b ``y``", vec![(2, 5), (8, 13)]),
1150            ("`x `` y` z", vec![(0, 8)]),
1151            // The legacy fallback stops at the first unmatched opener.
1152            ("```unclosed `paired`", vec![]),
1153            ("`x` ``unclosed", vec![(0, 3)]),
1154            ("é `日`", vec![(3, 8)]),
1155        ] {
1156            let mut ranges = Vec::new();
1157            visit_inline_code_ranges(line, |start, end| {
1158                ranges.push((start, end));
1159                false
1160            });
1161            assert_eq!(ranges, expected, "{line:?}");
1162            for pos in 0..=line.len() + 1 {
1163                assert_eq!(
1164                    is_in_inline_code_on_line(line, pos),
1165                    expected.iter().any(|&(a, b)| a <= pos && pos < b)
1166                );
1167            }
1168        }
1169    }
1170
1171    proptest::proptest! {
1172        #![proptest_config(proptest::test_runner::Config::with_cases(64))]
1173        #[test]
1174        fn indexed_line_regions_match_legacy_predicates(
1175            tokens in proptest::collection::vec(0usize..18, 0..80),
1176            queries in proptest::collection::vec(0usize..10000, 1..40),
1177        ) {
1178            let choices = ["plain ", "é日本🙂 ", "`code` ", "``x ` y`` ", "`", ":smile:", ":material-check:",
1179                "++ctrl+alt++", "^^insert^^", "^super^", "~~strike~~", "~sub~", "==mark==", "...", "(c)", "**bold**", "{>>comment<<}", "{~~a~>b~~}"];
1180            let line: String = tokens.into_iter().map(|i| choices[i]).collect();
1181            for flavor in crate::format_proof::ALL_FLAVORS {
1182                let index = SkipRegions::on_line(&line, flavor);
1183                for pos in queries.iter().map(|q| q % (line.len() + 2)).chain([0, line.len()]) {
1184                    proptest::prop_assert_eq!(index.contains(pos),
1185                        is_in_inline_code_on_line(&line, pos) || is_in_mkdocs_markup(&line, pos, flavor));
1186                }
1187            }
1188        }
1189    }
1190
1191    #[test]
1192    fn emphasis_context_preserves_skip_decisions_and_clipped_ranges() {
1193        let prefix = "é日本🙂e\u{301} ".repeat(40);
1194        let source = format!(
1195            "---\ntitle: '**frontmatter**'\n---\n\n\
1196             {prefix}**good** __other__ `**literal**` :icon_with_underscore: $x$ **end**\n\n\
1197             {prefix}^^insert^^ ^super^ ++ctrl+alt++ ==mark== ~~strike~~ ~sub~ ... __visible__\n\n\
1198             {prefix}**multi\nline** __multi\nline__ [**link**](target) <code>__code__</code>\n\n\
1199             | a | b |\n| - | - |\n| `x|__code__` | **text** |\n\n\
1200             !!! note\n\n    **note** __note__\n\n\
1201             {{/* **comment** */}}\n\n{{{{< note title=\"**literal**\" >}}}} __last__\n"
1202        );
1203        for flavor in crate::format_proof::ALL_FLAVORS {
1204            for newline in ["\n", "\r\n"] {
1205                let content = source.replace('\n', newline);
1206                let ctx = LintContext::new(&content, flavor, None);
1207                let mut indexed = EmphasisContext::new(&ctx);
1208                let html_tags = ctx.html_tags();
1209                let html_code_ranges = compute_html_code_ranges(&html_tags);
1210                let mut spans: Vec<_> = ctx.strong_spans.iter().map(|s| (s.start, s.end)).collect();
1211                spans.extend(ctx.source_emphasis_spans().iter().map(|s| (s.byte_offset, s.byte_end)));
1212                assert!(!spans.is_empty());
1213                // Multiple passes and reverse order exercise cache reuse and
1214                // out-of-order source positions, including nested spans.
1215                for (start, end) in spans.iter().copied().chain(spans.iter().copied().rev()) {
1216                    assert_eq!(
1217                        indexed.should_skip(start, end),
1218                        legacy_should_skip_emphasis_span(&ctx, &html_tags, &html_code_ranges, start, end),
1219                        "{flavor:?} at {start}..{end}"
1220                    );
1221                    let (line, _) = ctx.offset_to_line_col(start);
1222                    let line_start = ctx.line_start_byte(line).unwrap();
1223                    let text = ctx.raw_lines()[line - 1];
1224                    assert_eq!(
1225                        indexed.single_line_range(start, end),
1226                        crate::utils::range_utils::calculate_match_range(line, text, start - line_start, end - start),
1227                        "{flavor:?} at {start}..{end}"
1228                    );
1229                }
1230            }
1231        }
1232    }
1233
1234    #[test]
1235    fn test_html_comment_detection() {
1236        let content = "Text <!-- comment --> more text";
1237        let ranges = compute_html_comment_ranges(content);
1238        assert!(is_in_html_comment_ranges(&ranges, 10)); // Inside comment
1239        assert!(!is_in_html_comment_ranges(&ranges, 0)); // Before comment
1240        assert!(!is_in_html_comment_ranges(&ranges, 25)); // After comment
1241    }
1242
1243    #[test]
1244    fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
1245        // `<!--` and `-->` inside inline code spans on different lines must not
1246        // pair into a multi-line HTML comment (issue #679).
1247        let content = "a `<!--` b\n\nc `-->` d";
1248        let open = content.find("<!--").unwrap();
1249        let close = content.find("-->").unwrap();
1250        // Code spans covering the two backtick-delimited tokens.
1251        let code_spans = [
1252            (content.find('`').unwrap(), open + "<!--".len() + 1),
1253            (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
1254        ];
1255
1256        // Without code-span awareness the pattern spans open..close (the bug).
1257        assert!(
1258            !compute_html_comment_ranges(content).is_empty(),
1259            "sanity: raw pattern matches across the code spans"
1260        );
1261        // With code-span awareness the spurious match is dropped.
1262        assert!(
1263            compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
1264            "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
1270        // A `<!--` inside a code block must not pair with a later `-->` outside it
1271        // (the code-block counterpart of the code-span case).
1272        let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
1273        let block_end = content.find("```\n\n").unwrap() + "```".len();
1274        let code_blocks = [(0usize, block_end)];
1275        assert!(
1276            compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
1277            "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
1278        );
1279        // A real comment whose opener is outside the block is still detected.
1280        let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
1281        let real_block_end = real.find("```\n\n").unwrap() + "```".len();
1282        let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
1283        assert_eq!(ranges.len(), 1);
1284        assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
1285    }
1286
1287    #[test]
1288    fn test_compute_html_comment_ranges_keeps_real_comments() {
1289        // A genuine comment whose `<!--` is not inside a code span is still
1290        // detected, even when an unrelated code span exists elsewhere.
1291        let content = "text `code` <!-- real comment --> more";
1292        let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
1293        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
1294        assert_eq!(ranges.len(), 1, "the real comment must still be detected");
1295        let comment_start = content.find("<!--").unwrap();
1296        assert_eq!(ranges[0].start, comment_start);
1297    }
1298
1299    #[test]
1300    fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
1301        // A code span containing `<!--` must not consume a real comment that
1302        // follows it: skipping the literal opener, the scan must still discover
1303        // the genuine `<!-- ... -->` and mark its content as a comment.
1304        let content = "a `<!--` then <!-- real --> end";
1305        let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
1306        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
1307        assert_eq!(
1308            ranges.len(),
1309            1,
1310            "the real comment after a code-span opener must be detected"
1311        );
1312        let real_open = content.find("<!-- real").unwrap();
1313        assert_eq!(
1314            ranges[0].start, real_open,
1315            "range must start at the real comment, not the code-span opener"
1316        );
1317        assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
1318    }
1319
1320    #[test]
1321    fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
1322        // A real comment's closing `-->` that lands inside a code span is literal;
1323        // the scan must continue to the next real `-->`.
1324        let content = "<!-- open `-->` still open --> done";
1325        let first_close = content.find("`-->`").unwrap() + 1;
1326        let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
1327        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
1328        assert_eq!(ranges.len(), 1);
1329        assert_eq!(ranges[0].start, 0);
1330        let real_close_end = content.find("--> done").unwrap() + "-->".len();
1331        assert_eq!(
1332            ranges[0].end, real_close_end,
1333            "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_scan_html_comments_starts_at_the_given_offset() {
1339        // The offset is where front matter ends: a delimiter before it is data,
1340        // so it neither opens a comment nor closes one.
1341        let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
1342        let body_start = content.rfind("---\n").unwrap() + "---\n".len();
1343        let scan = scan_html_comments(content, &[], &[], body_start);
1344        assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
1345        assert_eq!(scan.unterminated, None);
1346
1347        let unscoped = scan_html_comments(content, &[], &[], 0);
1348        assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
1349    }
1350
1351    #[test]
1352    fn test_scan_html_comments_reports_the_unterminated_opener() {
1353        let content = "<!-- closed --> text <!-- open";
1354        let scan = scan_html_comments(content, &[], &[], 0);
1355        assert_eq!(scan.ranges.len(), 1);
1356        assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
1357
1358        let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
1359        assert_eq!(closed.unterminated, None);
1360    }
1361
1362    #[test]
1363    fn test_unterminated_html_comment_outside_hidden_ranges() {
1364        // An opener the caller can see is hidden resumes the search at the next
1365        // one rather than clearing the finding.
1366        let content = "%% note <!-- marker %%\n\n<!-- real\n";
1367        let hidden = [(0, content.find("\n\n").unwrap())];
1368        let reported = scan_html_comments(content, &[], &[], 0).unterminated;
1369        assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
1370        assert_eq!(
1371            unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
1372            Some(content.rfind("<!--").unwrap())
1373        );
1374
1375        // Nothing left outside the hidden range: the finding goes away.
1376        let only_hidden = "%% note <!-- marker %%\n";
1377        let hidden_all = [(0, only_hidden.len())];
1378        let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
1379        assert!(reported.is_some(), "the scan sees the hidden opener");
1380        assert_eq!(
1381            unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
1382            None
1383        );
1384
1385        // An opener outside every hidden range is returned untouched.
1386        let visible = "<!-- open\n\n%% a note %%\n";
1387        let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
1388        assert_eq!(
1389            unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
1390            Some(0)
1391        );
1392    }
1393
1394    #[test]
1395    fn test_unterminated_comment_range_follows_the_block() {
1396        // An opener that starts a block hides the rest of that block.
1397        let blocks = [(0, 40)];
1398        let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
1399        assert_eq!((range.start, range.end), (0, 40));
1400
1401        // An opener in a block a tag already started hides only from itself on,
1402        // so the tag above it stays visible.
1403        let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
1404        assert_eq!((range.start, range.end), (7, 40));
1405
1406        // An opener in no block at all is inline, and hides nothing.
1407        assert!(unterminated_comment_range(60, &blocks).is_none());
1408        assert!(unterminated_comment_range(0, &[]).is_none());
1409
1410        // The end of a block is exclusive: an opener there belongs to whatever
1411        // follows, not to the block that just closed.
1412        assert!(unterminated_comment_range(40, &blocks).is_none());
1413    }
1414
1415    #[test]
1416    fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
1417        // CommonMark ends a comment at the first `-->`, and the opener's own
1418        // dashes can supply its first two, so these are whole comments and the
1419        // text after them is ordinary markdown.
1420        for (content, expected_end) in [
1421            ("<!--> text", 5),
1422            ("<!---> text", 6),
1423            ("<!----> text", 7),
1424            ("<!-- x --> text", 10),
1425        ] {
1426            let ranges = compute_html_comment_ranges(content);
1427            assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
1428            assert_eq!(ranges[0].start, 0);
1429            assert_eq!(
1430                ranges[0].end, expected_end,
1431                "{content:?} ends its comment at the first -->"
1432            );
1433        }
1434    }
1435
1436    #[test]
1437    fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
1438        // The degenerate comment must not swallow the text up to the next `-->`,
1439        // which would hide a whole document from every rule.
1440        let content = "<!--> visible <!-- hidden --> visible";
1441        let ranges = compute_html_comment_ranges(content);
1442        assert_eq!(ranges.len(), 2);
1443        assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
1444        assert_eq!(
1445            (ranges[1].start, ranges[1].end),
1446            (content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
1447        );
1448    }
1449
1450    #[test]
1451    fn test_is_line_entirely_in_html_comment() {
1452        // Test 1: Multi-line comment with content after closing
1453        let content = "<!--\ncomment\n--> Content after comment";
1454        let ranges = compute_html_comment_ranges(content);
1455        // Line 0: "<!--" (bytes 0-4) - entirely in comment
1456        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
1457        // Line 1: "comment" (bytes 5-12) - entirely in comment
1458        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
1459        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
1460        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
1461
1462        // Test 2: Single-line comment with content after
1463        let content2 = "<!-- comment --> Not a comment";
1464        let ranges2 = compute_html_comment_ranges(content2);
1465        // The entire line is NOT entirely in the comment
1466        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
1467
1468        // Test 3: Single-line comment alone
1469        let content3 = "<!-- comment -->";
1470        let ranges3 = compute_html_comment_ranges(content3);
1471        // The entire line IS entirely in the comment
1472        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
1473
1474        // Test 4: Content before comment
1475        let content4 = "Text before <!-- comment -->";
1476        let ranges4 = compute_html_comment_ranges(content4);
1477        // Line start is NOT in the comment range
1478        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
1479    }
1480
1481    #[test]
1482    fn test_is_line_entirely_in_html_comment_indented() {
1483        // An indented single-line comment: callers pass the trimmed content bounds
1484        // (start at the `<!--`, end after the `-->`), so it is recognised as being
1485        // entirely inside the comment even though the line starts with whitespace.
1486        let content = "    <!-- comment -->";
1487        let ranges = compute_html_comment_ranges(content);
1488        let content_start = content.find("<!--").unwrap();
1489        let content_end = content.trim_end().len();
1490        assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
1491        // Passing the raw column-0 line start would miss it (regression guard for #755).
1492        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
1493    }
1494
1495    #[test]
1496    fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
1497        // Trailing whitespace after the closer must not push content_end past the range.
1498        let content = "<!-- comment -->   ";
1499        let ranges = compute_html_comment_ranges(content);
1500        let content_end = content.trim_end().len();
1501        assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
1502        // With the raw line length (incl. trailing spaces) it would fall outside the range.
1503        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
1504    }
1505
1506    #[test]
1507    fn test_math_block_detection() {
1508        let content = "Text\n$$\nmath content\n$$\nmore text";
1509        assert!(is_in_math_block(content, 8)); // On opening $$
1510        assert!(is_in_math_block(content, 15)); // Inside math block
1511        assert!(!is_in_math_block(content, 0)); // Before math block
1512        assert!(!is_in_math_block(content, 30)); // After math block
1513    }
1514
1515    #[test]
1516    fn test_stray_double_dollar_in_prose_is_not_math() {
1517        // Two `$$` tokens inside a prose line must NOT pair into a math block:
1518        // a multi-line block only opens on a `$$` that begins its line. This
1519        // keeps the byte-level result consistent with the line-level map.
1520        let content = "Note: $$ is used for display math and $$ closes it";
1521        let between = content.find("is used").unwrap();
1522        assert!(
1523            !is_in_math_block(content, between),
1524            "stray paired `$$` in prose must not be treated as a math block"
1525        );
1526        assert!(math_block_ranges(content).is_empty());
1527    }
1528
1529    #[test]
1530    fn test_blockquoted_double_dollar_opens_block() {
1531        // A `$$` opener is still recognized behind a blockquote prefix.
1532        let content = "> $$\n> x = y\n> $$\n";
1533        let inside = content.find("x = y").unwrap();
1534        assert!(is_in_math_block(content, inside), "blockquoted math interior");
1535    }
1536
1537    #[test]
1538    fn test_self_contained_single_line_block_leaves_trailing_prose() {
1539        // `$$ a $$` at line start is math; prose after the closing `$$` is not.
1540        let content = "$$ a $$ and __not math__\n";
1541        let in_math = content.find('a').unwrap();
1542        assert!(is_in_math_block(content, in_math), "single-line math interior");
1543        let after = content.find("not math").unwrap();
1544        assert!(!is_in_math_block(content, after), "trailing prose is lintable");
1545    }
1546
1547    #[test]
1548    fn test_math_block_closes_with_content_before_fence() {
1549        // A display-math block whose closing `$$` shares its line with
1550        // content (e.g. `\end{aligned}$$`) must still close the block.
1551        // Content after the block is prose, not math.
1552        let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
1553
1554        let inside = content.find("x = y").unwrap();
1555        assert!(is_in_math_block(content, inside), "interior must be math");
1556
1557        let after = content.find("after").unwrap();
1558        assert!(
1559            !is_in_math_block(content, after),
1560            "content after a content-sharing closing fence must NOT be math"
1561        );
1562    }
1563
1564    #[test]
1565    fn test_inline_math_detection() {
1566        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
1567        assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
1568        // The mid-line `$$a^2 + b^2$$` is display syntax, not a line-start
1569        // block, so it is a literal under the shared math model - neither the
1570        // inline path nor `math_block_ranges` treats it as math.
1571        assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
1572        assert!(
1573            !is_in_math_block(content, 20),
1574            "mid-line $$...$$ is not a line-start display block"
1575        );
1576        assert!(!is_in_inline_math(content, 0), "before any math");
1577        assert!(!is_in_inline_math(content, 35), "after the spans");
1578    }
1579
1580    #[test]
1581    fn test_table_line_detection() {
1582        assert!(is_table_line("| Header | Column |"));
1583        assert!(is_table_line("|--------|--------|"));
1584        assert!(is_table_line("| Cell 1 | Cell 2 |"));
1585        assert!(!is_table_line("Regular text"));
1586        assert!(!is_table_line("Just a pipe | here"));
1587    }
1588
1589    #[test]
1590    fn test_is_in_icon_shortcode() {
1591        let line = "Click :material-check: to confirm";
1592        // Position 0-5 is "Click"
1593        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
1594        // Position 6-22 is ":material-check:"
1595        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
1596        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
1597        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
1598        // Position 22+ is " to confirm"
1599        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
1600    }
1601
1602    #[test]
1603    fn test_is_in_pymdown_markup() {
1604        // Test Keys notation
1605        let line = "Press ++ctrl+c++ to copy";
1606        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
1607        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
1608        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
1609        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
1610
1611        // Test Mark notation
1612        let line2 = "This is ==highlighted== text";
1613        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
1614        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
1615        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
1616        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
1617
1618        // Should not match for Standard flavor
1619        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
1620    }
1621
1622    #[test]
1623    fn test_is_in_mkdocs_markup() {
1624        // Should combine both icon and pymdown
1625        let line = ":material-check: and ++ctrl++";
1626        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
1627        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
1628        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
1629    }
1630
1631    // ==================== Obsidian highlight tests ====================
1632
1633    #[test]
1634    fn test_obsidian_highlight_basic() {
1635        // Obsidian flavor should recognize ==highlight== syntax
1636        let line = "This is ==highlighted== text";
1637        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
1638        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
1639        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
1640        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
1641        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
1642        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
1643    }
1644
1645    #[test]
1646    fn test_obsidian_highlight_multiple() {
1647        // Multiple highlights on one line
1648        let line = "Both ==one== and ==two== here";
1649        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
1650        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
1651        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
1652        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
1653    }
1654
1655    #[test]
1656    fn test_obsidian_highlight_not_standard_flavor() {
1657        // Standard flavor should NOT recognize ==highlight== as special
1658        let line = "This is ==highlighted== text";
1659        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
1660        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
1661    }
1662
1663    #[test]
1664    fn test_obsidian_highlight_with_spaces_inside() {
1665        // Highlights can have spaces inside the content
1666        let line = "This is ==text with spaces== here";
1667        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
1668        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
1669        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
1670    }
1671
1672    #[test]
1673    fn test_obsidian_does_not_support_keys_notation() {
1674        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
1675        let line = "Press ++ctrl+c++ to copy";
1676        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1677        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1678    }
1679
1680    #[test]
1681    fn test_obsidian_mkdocs_markup_function() {
1682        // is_in_mkdocs_markup should also work for Obsidian highlights
1683        let line = "This is ==highlighted== text";
1684        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
1685        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
1686    }
1687
1688    #[test]
1689    fn test_obsidian_highlight_edge_cases() {
1690        // Empty highlight (====) should not match
1691        let line = "Test ==== here";
1692        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
1693        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1694
1695        // Single character highlight
1696        let line2 = "Test ==a== here";
1697        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
1698        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
1699        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
1700
1701        // Triple equals (===) should not create highlight
1702        let line3 = "a === b";
1703        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
1704    }
1705
1706    #[test]
1707    fn test_obsidian_highlight_unclosed() {
1708        // Unclosed highlight should not match
1709        let line = "This ==starts but never ends";
1710        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
1711        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1712    }
1713
1714    #[test]
1715    fn test_inline_html_code_basic() {
1716        let line = "The formula is <code>a * b * c</code> in math.";
1717        // Position inside <code> content
1718        assert!(is_in_inline_html_code(line, 21)); // 'a'
1719        assert!(is_in_inline_html_code(line, 25)); // '*'
1720        // Position outside <code> content
1721        assert!(!is_in_inline_html_code(line, 0)); // 'T'
1722        assert!(!is_in_inline_html_code(line, 40)); // after </code>
1723    }
1724
1725    #[test]
1726    fn test_inline_html_code_multiple_tags() {
1727        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
1728        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
1729        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
1730        assert!(!is_in_inline_html_code(line, 16)); // '+'
1731    }
1732
1733    #[test]
1734    fn test_inline_html_code_with_attributes() {
1735        let line = r#"<code class="lang">x * y</code>"#;
1736        assert!(is_in_inline_html_code(line, 19)); // 'x'
1737        assert!(is_in_inline_html_code(line, 23)); // '*'
1738        assert!(!is_in_inline_html_code(line, 0)); // before tag
1739    }
1740
1741    #[test]
1742    fn test_inline_html_code_case_insensitive() {
1743        let line = "<CODE>a * b</CODE>";
1744        assert!(is_in_inline_html_code(line, 6)); // 'a'
1745        assert!(is_in_inline_html_code(line, 8)); // '*'
1746    }
1747
1748    #[test]
1749    fn test_inline_html_code_var_and_pre() {
1750        let line = "<var>x * y</var> and <pre>a * b</pre>";
1751        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
1752        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
1753        assert!(!is_in_inline_html_code(line, 17)); // 'and'
1754    }
1755
1756    #[test]
1757    fn test_inline_html_code_unclosed() {
1758        // Unclosed tag should not match
1759        let line = "<code>a * b without closing";
1760        assert!(!is_in_inline_html_code(line, 6));
1761    }
1762
1763    #[test]
1764    fn test_inline_html_code_no_substring_match() {
1765        // <variable> should NOT be treated as <var>
1766        let line = "<variable>a * b</variable>";
1767        assert!(!is_in_inline_html_code(line, 11));
1768
1769        // <keyboard> should NOT be treated as <kbd>
1770        let line2 = "<keyboard>x * y</keyboard>";
1771        assert!(!is_in_inline_html_code(line2, 11));
1772    }
1773}