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) = super::find_ascii_delimiter(&content[search_from..], "<!--") {
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) = super::find_ascii_delimiter(&content[close_from..], "-->") 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; a
290/// mid-line or stray-prose `$$...$$` is a literal, not math. Single-`$` inline
291/// spans are recognized anywhere. This keeps every math-aware rule agreeing on
292/// 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    if !content.contains("$$") {
313        return Vec::new();
314    }
315    let bytes = content.as_bytes();
316    let mut ranges = Vec::new();
317    let mut open: Option<usize> = None;
318    let mut line_start = 0usize;
319    let mut i = 0;
320    while i < bytes.len() {
321        match bytes[i] {
322            b'\n' => {
323                line_start = i + 1;
324                i += 1;
325            }
326            b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
327                match open {
328                    None => {
329                        // Open only when this `$$` is the first non-blank,
330                        // non-blockquote content on its line.
331                        let starts_line = bytes[line_start..i]
332                            .iter()
333                            .all(|&b| b == b' ' || b == b'\t' || b == b'>');
334                        if starts_line {
335                            open = Some(i);
336                        }
337                    }
338                    Some(start) => {
339                        ranges.push((start, i + 2));
340                        open = None;
341                    }
342                }
343                i += 2;
344            }
345            _ => i += 1,
346        }
347    }
348    ranges
349}
350
351/// Check if a byte position is within a `$$ ... $$` display-math block.
352///
353/// A block opens only on a `$$` that begins its line and closes on the next
354/// `$$` anywhere, so the closing fence ends the block even when it shares its
355/// line with LaTeX content (e.g. `\end{cases}$$`) or trailing Markdown prose;
356/// bytes after the closing `$$` are not math.
357pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
358    math_block_ranges(content)
359        .iter()
360        .any(|&(start, end)| byte_pos >= start && byte_pos < end)
361}
362
363/// Check if a byte position is within inline math (`$...$`).
364///
365/// Only single-`$` spans count here. A `$$...$$` token is display-math
366/// syntax, and whether it is actually math depends solely on whether it
367/// begins its line - that decision belongs to [`is_in_math_block`]. The
368/// regex still consumes `$$...$$` tokens so a single-`$` span cannot straddle
369/// them, but a mid-line `$$...$$` is a literal here, not inline math, keeping
370/// this function consistent with the line-start-gated block model.
371pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
372    for m in INLINE_MATH_REGEX.find_iter(content) {
373        if content[m.start()..m.end()].starts_with("$$") {
374            continue;
375        }
376        if m.start() <= byte_pos && byte_pos < m.end() {
377            return true;
378        }
379    }
380    false
381}
382
383/// All math byte ranges in `content`: line-start `$$...$$` display blocks
384/// plus single-`$` inline spans. Ranges are half-open `[start, end)` and may
385/// be unordered relative to each other; membership is by `any`-containment.
386///
387/// Precompute this once when classifying many positions in one document
388/// (e.g. every emphasis span). [`is_in_math_context`] is the single-shot
389/// equivalent and is defined in terms of the same two sources.
390pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
391    if !content.contains('$') {
392        return Vec::new();
393    }
394    let mut ranges = math_block_ranges(content);
395    for m in INLINE_MATH_REGEX.find_iter(content) {
396        if content[m.start()..m.end()].starts_with("$$") {
397            continue;
398        }
399        ranges.push((m.start(), m.end()));
400    }
401    ranges
402}
403
404/// Check if a line contains table syntax
405pub fn is_table_line(line: &str) -> bool {
406    let trimmed = line.trim();
407
408    // Check for table separator line
409    if trimmed
410        .chars()
411        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
412        && trimmed.contains('|')
413        && trimmed.contains('-')
414    {
415        return true;
416    }
417
418    // Check for table content line (starts and/or ends with |)
419    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
420        return true;
421    }
422
423    false
424}
425
426/// Check if a byte position is within an MkDocs icon shortcode
427/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
428pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
429    // Only skip for MkDocs flavor, but check pattern for all flavors
430    // since emoji shortcodes are universal
431    mkdocs_icons::is_in_any_shortcode(line, position)
432}
433
434/// Check if a byte position is within PyMdown extension markup
435/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
436///
437/// For MkDocs flavor: supports all PyMdown extensions
438/// For Obsidian flavor: only supports Mark (==highlight==) syntax
439pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
440    match flavor {
441        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
442        MarkdownFlavor::Obsidian => {
443            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
444            mkdocs_extensions::is_in_mark(line, position)
445        }
446        _ => false,
447    }
448}
449
450/// Check whether a position on a line falls inside an inline HTML code-like element.
451///
452/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
453/// These are inline elements whose content should not be interpreted as markdown emphasis.
454pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
455    // Tags whose content should not be parsed as markdown
456    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
457
458    let bytes = line.as_bytes();
459
460    for tag in TAGS {
461        let open_bytes = format!("<{tag}").into_bytes();
462        let close_pattern = format!("</{tag}>").into_bytes();
463
464        let mut search_from = 0;
465        while search_from + open_bytes.len() <= bytes.len() {
466            // Find opening tag (case-insensitive byte search)
467            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
468                break;
469            };
470
471            let after_tag = open_abs + open_bytes.len();
472
473            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
474            if after_tag < bytes.len() {
475                let next = bytes[after_tag];
476                if next != b'>' && next != b' ' && next != b'\t' {
477                    search_from = after_tag;
478                    continue;
479                }
480            }
481
482            // Find the end of the opening tag
483            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
484                break;
485            };
486            let content_start = after_tag + tag_close + 1;
487
488            // Find the closing tag (case-insensitive)
489            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
490                break;
491            };
492            let content_end = close_start;
493
494            if position >= content_start && position < content_end {
495                return true;
496            }
497
498            search_from = close_start + close_pattern.len();
499        }
500    }
501    false
502}
503
504/// Case-insensitive byte search within a slice, starting at `from`.
505fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
506    if needle.is_empty() || from + needle.len() > haystack.len() {
507        return None;
508    }
509    for i in from..=haystack.len() - needle.len() {
510        if haystack[i..i + needle.len()]
511            .iter()
512            .zip(needle.iter())
513            .all(|(h, n)| h.eq_ignore_ascii_case(n))
514        {
515            return Some(i);
516        }
517    }
518    None
519}
520
521/// Check if a byte position is within flavor-specific markup
522/// For MkDocs: icon shortcodes and PyMdown extensions
523/// For Obsidian: highlight syntax (==text==)
524pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
525    if is_in_icon_shortcode(line, position, flavor) {
526        return true;
527    }
528    if is_in_pymdown_markup(line, position, flavor) {
529        return true;
530    }
531    false
532}
533
534/// Check if a byte position within a line is inside a backtick-delimited code span.
535///
536/// This is a line-level fallback for cases where pulldown-cmark's code span detection
537/// misses spans due to table parsing interference (e.g., pipes inside code spans
538/// in table rows cause pulldown-cmark to misidentify cell boundaries).
539fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
540    let bytes = line.as_bytes();
541    let mut i = 0;
542
543    while i < bytes.len() {
544        if bytes[i] == b'`' {
545            let open_start = i;
546            let mut backtick_count = 0;
547            while i < bytes.len() && bytes[i] == b'`' {
548                backtick_count += 1;
549                i += 1;
550            }
551
552            // Search for matching closing backticks
553            let mut j = i;
554            while j < bytes.len() {
555                if bytes[j] == b'`' {
556                    let mut close_count = 0;
557                    while j < bytes.len() && bytes[j] == b'`' {
558                        close_count += 1;
559                        j += 1;
560                    }
561                    if close_count == backtick_count {
562                        // Found matching pair: code span covers open_start..j
563                        if byte_pos >= open_start && byte_pos < j {
564                            return true;
565                        }
566                        i = j;
567                        break;
568                    }
569                } else {
570                    j += 1;
571                }
572            }
573
574            if j >= bytes.len() {
575                // No matching close found, remaining text is not a code span
576                break;
577            }
578        } else {
579            i += 1;
580        }
581    }
582
583    false
584}
585
586/// Check if a byte position is within an HTML tag. O(log n) via binary search.
587fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
588    let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
589    idx > 0 && byte_pos < html_tags[idx - 1].byte_end
590}
591
592/// Check if a byte position is within HTML code content (`<code>...</code>`).
593/// Uses pre-computed code ranges for O(log n) lookup via binary search.
594fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
595    let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
596    idx > 0 && byte_pos < code_ranges[idx - 1].1
597}
598
599/// Pre-compute ranges covered by `<code>...</code>` HTML tags.
600/// Returns sorted Vec of (start, end) byte ranges.
601pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
602    let mut ranges = Vec::new();
603    let mut open_code_end: Option<usize> = None;
604
605    for tag in html_tags {
606        if tag.tag_name == "code" {
607            if tag.is_self_closing {
608                continue;
609            } else if !tag.is_closing {
610                open_code_end = Some(tag.byte_end);
611            } else if tag.is_closing {
612                if let Some(start) = open_code_end {
613                    ranges.push((start, tag.byte_offset));
614                }
615                open_code_end = None;
616            }
617        }
618    }
619    // Handle unclosed <code> tag
620    if let Some(start) = open_code_end {
621        ranges.push((start, usize::MAX));
622    }
623    ranges
624}
625
626/// Hide cached MDX code from inline marker discovery without changing offsets.
627/// Callers must reject fixes overlapping these ranges so masking cannot enter
628/// replacement text. Lines without MDX code borrow their original source.
629pub(crate) fn mask_mdx_inline_code<'a>(ctx: &LintContext, source: &'a str, start: usize) -> std::borrow::Cow<'a, str> {
630    let end = start + source.len();
631    let mut ranges = ctx.mdx_inline_code_ranges(start, end).peekable();
632    if ranges.peek().is_none() {
633        return std::borrow::Cow::Borrowed(source);
634    }
635    let mut bytes = source.as_bytes().to_vec();
636    for (range_start, range_end) in ranges {
637        bytes[range_start.max(start) - start..range_end.min(end) - start].fill(b'X');
638    }
639    // Whole source ranges are replaced with ASCII, retaining valid UTF-8.
640    std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII masking preserves UTF-8"))
641}
642
643/// Determine whether an emphasis or strong span starting at `span_start` should be
644/// skipped because it falls inside a non-prose context: code blocks/spans, inline
645/// code, links, HTML tags or `<code>` content, MkDocs/PyMdown markup, math, JSX
646/// expressions, MDX comments or module code, MyST comments, front matter, or mkdocstrings blocks.
647/// Either delimiter inside a quoted Jinja value or shortcode tag also makes the span non-prose.
648///
649/// `html_tags` and `html_code_ranges` are passed in so callers iterating many spans
650/// can compute them once via [`compute_html_code_ranges`].
651pub(crate) fn should_skip_emphasis_span(
652    ctx: &LintContext,
653    html_tags: &[HtmlTag],
654    html_code_ranges: &[(usize, usize)],
655    span_start: usize,
656    span_end: usize,
657) -> bool {
658    let lines = ctx.raw_lines();
659    let (line_num, col) = ctx.offset_to_line_col(span_start);
660
661    // Skip matches in front matter, mkdocstrings blocks, MDX module code, or MyST comments
662    if ctx
663        .line_info(line_num)
664        .is_some_and(|info| info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_comment)
665    {
666        return true;
667    }
668
669    // Check MkDocs markup
670    let in_mkdocs_markup = lines
671        .get(line_num.saturating_sub(1))
672        .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
673
674    // Line-level inline code fallback for cases pulldown-cmark misses
675    let in_inline_code = lines
676        .get(line_num.saturating_sub(1))
677        .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
678
679    ctx.is_in_jinja_string(span_start)
680        || ctx.is_in_jinja_string(span_end.saturating_sub(1))
681        || ctx.is_in_shortcode(span_start)
682        || ctx.is_in_shortcode(span_end.saturating_sub(1))
683        || ctx.is_in_code_block_or_span(span_start)
684        || in_inline_code
685        || ctx.is_in_link(span_start)
686        || is_byte_in_html_tag(html_tags, span_start)
687        || ctx.is_in_jsx_component_tag(span_start)
688        || ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
689        || is_byte_in_html_code_content(html_code_ranges, span_start)
690        || in_mkdocs_markup
691        || is_in_math_context(ctx, span_start)
692        || is_in_jsx_expression(ctx, span_start)
693        || is_in_mdx_comment(ctx, span_start)
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn test_html_comment_detection() {
702        let content = "Text <!-- comment --> more text";
703        let ranges = compute_html_comment_ranges(content);
704        assert!(is_in_html_comment_ranges(&ranges, 10)); // Inside comment
705        assert!(!is_in_html_comment_ranges(&ranges, 0)); // Before comment
706        assert!(!is_in_html_comment_ranges(&ranges, 25)); // After comment
707    }
708
709    #[test]
710    fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
711        // `<!--` and `-->` inside inline code spans on different lines must not
712        // pair into a multi-line HTML comment (issue #679).
713        let content = "a `<!--` b\n\nc `-->` d";
714        let open = content.find("<!--").unwrap();
715        let close = content.find("-->").unwrap();
716        // Code spans covering the two backtick-delimited tokens.
717        let code_spans = [
718            (content.find('`').unwrap(), open + "<!--".len() + 1),
719            (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
720        ];
721
722        // Without code-span awareness the pattern spans open..close (the bug).
723        assert!(
724            !compute_html_comment_ranges(content).is_empty(),
725            "sanity: raw pattern matches across the code spans"
726        );
727        // With code-span awareness the spurious match is dropped.
728        assert!(
729            compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
730            "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
731        );
732    }
733
734    #[test]
735    fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
736        // A `<!--` inside a code block must not pair with a later `-->` outside it
737        // (the code-block counterpart of the code-span case).
738        let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
739        let block_end = content.find("```\n\n").unwrap() + "```".len();
740        let code_blocks = [(0usize, block_end)];
741        assert!(
742            compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
743            "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
744        );
745        // A real comment whose opener is outside the block is still detected.
746        let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
747        let real_block_end = real.find("```\n\n").unwrap() + "```".len();
748        let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
749        assert_eq!(ranges.len(), 1);
750        assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
751    }
752
753    #[test]
754    fn test_compute_html_comment_ranges_keeps_real_comments() {
755        // A genuine comment whose `<!--` is not inside a code span is still
756        // detected, even when an unrelated code span exists elsewhere.
757        let content = "text `code` <!-- real comment --> more";
758        let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
759        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
760        assert_eq!(ranges.len(), 1, "the real comment must still be detected");
761        let comment_start = content.find("<!--").unwrap();
762        assert_eq!(ranges[0].start, comment_start);
763    }
764
765    #[test]
766    fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
767        // A code span containing `<!--` must not consume a real comment that
768        // follows it: skipping the literal opener, the scan must still discover
769        // the genuine `<!-- ... -->` and mark its content as a comment.
770        let content = "a `<!--` then <!-- real --> end";
771        let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
772        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
773        assert_eq!(
774            ranges.len(),
775            1,
776            "the real comment after a code-span opener must be detected"
777        );
778        let real_open = content.find("<!-- real").unwrap();
779        assert_eq!(
780            ranges[0].start, real_open,
781            "range must start at the real comment, not the code-span opener"
782        );
783        assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
784    }
785
786    #[test]
787    fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
788        // A real comment's closing `-->` that lands inside a code span is literal;
789        // the scan must continue to the next real `-->`.
790        let content = "<!-- open `-->` still open --> done";
791        let first_close = content.find("`-->`").unwrap() + 1;
792        let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
793        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
794        assert_eq!(ranges.len(), 1);
795        assert_eq!(ranges[0].start, 0);
796        let real_close_end = content.find("--> done").unwrap() + "-->".len();
797        assert_eq!(
798            ranges[0].end, real_close_end,
799            "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
800        );
801    }
802
803    #[test]
804    fn test_scan_html_comments_starts_at_the_given_offset() {
805        // The offset is where front matter ends: a delimiter before it is data,
806        // so it neither opens a comment nor closes one.
807        let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
808        let body_start = content.rfind("---\n").unwrap() + "---\n".len();
809        let scan = scan_html_comments(content, &[], &[], body_start);
810        assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
811        assert_eq!(scan.unterminated, None);
812
813        let unscoped = scan_html_comments(content, &[], &[], 0);
814        assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
815    }
816
817    #[test]
818    fn test_scan_html_comments_reports_the_unterminated_opener() {
819        let content = "<!-- closed --> text <!-- open";
820        let scan = scan_html_comments(content, &[], &[], 0);
821        assert_eq!(scan.ranges.len(), 1);
822        assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
823
824        let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
825        assert_eq!(closed.unterminated, None);
826    }
827
828    #[test]
829    fn test_unterminated_html_comment_outside_hidden_ranges() {
830        // An opener the caller can see is hidden resumes the search at the next
831        // one rather than clearing the finding.
832        let content = "%% note <!-- marker %%\n\n<!-- real\n";
833        let hidden = [(0, content.find("\n\n").unwrap())];
834        let reported = scan_html_comments(content, &[], &[], 0).unterminated;
835        assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
836        assert_eq!(
837            unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
838            Some(content.rfind("<!--").unwrap())
839        );
840
841        // Nothing left outside the hidden range: the finding goes away.
842        let only_hidden = "%% note <!-- marker %%\n";
843        let hidden_all = [(0, only_hidden.len())];
844        let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
845        assert!(reported.is_some(), "the scan sees the hidden opener");
846        assert_eq!(
847            unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
848            None
849        );
850
851        // An opener outside every hidden range is returned untouched.
852        let visible = "<!-- open\n\n%% a note %%\n";
853        let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
854        assert_eq!(
855            unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
856            Some(0)
857        );
858    }
859
860    #[test]
861    fn test_unterminated_comment_range_follows_the_block() {
862        // An opener that starts a block hides the rest of that block.
863        let blocks = [(0, 40)];
864        let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
865        assert_eq!((range.start, range.end), (0, 40));
866
867        // An opener in a block a tag already started hides only from itself on,
868        // so the tag above it stays visible.
869        let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
870        assert_eq!((range.start, range.end), (7, 40));
871
872        // An opener in no block at all is inline, and hides nothing.
873        assert!(unterminated_comment_range(60, &blocks).is_none());
874        assert!(unterminated_comment_range(0, &[]).is_none());
875
876        // The end of a block is exclusive: an opener there belongs to whatever
877        // follows, not to the block that just closed.
878        assert!(unterminated_comment_range(40, &blocks).is_none());
879    }
880
881    #[test]
882    fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
883        // CommonMark ends a comment at the first `-->`, and the opener's own
884        // dashes can supply its first two, so these are whole comments and the
885        // text after them is ordinary markdown.
886        for (content, expected_end) in [
887            ("<!--> text", 5),
888            ("<!---> text", 6),
889            ("<!----> text", 7),
890            ("<!-- x --> text", 10),
891        ] {
892            let ranges = compute_html_comment_ranges(content);
893            assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
894            assert_eq!(ranges[0].start, 0);
895            assert_eq!(
896                ranges[0].end, expected_end,
897                "{content:?} ends its comment at the first -->"
898            );
899        }
900    }
901
902    #[test]
903    fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
904        // The degenerate comment must not swallow the text up to the next `-->`,
905        // which would hide a whole document from every rule.
906        let content = "<!--> visible <!-- hidden --> visible";
907        let ranges = compute_html_comment_ranges(content);
908        assert_eq!(ranges.len(), 2);
909        assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
910        assert_eq!(
911            (ranges[1].start, ranges[1].end),
912            (content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
913        );
914    }
915
916    #[test]
917    fn test_is_line_entirely_in_html_comment() {
918        // Test 1: Multi-line comment with content after closing
919        let content = "<!--\ncomment\n--> Content after comment";
920        let ranges = compute_html_comment_ranges(content);
921        // Line 0: "<!--" (bytes 0-4) - entirely in comment
922        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
923        // Line 1: "comment" (bytes 5-12) - entirely in comment
924        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
925        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
926        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
927
928        // Test 2: Single-line comment with content after
929        let content2 = "<!-- comment --> Not a comment";
930        let ranges2 = compute_html_comment_ranges(content2);
931        // The entire line is NOT entirely in the comment
932        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
933
934        // Test 3: Single-line comment alone
935        let content3 = "<!-- comment -->";
936        let ranges3 = compute_html_comment_ranges(content3);
937        // The entire line IS entirely in the comment
938        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
939
940        // Test 4: Content before comment
941        let content4 = "Text before <!-- comment -->";
942        let ranges4 = compute_html_comment_ranges(content4);
943        // Line start is NOT in the comment range
944        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
945    }
946
947    #[test]
948    fn test_is_line_entirely_in_html_comment_indented() {
949        // An indented single-line comment: callers pass the trimmed content bounds
950        // (start at the `<!--`, end after the `-->`), so it is recognised as being
951        // entirely inside the comment even though the line starts with whitespace.
952        let content = "    <!-- comment -->";
953        let ranges = compute_html_comment_ranges(content);
954        let content_start = content.find("<!--").unwrap();
955        let content_end = content.trim_end().len();
956        assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
957        // Passing the raw column-0 line start would miss it (regression guard for #755).
958        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
959    }
960
961    #[test]
962    fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
963        // Trailing whitespace after the closer must not push content_end past the range.
964        let content = "<!-- comment -->   ";
965        let ranges = compute_html_comment_ranges(content);
966        let content_end = content.trim_end().len();
967        assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
968        // With the raw line length (incl. trailing spaces) it would fall outside the range.
969        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
970    }
971
972    #[test]
973    fn test_math_block_detection() {
974        let content = "Text\n$$\nmath content\n$$\nmore text";
975        assert!(is_in_math_block(content, 8)); // On opening $$
976        assert!(is_in_math_block(content, 15)); // Inside math block
977        assert!(!is_in_math_block(content, 0)); // Before math block
978        assert!(!is_in_math_block(content, 30)); // After math block
979    }
980
981    #[test]
982    fn test_stray_double_dollar_in_prose_is_not_math() {
983        // Two `$$` tokens inside a prose line must NOT pair into a math block:
984        // a multi-line block only opens on a `$$` that begins its line. This
985        // keeps the byte-level result consistent with the line-level map.
986        let content = "Note: $$ is used for display math and $$ closes it";
987        let between = content.find("is used").unwrap();
988        assert!(
989            !is_in_math_block(content, between),
990            "stray paired `$$` in prose must not be treated as a math block"
991        );
992        assert!(math_block_ranges(content).is_empty());
993    }
994
995    #[test]
996    fn test_blockquoted_double_dollar_opens_block() {
997        // A `$$` opener is still recognized behind a blockquote prefix.
998        let content = "> $$\n> x = y\n> $$\n";
999        let inside = content.find("x = y").unwrap();
1000        assert!(is_in_math_block(content, inside), "blockquoted math interior");
1001    }
1002
1003    #[test]
1004    fn test_self_contained_single_line_block_leaves_trailing_prose() {
1005        // `$$ a $$` at line start is math; prose after the closing `$$` is not.
1006        let content = "$$ a $$ and __not math__\n";
1007        let in_math = content.find('a').unwrap();
1008        assert!(is_in_math_block(content, in_math), "single-line math interior");
1009        let after = content.find("not math").unwrap();
1010        assert!(!is_in_math_block(content, after), "trailing prose is lintable");
1011    }
1012
1013    #[test]
1014    fn test_math_block_closes_with_content_before_fence() {
1015        // A display-math block whose closing `$$` shares its line with
1016        // content (e.g. `\end{aligned}$$`) must still close the block.
1017        // Content after the block is prose, not math.
1018        let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
1019
1020        let inside = content.find("x = y").unwrap();
1021        assert!(is_in_math_block(content, inside), "interior must be math");
1022
1023        let after = content.find("after").unwrap();
1024        assert!(
1025            !is_in_math_block(content, after),
1026            "content after a content-sharing closing fence must NOT be math"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_inline_math_detection() {
1032        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
1033        assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
1034        // The mid-line `$$a^2 + b^2$$` is display syntax, not a line-start
1035        // block, so it is a literal under the shared math model - neither the
1036        // inline path nor `math_block_ranges` treats it as math.
1037        assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
1038        assert!(
1039            !is_in_math_block(content, 20),
1040            "mid-line $$...$$ is not a line-start display block"
1041        );
1042        assert!(!is_in_inline_math(content, 0), "before any math");
1043        assert!(!is_in_inline_math(content, 35), "after the spans");
1044    }
1045
1046    #[test]
1047    fn test_table_line_detection() {
1048        assert!(is_table_line("| Header | Column |"));
1049        assert!(is_table_line("|--------|--------|"));
1050        assert!(is_table_line("| Cell 1 | Cell 2 |"));
1051        assert!(!is_table_line("Regular text"));
1052        assert!(!is_table_line("Just a pipe | here"));
1053    }
1054
1055    #[test]
1056    fn test_is_in_icon_shortcode() {
1057        let line = "Click :material-check: to confirm";
1058        // Position 0-5 is "Click"
1059        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
1060        // Position 6-22 is ":material-check:"
1061        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
1062        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
1063        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
1064        // Position 22+ is " to confirm"
1065        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
1066    }
1067
1068    #[test]
1069    fn test_is_in_pymdown_markup() {
1070        // Test Keys notation
1071        let line = "Press ++ctrl+c++ to copy";
1072        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
1073        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
1074        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
1075        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
1076
1077        // Test Mark notation
1078        let line2 = "This is ==highlighted== text";
1079        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
1080        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
1081        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
1082        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
1083
1084        // Should not match for Standard flavor
1085        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
1086    }
1087
1088    #[test]
1089    fn test_is_in_mkdocs_markup() {
1090        // Should combine both icon and pymdown
1091        let line = ":material-check: and ++ctrl++";
1092        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
1093        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
1094        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
1095    }
1096
1097    // ==================== Obsidian highlight tests ====================
1098
1099    #[test]
1100    fn test_obsidian_highlight_basic() {
1101        // Obsidian flavor should recognize ==highlight== syntax
1102        let line = "This is ==highlighted== text";
1103        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
1104        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
1105        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
1106        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
1107        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
1108        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
1109    }
1110
1111    #[test]
1112    fn test_obsidian_highlight_multiple() {
1113        // Multiple highlights on one line
1114        let line = "Both ==one== and ==two== here";
1115        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
1116        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
1117        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
1118        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
1119    }
1120
1121    #[test]
1122    fn test_obsidian_highlight_not_standard_flavor() {
1123        // Standard flavor should NOT recognize ==highlight== as special
1124        let line = "This is ==highlighted== text";
1125        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
1126        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
1127    }
1128
1129    #[test]
1130    fn test_obsidian_highlight_with_spaces_inside() {
1131        // Highlights can have spaces inside the content
1132        let line = "This is ==text with spaces== here";
1133        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
1134        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
1135        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
1136    }
1137
1138    #[test]
1139    fn test_obsidian_does_not_support_keys_notation() {
1140        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
1141        let line = "Press ++ctrl+c++ to copy";
1142        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1143        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1144    }
1145
1146    #[test]
1147    fn test_obsidian_mkdocs_markup_function() {
1148        // is_in_mkdocs_markup should also work for Obsidian highlights
1149        let line = "This is ==highlighted== text";
1150        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
1151        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
1152    }
1153
1154    #[test]
1155    fn test_obsidian_highlight_edge_cases() {
1156        // Empty highlight (====) should not match
1157        let line = "Test ==== here";
1158        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
1159        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1160
1161        // Single character highlight
1162        let line2 = "Test ==a== here";
1163        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
1164        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
1165        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
1166
1167        // Triple equals (===) should not create highlight
1168        let line3 = "a === b";
1169        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
1170    }
1171
1172    #[test]
1173    fn test_obsidian_highlight_unclosed() {
1174        // Unclosed highlight should not match
1175        let line = "This ==starts but never ends";
1176        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
1177        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1178    }
1179
1180    #[test]
1181    fn test_inline_html_code_basic() {
1182        let line = "The formula is <code>a * b * c</code> in math.";
1183        // Position inside <code> content
1184        assert!(is_in_inline_html_code(line, 21)); // 'a'
1185        assert!(is_in_inline_html_code(line, 25)); // '*'
1186        // Position outside <code> content
1187        assert!(!is_in_inline_html_code(line, 0)); // 'T'
1188        assert!(!is_in_inline_html_code(line, 40)); // after </code>
1189    }
1190
1191    #[test]
1192    fn test_inline_html_code_multiple_tags() {
1193        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
1194        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
1195        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
1196        assert!(!is_in_inline_html_code(line, 16)); // '+'
1197    }
1198
1199    #[test]
1200    fn test_inline_html_code_with_attributes() {
1201        let line = r#"<code class="lang">x * y</code>"#;
1202        assert!(is_in_inline_html_code(line, 19)); // 'x'
1203        assert!(is_in_inline_html_code(line, 23)); // '*'
1204        assert!(!is_in_inline_html_code(line, 0)); // before tag
1205    }
1206
1207    #[test]
1208    fn test_inline_html_code_case_insensitive() {
1209        let line = "<CODE>a * b</CODE>";
1210        assert!(is_in_inline_html_code(line, 6)); // 'a'
1211        assert!(is_in_inline_html_code(line, 8)); // '*'
1212    }
1213
1214    #[test]
1215    fn test_inline_html_code_var_and_pre() {
1216        let line = "<var>x * y</var> and <pre>a * b</pre>";
1217        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
1218        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
1219        assert!(!is_in_inline_html_code(line, 17)); // 'and'
1220    }
1221
1222    #[test]
1223    fn test_inline_html_code_unclosed() {
1224        // Unclosed tag should not match
1225        let line = "<code>a * b without closing";
1226        assert!(!is_in_inline_html_code(line, 6));
1227    }
1228
1229    #[test]
1230    fn test_inline_html_code_no_substring_match() {
1231        // <variable> should NOT be treated as <var>
1232        let line = "<variable>a * b</variable>";
1233        assert!(!is_in_inline_html_code(line, 11));
1234
1235        // <keyboard> should NOT be treated as <kbd>
1236        let line2 = "<keyboard>x * y</keyboard>";
1237        assert!(!is_in_inline_html_code(line2, 11));
1238    }
1239}