Skip to main content

rumdl_lib/utils/
skip_context.rs

1//! Utilities for determining if a position in markdown should be skipped from processing
2//!
3//! This module provides centralized context detection for various markdown constructs
4//! that should typically be skipped when processing rules.
5
6use crate::config::MarkdownFlavor;
7use crate::lint_context::{HtmlTag, LintContext};
8use crate::utils::mkdocs_admonitions;
9use crate::utils::mkdocs_critic;
10use crate::utils::mkdocs_extensions;
11use crate::utils::mkdocs_footnotes;
12use crate::utils::mkdocs_icons;
13use crate::utils::mkdocs_snippets;
14use crate::utils::mkdocs_tabs;
15use regex::Regex;
16use std::sync::LazyLock;
17
18/// Enhanced inline math pattern that handles both single $ and double $$ delimiters.
19/// Matches:
20/// - Display math: $$...$$ (zero or more non-$ characters)
21/// - Inline math: $...$ (zero or more non-$ non-newline characters)
22///
23/// The display math pattern is tried first to correctly handle $$content$$.
24/// Critically, both patterns allow ZERO characters between delimiters,
25/// so empty math like $$ or $ $ is consumed and won't pair with other $ signs.
26static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
27
28/// Range representing a span of bytes (start inclusive, end exclusive)
29#[derive(Debug, Clone, Copy)]
30pub struct ByteRange {
31    pub start: usize,
32    pub end: usize,
33}
34
35/// What a scan of the content found for HTML comments.
36#[derive(Debug, Default)]
37pub struct HtmlCommentScan {
38    /// Byte ranges of the complete comments, in document order.
39    pub ranges: Vec<ByteRange>,
40    /// Byte offset of a `<!--` that no `-->` closes. At most one is reported:
41    /// once the document has no closer left, no later opener can close either.
42    pub unterminated: Option<usize>,
43}
44
45/// Pre-compute all HTML comment ranges in the content.
46/// Returns a sorted vector of byte ranges for efficient lookup.
47pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
48    compute_html_comment_ranges_filtered(content, &[], &[])
49}
50
51/// Pre-compute HTML comment ranges, treating `<!--`/`-->` inside inline code
52/// spans or fenced/indented code blocks as literal text rather than comment
53/// delimiters.
54///
55/// Inside code (a backtick code span or a code block), `<!--` and `-->` are
56/// literal. A naive `<!--[\s\S]*?-->` scan would pair a `<!--` in one code
57/// region with a `-->` in a later region, spuriously marking every line between
58/// them as "inside an HTML comment". That made content invisible to rules that
59/// skip comment lines: MD032's blank-line check produced false positives and a
60/// non-converging `--fix` loop, and MD034 silently dropped a real bare URL that
61/// fell between a code-block `<!--` and a later `-->`.
62///
63/// To stay correct even when a literal delimiter precedes a genuine comment
64/// (`` `<!--` <!-- real --> ``), this scans for the next `<!--` opener that is
65/// not in code, then the next `-->` closer that is not in code - it does not
66/// filter completed regex matches. `code_span_ranges` and `code_block_ranges`
67/// are the parser's half-open `[start, end)` byte ranges (`ParseResult`). An
68/// opener pairs with the first following closer, and an opener with no closer
69/// yields no range. A closer may overlap the opener it closes, so `<!-->` and
70/// `<!--->` are whole comments, matching CommonMark.
71pub fn compute_html_comment_ranges_filtered(
72    content: &str,
73    code_span_ranges: &[(usize, usize)],
74    code_block_ranges: &[(usize, usize)],
75) -> Vec<ByteRange> {
76    scan_html_comments(content, code_span_ranges, code_block_ranges, 0).ranges
77}
78
79/// Scan for HTML comments, keeping the position of an opener that never closes.
80///
81/// Same scan as [`compute_html_comment_ranges_filtered`], which discards the
82/// unterminated opener. MD086 reports it, so it is carried out of the scan
83/// rather than re-derived by a second, possibly disagreeing pass.
84///
85/// `scan_from` is the byte offset the document's body starts at, so front
86/// matter can be excluded. A `<!--` in a YAML or TOML value is data, and
87/// pairing it with a `-->` in the body would mark everything in between as a
88/// comment, hiding it from every rule.
89pub fn scan_html_comments(
90    content: &str,
91    code_span_ranges: &[(usize, usize)],
92    code_block_ranges: &[(usize, usize)],
93    scan_from: usize,
94) -> HtmlCommentScan {
95    let in_code = |pos: usize| {
96        code_span_ranges.iter().any(|&(start, end)| pos >= start && pos < end)
97            || code_block_ranges.iter().any(|&(start, end)| pos >= start && pos < end)
98    };
99
100    let mut ranges = Vec::new();
101    let mut search_from = scan_from.min(content.len());
102    while let Some(rel) = content[search_from..].find("<!--") {
103        let open = search_from + rel;
104        if in_code(open) {
105            // Literal `<!--` inside code: not a comment opener.
106            search_from = open + "<!--".len();
107            continue;
108        }
109        // Find the next `-->` that is not itself inside code. The search starts
110        // two bytes into the opener, because the opener's own trailing dashes can
111        // serve as the closer's leading dashes: `<!-->` and `<!--->` are complete
112        // comments, and a line holding one ends an HTML block.
113        let mut close_from = open + 2;
114        let end = loop {
115            let Some(crel) = content[close_from..].find("-->") else {
116                break None;
117            };
118            let close = close_from + crel;
119            if in_code(close) {
120                close_from = close + "-->".len();
121                continue;
122            }
123            break Some(close + "-->".len());
124        };
125        match end {
126            Some(end) => {
127                ranges.push(ByteRange { start: open, end });
128                search_from = end;
129            }
130            // Unterminated comment (no closer anywhere): it covers no range,
131            // and no opener after it can close either, so the scan is done.
132            None => {
133                return HtmlCommentScan {
134                    ranges,
135                    unterminated: Some(open),
136                };
137            }
138        }
139    }
140    HtmlCommentScan {
141        ranges,
142        unterminated: None,
143    }
144}
145
146/// Re-resolve an unterminated `<!--` that an Obsidian comment hides.
147///
148/// [`scan_html_comments`] has to run before Obsidian comments are known,
149/// because detecting those needs the HTML comment ranges. A `<!--` inside a
150/// `%%` comment is hidden text rather than an opener, so when the reported one
151/// falls in a comment the scan is repeated with those comments among the ranges
152/// whose delimiters are literal. That resumes the search at the next opener
153/// instead of suppressing the finding, which would hide a real unclosed comment
154/// below the hidden one.
155///
156/// Only the opener is taken from the second scan. Skipping strictly more of the
157/// content cannot turn up a closer the first scan missed, so the complete
158/// comments it found still stand.
159pub fn unterminated_html_comment_outside(
160    reported: Option<usize>,
161    hidden_ranges: &[(usize, usize)],
162    content: &str,
163    code_span_ranges: &[(usize, usize)],
164    code_block_ranges: &[(usize, usize)],
165    scan_from: usize,
166) -> Option<usize> {
167    let offset = reported?;
168    if !hidden_ranges
169        .iter()
170        .any(|&(start, end)| offset >= start && offset < end)
171    {
172        return reported;
173    }
174    let mut literal_ranges = code_block_ranges.to_vec();
175    literal_ranges.extend_from_slice(hidden_ranges);
176    scan_html_comments(content, code_span_ranges, &literal_ranges, scan_from).unterminated
177}
178
179/// The range an unclosed `<!--` hides, if it hides anything.
180///
181/// Whether an unclosed opener swallows the text after it is a block-structure
182/// question, so it is answered by the parser rather than by inspecting the line.
183/// At the start of a line `<!--` opens an HTML block and everything to the end
184/// of that block is comment text; mid-paragraph CommonMark has no comment to
185/// open, so the marker renders literally and hides nothing.
186///
187/// The block is the unit because it is not always the rest of the document. An
188/// opener inside a blockquote or a list item ends with its container, leaving
189/// the content after it visible, and one indented four spaces is code and opens
190/// no block at all. `html_blocks` are the parser's ranges, so each of those
191/// falls out without a separate rule here.
192///
193/// The range starts at the opener, not at the block, because a block may have
194/// begun earlier with a tag: in `<div>` followed by `<!--`, only the text from
195/// the marker on is inside the comment.
196pub fn unterminated_comment_range(opener: usize, html_blocks: &[(usize, usize)]) -> Option<ByteRange> {
197    html_blocks
198        .iter()
199        .find(|&&(start, end)| opener >= start && opener < end)
200        .map(|&(_, end)| ByteRange { start: opener, end })
201}
202
203/// Check if a byte position is within any of the pre-computed HTML comment ranges
204/// Uses binary search for O(log n) complexity
205pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
206    // Binary search to find a range that might contain byte_pos
207    ranges
208        .binary_search_by(|range| {
209            if byte_pos < range.start {
210                std::cmp::Ordering::Greater
211            } else if byte_pos >= range.end {
212                std::cmp::Ordering::Less
213            } else {
214                std::cmp::Ordering::Equal
215            }
216        })
217        .is_ok()
218}
219
220/// Check if a line's content is ENTIRELY within a single HTML comment.
221///
222/// Callers must pass the byte range of the line's *content* (leading and
223/// trailing whitespace trimmed off), not the raw line bounds: an indented
224/// comment begins at the `<!--` after the indent, so passing the column-0
225/// line start would place `content_start` before the comment range and the
226/// line would never be recognised as being inside the comment.
227///
228/// Returns true only if both `content_start` AND `content_end` fall within the
229/// same comment range.
230pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], content_start: usize, content_end: usize) -> bool {
231    for range in ranges {
232        // If the content start is within this range, check if the content end is also within it
233        if content_start >= range.start && content_start < range.end {
234            return content_end <= range.end;
235        }
236    }
237    false
238}
239
240/// Check if a byte position is within a JSX expression (MDX: {expression})
241#[inline]
242pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
243    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
244}
245
246/// Check if a byte position is within an MDX comment ({/* ... */})
247#[inline]
248pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
249    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
250}
251
252/// Check if a line should be skipped due to MkDocs snippet syntax
253pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
254    flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
255}
256
257/// Check if a line is a MkDocs admonition marker
258pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
259    flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
260}
261
262/// Check if a line is a MkDocs footnote definition
263pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
264    flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
265}
266
267/// Check if a line is a MkDocs tab marker
268pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
269    flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
270}
271
272/// Check if a line contains MkDocs Critic Markup
273pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
274    flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
275}
276
277/// Check if a byte position is within an HTML tag
278pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
279    for html_tag in ctx.html_tags().iter() {
280        if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
281            return true;
282        }
283    }
284    false
285}
286
287/// Check if a byte position is within a math context.
288///
289/// `$$...$$` display math is recognized only when it begins its line, via
290/// [`math_block_ranges`]; a mid-line or stray-prose `$$...$$` is a literal,
291/// not math. Single-`$` inline spans are recognized anywhere. This keeps
292/// every math-aware rule agreeing on what is math.
293pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
294    // Use the cached ranges on the context; recomputing math_byte_ranges(content)
295    // on every call made callers that invoke this per element O(elements * content).
296    ctx.math_byte_ranges()
297        .iter()
298        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
299}
300
301/// Paired `$$ ... $$` display-math byte ranges, half-open `[start, end)`.
302///
303/// A block only *opens* on a `$$` that begins its line, ignoring leading
304/// whitespace and blockquote markers (`>`); a stray `$$` mid-prose is a
305/// literal, not a block opener. This keeps the byte-level result consistent
306/// with the line-level [`compute_math_block_line_map`] guard. Once open, the
307/// block *closes* on the next `$$` anywhere - even when that closing `$$`
308/// shares its line with LaTeX content (`\end{cases}$$`) or trailing Markdown
309/// prose. An opener with no matching closer is dropped, not treated as an
310/// unterminated block that swallows the rest of the document.
311pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
312    let bytes = content.as_bytes();
313    let mut ranges = Vec::new();
314    let mut open: Option<usize> = None;
315    let mut line_start = 0usize;
316    let mut i = 0;
317    while i < bytes.len() {
318        match bytes[i] {
319            b'\n' => {
320                line_start = i + 1;
321                i += 1;
322            }
323            b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
324                match open {
325                    None => {
326                        // Open only when this `$$` is the first non-blank,
327                        // non-blockquote content on its line.
328                        let starts_line = bytes[line_start..i]
329                            .iter()
330                            .all(|&b| b == b' ' || b == b'\t' || b == b'>');
331                        if starts_line {
332                            open = Some(i);
333                        }
334                    }
335                    Some(start) => {
336                        ranges.push((start, i + 2));
337                        open = None;
338                    }
339                }
340                i += 2;
341            }
342            _ => i += 1,
343        }
344    }
345    ranges
346}
347
348/// Check if a byte position is within a `$$ ... $$` display-math block.
349///
350/// A block opens only on a `$$` that begins its line (see [`math_block_ranges`])
351/// and closes on the next `$$` anywhere, so the closing fence ends the block
352/// even when it shares its line with LaTeX content (e.g. `\end{cases}$$`) or
353/// trailing Markdown prose; bytes after the closing `$$` are not math.
354pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
355    math_block_ranges(content)
356        .iter()
357        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
358}
359
360/// Check if a byte position is within inline math (`$...$`).
361///
362/// Only single-`$` spans count here. A `$$...$$` token is display-math
363/// syntax, and whether it is actually math depends solely on whether it
364/// begins its line - that decision belongs to [`math_block_ranges`]. The
365/// regex still consumes `$$...$$` tokens so a single-`$` span cannot straddle
366/// them, but a mid-line `$$...$$` is a literal here, not inline math, keeping
367/// this function consistent with the line-start-gated block model.
368pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
369    for m in INLINE_MATH_REGEX.find_iter(content) {
370        if content[m.start()..m.end()].starts_with("$$") {
371            continue;
372        }
373        if m.start() <= byte_pos && byte_pos < m.end() {
374            return true;
375        }
376    }
377    false
378}
379
380/// All math byte ranges in `content`: line-start `$$...$$` display blocks
381/// plus single-`$` inline spans. Ranges are half-open `[start, end)` and may
382/// be unordered relative to each other; membership is by `any`-containment.
383///
384/// Precompute this once when classifying many positions in one document
385/// (e.g. every emphasis span). [`is_in_math_context`] is the single-shot
386/// equivalent and is defined in terms of the same two sources.
387pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
388    let mut ranges = math_block_ranges(content);
389    for m in INLINE_MATH_REGEX.find_iter(content) {
390        if content[m.start()..m.end()].starts_with("$$") {
391            continue;
392        }
393        ranges.push((m.start(), m.end()));
394    }
395    ranges
396}
397
398/// Check if a line contains table syntax
399pub fn is_table_line(line: &str) -> bool {
400    let trimmed = line.trim();
401
402    // Check for table separator line
403    if trimmed
404        .chars()
405        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
406        && trimmed.contains('|')
407        && trimmed.contains('-')
408    {
409        return true;
410    }
411
412    // Check for table content line (starts and/or ends with |)
413    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
414        return true;
415    }
416
417    false
418}
419
420/// Check if a byte position is within an MkDocs icon shortcode
421/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
422pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
423    // Only skip for MkDocs flavor, but check pattern for all flavors
424    // since emoji shortcodes are universal
425    mkdocs_icons::is_in_any_shortcode(line, position)
426}
427
428/// Check if a byte position is within PyMdown extension markup
429/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
430///
431/// For MkDocs flavor: supports all PyMdown extensions
432/// For Obsidian flavor: only supports Mark (==highlight==) syntax
433pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
434    match flavor {
435        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
436        MarkdownFlavor::Obsidian => {
437            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
438            mkdocs_extensions::is_in_mark(line, position)
439        }
440        _ => false,
441    }
442}
443
444/// Check whether a position on a line falls inside an inline HTML code-like element.
445///
446/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
447/// These are inline elements whose content should not be interpreted as markdown emphasis.
448pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
449    // Tags whose content should not be parsed as markdown
450    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
451
452    let bytes = line.as_bytes();
453
454    for tag in TAGS {
455        let open_bytes = format!("<{tag}").into_bytes();
456        let close_pattern = format!("</{tag}>").into_bytes();
457
458        let mut search_from = 0;
459        while search_from + open_bytes.len() <= bytes.len() {
460            // Find opening tag (case-insensitive byte search)
461            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
462                break;
463            };
464
465            let after_tag = open_abs + open_bytes.len();
466
467            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
468            if after_tag < bytes.len() {
469                let next = bytes[after_tag];
470                if next != b'>' && next != b' ' && next != b'\t' {
471                    search_from = after_tag;
472                    continue;
473                }
474            }
475
476            // Find the end of the opening tag
477            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
478                break;
479            };
480            let content_start = after_tag + tag_close + 1;
481
482            // Find the closing tag (case-insensitive)
483            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
484                break;
485            };
486            let content_end = close_start;
487
488            if position >= content_start && position < content_end {
489                return true;
490            }
491
492            search_from = close_start + close_pattern.len();
493        }
494    }
495    false
496}
497
498/// Case-insensitive byte search within a slice, starting at `from`.
499fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
500    if needle.is_empty() || from + needle.len() > haystack.len() {
501        return None;
502    }
503    for i in from..=haystack.len() - needle.len() {
504        if haystack[i..i + needle.len()]
505            .iter()
506            .zip(needle.iter())
507            .all(|(h, n)| h.eq_ignore_ascii_case(n))
508        {
509            return Some(i);
510        }
511    }
512    None
513}
514
515/// Check if a byte position is within flavor-specific markup
516/// For MkDocs: icon shortcodes and PyMdown extensions
517/// For Obsidian: highlight syntax (==text==)
518pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
519    if is_in_icon_shortcode(line, position, flavor) {
520        return true;
521    }
522    if is_in_pymdown_markup(line, position, flavor) {
523        return true;
524    }
525    false
526}
527
528/// Check if a byte position within a line is inside a backtick-delimited code span.
529///
530/// This is a line-level fallback for cases where pulldown-cmark's code span detection
531/// misses spans due to table parsing interference (e.g., pipes inside code spans
532/// in table rows cause pulldown-cmark to misidentify cell boundaries).
533fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
534    let bytes = line.as_bytes();
535    let mut i = 0;
536
537    while i < bytes.len() {
538        if bytes[i] == b'`' {
539            let open_start = i;
540            let mut backtick_count = 0;
541            while i < bytes.len() && bytes[i] == b'`' {
542                backtick_count += 1;
543                i += 1;
544            }
545
546            // Search for matching closing backticks
547            let mut j = i;
548            while j < bytes.len() {
549                if bytes[j] == b'`' {
550                    let mut close_count = 0;
551                    while j < bytes.len() && bytes[j] == b'`' {
552                        close_count += 1;
553                        j += 1;
554                    }
555                    if close_count == backtick_count {
556                        // Found matching pair: code span covers open_start..j
557                        if byte_pos >= open_start && byte_pos < j {
558                            return true;
559                        }
560                        i = j;
561                        break;
562                    }
563                } else {
564                    j += 1;
565                }
566            }
567
568            if j >= bytes.len() {
569                // No matching close found, remaining text is not a code span
570                break;
571            }
572        } else {
573            i += 1;
574        }
575    }
576
577    false
578}
579
580/// Check if a byte position is within an HTML tag. O(log n) via binary search.
581fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
582    let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
583    idx > 0 && byte_pos < html_tags[idx - 1].byte_end
584}
585
586/// Check if a byte position is within HTML code content (`<code>...</code>`).
587/// Uses pre-computed code ranges for O(log n) lookup via binary search.
588fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
589    let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
590    idx > 0 && byte_pos < code_ranges[idx - 1].1
591}
592
593/// Pre-compute ranges covered by `<code>...</code>` HTML tags.
594/// Returns sorted Vec of (start, end) byte ranges.
595pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
596    let mut ranges = Vec::new();
597    let mut open_code_end: Option<usize> = None;
598
599    for tag in html_tags {
600        if tag.tag_name == "code" {
601            if tag.is_self_closing {
602                continue;
603            } else if !tag.is_closing {
604                open_code_end = Some(tag.byte_end);
605            } else if tag.is_closing {
606                if let Some(start) = open_code_end {
607                    ranges.push((start, tag.byte_offset));
608                }
609                open_code_end = None;
610            }
611        }
612    }
613    // Handle unclosed <code> tag
614    if let Some(start) = open_code_end {
615        ranges.push((start, usize::MAX));
616    }
617    ranges
618}
619
620/// Determine whether an emphasis or strong span starting at `span_start` should be
621/// skipped because it falls inside a non-prose context: code blocks/spans, inline
622/// code, links, HTML tags or `<code>` content, MkDocs/PyMdown markup, math, JSX
623/// expressions, MDX comments, front matter, or mkdocstrings blocks.
624///
625/// `html_tags` and `html_code_ranges` are passed in so callers iterating many spans
626/// can compute them once via [`compute_html_code_ranges`].
627pub(crate) fn should_skip_emphasis_span(
628    ctx: &LintContext,
629    html_tags: &[HtmlTag],
630    html_code_ranges: &[(usize, usize)],
631    span_start: usize,
632) -> bool {
633    let lines = ctx.raw_lines();
634    let (line_num, col) = ctx.offset_to_line_col(span_start);
635
636    // Skip matches in front matter or mkdocstrings blocks
637    if ctx
638        .line_info(line_num)
639        .is_some_and(|info| info.in_front_matter || info.in_mkdocstrings)
640    {
641        return true;
642    }
643
644    // Check MkDocs markup
645    let in_mkdocs_markup = lines
646        .get(line_num.saturating_sub(1))
647        .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
648
649    // Line-level inline code fallback for cases pulldown-cmark misses
650    let in_inline_code = lines
651        .get(line_num.saturating_sub(1))
652        .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
653
654    ctx.is_in_code_block_or_span(span_start)
655        || in_inline_code
656        || ctx.is_in_link(span_start)
657        || is_byte_in_html_tag(html_tags, span_start)
658        || is_byte_in_html_code_content(html_code_ranges, span_start)
659        || in_mkdocs_markup
660        || is_in_math_context(ctx, span_start)
661        || is_in_jsx_expression(ctx, span_start)
662        || is_in_mdx_comment(ctx, span_start)
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn test_html_comment_detection() {
671        let content = "Text <!-- comment --> more text";
672        let ranges = compute_html_comment_ranges(content);
673        assert!(is_in_html_comment_ranges(&ranges, 10)); // Inside comment
674        assert!(!is_in_html_comment_ranges(&ranges, 0)); // Before comment
675        assert!(!is_in_html_comment_ranges(&ranges, 25)); // After comment
676    }
677
678    #[test]
679    fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
680        // `<!--` and `-->` inside inline code spans on different lines must not
681        // pair into a multi-line HTML comment (issue #679).
682        let content = "a `<!--` b\n\nc `-->` d";
683        let open = content.find("<!--").unwrap();
684        let close = content.find("-->").unwrap();
685        // Code spans covering the two backtick-delimited tokens.
686        let code_spans = [
687            (content.find('`').unwrap(), open + "<!--".len() + 1),
688            (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
689        ];
690
691        // Without code-span awareness the pattern spans open..close (the bug).
692        assert!(
693            !compute_html_comment_ranges(content).is_empty(),
694            "sanity: raw pattern matches across the code spans"
695        );
696        // With code-span awareness the spurious match is dropped.
697        assert!(
698            compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
699            "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
700        );
701    }
702
703    #[test]
704    fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
705        // A `<!--` inside a code block must not pair with a later `-->` outside it
706        // (the code-block counterpart of the code-span case).
707        let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
708        let block_end = content.find("```\n\n").unwrap() + "```".len();
709        let code_blocks = [(0usize, block_end)];
710        assert!(
711            compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
712            "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
713        );
714        // A real comment whose opener is outside the block is still detected.
715        let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
716        let real_block_end = real.find("```\n\n").unwrap() + "```".len();
717        let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
718        assert_eq!(ranges.len(), 1);
719        assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
720    }
721
722    #[test]
723    fn test_compute_html_comment_ranges_keeps_real_comments() {
724        // A genuine comment whose `<!--` is not inside a code span is still
725        // detected, even when an unrelated code span exists elsewhere.
726        let content = "text `code` <!-- real comment --> more";
727        let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
728        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
729        assert_eq!(ranges.len(), 1, "the real comment must still be detected");
730        let comment_start = content.find("<!--").unwrap();
731        assert_eq!(ranges[0].start, comment_start);
732    }
733
734    #[test]
735    fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
736        // A code span containing `<!--` must not consume a real comment that
737        // follows it: skipping the literal opener, the scan must still discover
738        // the genuine `<!-- ... -->` and mark its content as a comment.
739        let content = "a `<!--` then <!-- real --> end";
740        let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
741        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
742        assert_eq!(
743            ranges.len(),
744            1,
745            "the real comment after a code-span opener must be detected"
746        );
747        let real_open = content.find("<!-- real").unwrap();
748        assert_eq!(
749            ranges[0].start, real_open,
750            "range must start at the real comment, not the code-span opener"
751        );
752        assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
753    }
754
755    #[test]
756    fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
757        // A real comment's closing `-->` that lands inside a code span is literal;
758        // the scan must continue to the next real `-->`.
759        let content = "<!-- open `-->` still open --> done";
760        let first_close = content.find("`-->`").unwrap() + 1;
761        let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
762        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
763        assert_eq!(ranges.len(), 1);
764        assert_eq!(ranges[0].start, 0);
765        let real_close_end = content.find("--> done").unwrap() + "-->".len();
766        assert_eq!(
767            ranges[0].end, real_close_end,
768            "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
769        );
770    }
771
772    #[test]
773    fn test_scan_html_comments_starts_at_the_given_offset() {
774        // The offset is where front matter ends: a delimiter before it is data,
775        // so it neither opens a comment nor closes one.
776        let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
777        let body_start = content.rfind("---\n").unwrap() + "---\n".len();
778        let scan = scan_html_comments(content, &[], &[], body_start);
779        assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
780        assert_eq!(scan.unterminated, None);
781
782        let unscoped = scan_html_comments(content, &[], &[], 0);
783        assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
784    }
785
786    #[test]
787    fn test_scan_html_comments_reports_the_unterminated_opener() {
788        let content = "<!-- closed --> text <!-- open";
789        let scan = scan_html_comments(content, &[], &[], 0);
790        assert_eq!(scan.ranges.len(), 1);
791        assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
792
793        let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
794        assert_eq!(closed.unterminated, None);
795    }
796
797    #[test]
798    fn test_unterminated_html_comment_outside_hidden_ranges() {
799        // An opener the caller can see is hidden resumes the search at the next
800        // one rather than clearing the finding.
801        let content = "%% note <!-- marker %%\n\n<!-- real\n";
802        let hidden = [(0, content.find("\n\n").unwrap())];
803        let reported = scan_html_comments(content, &[], &[], 0).unterminated;
804        assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
805        assert_eq!(
806            unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
807            Some(content.rfind("<!--").unwrap())
808        );
809
810        // Nothing left outside the hidden range: the finding goes away.
811        let only_hidden = "%% note <!-- marker %%\n";
812        let hidden_all = [(0, only_hidden.len())];
813        let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
814        assert!(reported.is_some(), "the scan sees the hidden opener");
815        assert_eq!(
816            unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
817            None
818        );
819
820        // An opener outside every hidden range is returned untouched.
821        let visible = "<!-- open\n\n%% a note %%\n";
822        let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
823        assert_eq!(
824            unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
825            Some(0)
826        );
827    }
828
829    #[test]
830    fn test_unterminated_comment_range_follows_the_block() {
831        // An opener that starts a block hides the rest of that block.
832        let blocks = [(0, 40)];
833        let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
834        assert_eq!((range.start, range.end), (0, 40));
835
836        // An opener in a block a tag already started hides only from itself on,
837        // so the tag above it stays visible.
838        let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
839        assert_eq!((range.start, range.end), (7, 40));
840
841        // An opener in no block at all is inline, and hides nothing.
842        assert!(unterminated_comment_range(60, &blocks).is_none());
843        assert!(unterminated_comment_range(0, &[]).is_none());
844
845        // The end of a block is exclusive: an opener there belongs to whatever
846        // follows, not to the block that just closed.
847        assert!(unterminated_comment_range(40, &blocks).is_none());
848    }
849
850    #[test]
851    fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
852        // CommonMark ends a comment at the first `-->`, and the opener's own
853        // dashes can supply its first two, so these are whole comments and the
854        // text after them is ordinary markdown.
855        for (content, expected_end) in [
856            ("<!--> text", 5),
857            ("<!---> text", 6),
858            ("<!----> text", 7),
859            ("<!-- x --> text", 10),
860        ] {
861            let ranges = compute_html_comment_ranges(content);
862            assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
863            assert_eq!(ranges[0].start, 0);
864            assert_eq!(
865                ranges[0].end, expected_end,
866                "{content:?} ends its comment at the first -->"
867            );
868        }
869    }
870
871    #[test]
872    fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
873        // The degenerate comment must not swallow the text up to the next `-->`,
874        // which would hide a whole document from every rule.
875        let content = "<!--> visible <!-- hidden --> visible";
876        let ranges = compute_html_comment_ranges(content);
877        assert_eq!(ranges.len(), 2);
878        assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
879        assert_eq!(
880            (ranges[1].start, ranges[1].end),
881            (content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
882        );
883    }
884
885    #[test]
886    fn test_is_line_entirely_in_html_comment() {
887        // Test 1: Multi-line comment with content after closing
888        let content = "<!--\ncomment\n--> Content after comment";
889        let ranges = compute_html_comment_ranges(content);
890        // Line 0: "<!--" (bytes 0-4) - entirely in comment
891        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
892        // Line 1: "comment" (bytes 5-12) - entirely in comment
893        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
894        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
895        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
896
897        // Test 2: Single-line comment with content after
898        let content2 = "<!-- comment --> Not a comment";
899        let ranges2 = compute_html_comment_ranges(content2);
900        // The entire line is NOT entirely in the comment
901        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
902
903        // Test 3: Single-line comment alone
904        let content3 = "<!-- comment -->";
905        let ranges3 = compute_html_comment_ranges(content3);
906        // The entire line IS entirely in the comment
907        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
908
909        // Test 4: Content before comment
910        let content4 = "Text before <!-- comment -->";
911        let ranges4 = compute_html_comment_ranges(content4);
912        // Line start is NOT in the comment range
913        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
914    }
915
916    #[test]
917    fn test_is_line_entirely_in_html_comment_indented() {
918        // An indented single-line comment: callers pass the trimmed content bounds
919        // (start at the `<!--`, end after the `-->`), so it is recognised as being
920        // entirely inside the comment even though the line starts with whitespace.
921        let content = "    <!-- comment -->";
922        let ranges = compute_html_comment_ranges(content);
923        let content_start = content.find("<!--").unwrap();
924        let content_end = content.trim_end().len();
925        assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
926        // Passing the raw column-0 line start would miss it (regression guard for #755).
927        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
928    }
929
930    #[test]
931    fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
932        // Trailing whitespace after the closer must not push content_end past the range.
933        let content = "<!-- comment -->   ";
934        let ranges = compute_html_comment_ranges(content);
935        let content_end = content.trim_end().len();
936        assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
937        // With the raw line length (incl. trailing spaces) it would fall outside the range.
938        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
939    }
940
941    #[test]
942    fn test_math_block_detection() {
943        let content = "Text\n$$\nmath content\n$$\nmore text";
944        assert!(is_in_math_block(content, 8)); // On opening $$
945        assert!(is_in_math_block(content, 15)); // Inside math block
946        assert!(!is_in_math_block(content, 0)); // Before math block
947        assert!(!is_in_math_block(content, 30)); // After math block
948    }
949
950    #[test]
951    fn test_stray_double_dollar_in_prose_is_not_math() {
952        // Two `$$` tokens inside a prose line must NOT pair into a math block:
953        // a multi-line block only opens on a `$$` that begins its line. This
954        // keeps the byte-level result consistent with the line-level map.
955        let content = "Note: $$ is used for display math and $$ closes it";
956        let between = content.find("is used").unwrap();
957        assert!(
958            !is_in_math_block(content, between),
959            "stray paired `$$` in prose must not be treated as a math block"
960        );
961        assert!(math_block_ranges(content).is_empty());
962    }
963
964    #[test]
965    fn test_blockquoted_double_dollar_opens_block() {
966        // A `$$` opener is still recognized behind a blockquote prefix.
967        let content = "> $$\n> x = y\n> $$\n";
968        let inside = content.find("x = y").unwrap();
969        assert!(is_in_math_block(content, inside), "blockquoted math interior");
970    }
971
972    #[test]
973    fn test_self_contained_single_line_block_leaves_trailing_prose() {
974        // `$$ a $$` at line start is math; prose after the closing `$$` is not.
975        let content = "$$ a $$ and __not math__\n";
976        let in_math = content.find('a').unwrap();
977        assert!(is_in_math_block(content, in_math), "single-line math interior");
978        let after = content.find("not math").unwrap();
979        assert!(!is_in_math_block(content, after), "trailing prose is lintable");
980    }
981
982    #[test]
983    fn test_math_block_closes_with_content_before_fence() {
984        // A display-math block whose closing `$$` shares its line with
985        // content (e.g. `\end{aligned}$$`) must still close the block.
986        // Content after the block is prose, not math.
987        let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
988
989        let inside = content.find("x = y").unwrap();
990        assert!(is_in_math_block(content, inside), "interior must be math");
991
992        let after = content.find("after").unwrap();
993        assert!(
994            !is_in_math_block(content, after),
995            "content after a content-sharing closing fence must NOT be math"
996        );
997    }
998
999    #[test]
1000    fn test_inline_math_detection() {
1001        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
1002        assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
1003        // The mid-line `$$a^2 + b^2$$` is display syntax, not a line-start
1004        // block, so it is a literal under the shared math model - neither the
1005        // inline path nor `math_block_ranges` treats it as math.
1006        assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
1007        assert!(
1008            !is_in_math_block(content, 20),
1009            "mid-line $$...$$ is not a line-start display block"
1010        );
1011        assert!(!is_in_inline_math(content, 0), "before any math");
1012        assert!(!is_in_inline_math(content, 35), "after the spans");
1013    }
1014
1015    #[test]
1016    fn test_table_line_detection() {
1017        assert!(is_table_line("| Header | Column |"));
1018        assert!(is_table_line("|--------|--------|"));
1019        assert!(is_table_line("| Cell 1 | Cell 2 |"));
1020        assert!(!is_table_line("Regular text"));
1021        assert!(!is_table_line("Just a pipe | here"));
1022    }
1023
1024    #[test]
1025    fn test_is_in_icon_shortcode() {
1026        let line = "Click :material-check: to confirm";
1027        // Position 0-5 is "Click"
1028        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
1029        // Position 6-22 is ":material-check:"
1030        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
1031        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
1032        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
1033        // Position 22+ is " to confirm"
1034        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
1035    }
1036
1037    #[test]
1038    fn test_is_in_pymdown_markup() {
1039        // Test Keys notation
1040        let line = "Press ++ctrl+c++ to copy";
1041        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
1042        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
1043        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
1044        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
1045
1046        // Test Mark notation
1047        let line2 = "This is ==highlighted== text";
1048        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
1049        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
1050        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
1051        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
1052
1053        // Should not match for Standard flavor
1054        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
1055    }
1056
1057    #[test]
1058    fn test_is_in_mkdocs_markup() {
1059        // Should combine both icon and pymdown
1060        let line = ":material-check: and ++ctrl++";
1061        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
1062        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
1063        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
1064    }
1065
1066    // ==================== Obsidian highlight tests ====================
1067
1068    #[test]
1069    fn test_obsidian_highlight_basic() {
1070        // Obsidian flavor should recognize ==highlight== syntax
1071        let line = "This is ==highlighted== text";
1072        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
1073        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
1074        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
1075        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
1076        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
1077        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
1078    }
1079
1080    #[test]
1081    fn test_obsidian_highlight_multiple() {
1082        // Multiple highlights on one line
1083        let line = "Both ==one== and ==two== here";
1084        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
1085        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
1086        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
1087        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
1088    }
1089
1090    #[test]
1091    fn test_obsidian_highlight_not_standard_flavor() {
1092        // Standard flavor should NOT recognize ==highlight== as special
1093        let line = "This is ==highlighted== text";
1094        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
1095        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
1096    }
1097
1098    #[test]
1099    fn test_obsidian_highlight_with_spaces_inside() {
1100        // Highlights can have spaces inside the content
1101        let line = "This is ==text with spaces== here";
1102        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
1103        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
1104        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
1105    }
1106
1107    #[test]
1108    fn test_obsidian_does_not_support_keys_notation() {
1109        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
1110        let line = "Press ++ctrl+c++ to copy";
1111        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1112        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1113    }
1114
1115    #[test]
1116    fn test_obsidian_mkdocs_markup_function() {
1117        // is_in_mkdocs_markup should also work for Obsidian highlights
1118        let line = "This is ==highlighted== text";
1119        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
1120        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
1121    }
1122
1123    #[test]
1124    fn test_obsidian_highlight_edge_cases() {
1125        // Empty highlight (====) should not match
1126        let line = "Test ==== here";
1127        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
1128        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1129
1130        // Single character highlight
1131        let line2 = "Test ==a== here";
1132        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
1133        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
1134        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
1135
1136        // Triple equals (===) should not create highlight
1137        let line3 = "a === b";
1138        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
1139    }
1140
1141    #[test]
1142    fn test_obsidian_highlight_unclosed() {
1143        // Unclosed highlight should not match
1144        let line = "This ==starts but never ends";
1145        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
1146        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1147    }
1148
1149    #[test]
1150    fn test_inline_html_code_basic() {
1151        let line = "The formula is <code>a * b * c</code> in math.";
1152        // Position inside <code> content
1153        assert!(is_in_inline_html_code(line, 21)); // 'a'
1154        assert!(is_in_inline_html_code(line, 25)); // '*'
1155        // Position outside <code> content
1156        assert!(!is_in_inline_html_code(line, 0)); // 'T'
1157        assert!(!is_in_inline_html_code(line, 40)); // after </code>
1158    }
1159
1160    #[test]
1161    fn test_inline_html_code_multiple_tags() {
1162        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
1163        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
1164        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
1165        assert!(!is_in_inline_html_code(line, 16)); // '+'
1166    }
1167
1168    #[test]
1169    fn test_inline_html_code_with_attributes() {
1170        let line = r#"<code class="lang">x * y</code>"#;
1171        assert!(is_in_inline_html_code(line, 19)); // 'x'
1172        assert!(is_in_inline_html_code(line, 23)); // '*'
1173        assert!(!is_in_inline_html_code(line, 0)); // before tag
1174    }
1175
1176    #[test]
1177    fn test_inline_html_code_case_insensitive() {
1178        let line = "<CODE>a * b</CODE>";
1179        assert!(is_in_inline_html_code(line, 6)); // 'a'
1180        assert!(is_in_inline_html_code(line, 8)); // '*'
1181    }
1182
1183    #[test]
1184    fn test_inline_html_code_var_and_pre() {
1185        let line = "<var>x * y</var> and <pre>a * b</pre>";
1186        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
1187        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
1188        assert!(!is_in_inline_html_code(line, 17)); // 'and'
1189    }
1190
1191    #[test]
1192    fn test_inline_html_code_unclosed() {
1193        // Unclosed tag should not match
1194        let line = "<code>a * b without closing";
1195        assert!(!is_in_inline_html_code(line, 6));
1196    }
1197
1198    #[test]
1199    fn test_inline_html_code_no_substring_match() {
1200        // <variable> should NOT be treated as <var>
1201        let line = "<variable>a * b</variable>";
1202        assert!(!is_in_inline_html_code(line, 11));
1203
1204        // <keyboard> should NOT be treated as <kbd>
1205        let line2 = "<keyboard>x * y</keyboard>";
1206        assert!(!is_in_inline_html_code(line2, 11));
1207    }
1208}