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