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