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 position is within a table cell
301pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
302    // Check if this line is part of a table
303    for table_row in ctx.table_rows().iter() {
304        if table_row.line == line_num {
305            // This line is part of a table
306            // For now, we'll skip the entire table row
307            // Future enhancement: check specific column boundaries
308            return true;
309        }
310    }
311    false
312}
313
314/// Check if a line contains table syntax
315pub fn is_table_line(line: &str) -> bool {
316    let trimmed = line.trim();
317
318    // Check for table separator line
319    if trimmed
320        .chars()
321        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
322        && trimmed.contains('|')
323        && trimmed.contains('-')
324    {
325        return true;
326    }
327
328    // Check for table content line (starts and/or ends with |)
329    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
330        return true;
331    }
332
333    false
334}
335
336/// Check if a byte position is within an MkDocs icon shortcode
337/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
338pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
339    // Only skip for MkDocs flavor, but check pattern for all flavors
340    // since emoji shortcodes are universal
341    mkdocs_icons::is_in_any_shortcode(line, position)
342}
343
344/// Check if a byte position is within PyMdown extension markup
345/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
346///
347/// For MkDocs flavor: supports all PyMdown extensions
348/// For Obsidian flavor: only supports Mark (==highlight==) syntax
349pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
350    match flavor {
351        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
352        MarkdownFlavor::Obsidian => {
353            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
354            mkdocs_extensions::is_in_mark(line, position)
355        }
356        _ => false,
357    }
358}
359
360/// Check whether a position on a line falls inside an inline HTML code-like element.
361///
362/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
363/// These are inline elements whose content should not be interpreted as markdown emphasis.
364pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
365    // Tags whose content should not be parsed as markdown
366    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
367
368    let bytes = line.as_bytes();
369
370    for tag in TAGS {
371        let open_bytes = format!("<{tag}").into_bytes();
372        let close_pattern = format!("</{tag}>").into_bytes();
373
374        let mut search_from = 0;
375        while search_from + open_bytes.len() <= bytes.len() {
376            // Find opening tag (case-insensitive byte search)
377            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
378                break;
379            };
380
381            let after_tag = open_abs + open_bytes.len();
382
383            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
384            if after_tag < bytes.len() {
385                let next = bytes[after_tag];
386                if next != b'>' && next != b' ' && next != b'\t' {
387                    search_from = after_tag;
388                    continue;
389                }
390            }
391
392            // Find the end of the opening tag
393            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
394                break;
395            };
396            let content_start = after_tag + tag_close + 1;
397
398            // Find the closing tag (case-insensitive)
399            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
400                break;
401            };
402            let content_end = close_start;
403
404            if position >= content_start && position < content_end {
405                return true;
406            }
407
408            search_from = close_start + close_pattern.len();
409        }
410    }
411    false
412}
413
414/// Case-insensitive byte search within a slice, starting at `from`.
415fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
416    if needle.is_empty() || from + needle.len() > haystack.len() {
417        return None;
418    }
419    for i in from..=haystack.len() - needle.len() {
420        if haystack[i..i + needle.len()]
421            .iter()
422            .zip(needle.iter())
423            .all(|(h, n)| h.eq_ignore_ascii_case(n))
424        {
425            return Some(i);
426        }
427    }
428    None
429}
430
431/// Check if a byte position is within flavor-specific markup
432/// For MkDocs: icon shortcodes and PyMdown extensions
433/// For Obsidian: highlight syntax (==text==)
434pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
435    if is_in_icon_shortcode(line, position, flavor) {
436        return true;
437    }
438    if is_in_pymdown_markup(line, position, flavor) {
439        return true;
440    }
441    false
442}
443
444/// Check if a byte position within a line is inside a backtick-delimited code span.
445///
446/// This is a line-level fallback for cases where pulldown-cmark's code span detection
447/// misses spans due to table parsing interference (e.g., pipes inside code spans
448/// in table rows cause pulldown-cmark to misidentify cell boundaries).
449fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
450    let bytes = line.as_bytes();
451    let mut i = 0;
452
453    while i < bytes.len() {
454        if bytes[i] == b'`' {
455            let open_start = i;
456            let mut backtick_count = 0;
457            while i < bytes.len() && bytes[i] == b'`' {
458                backtick_count += 1;
459                i += 1;
460            }
461
462            // Search for matching closing backticks
463            let mut j = i;
464            while j < bytes.len() {
465                if bytes[j] == b'`' {
466                    let mut close_count = 0;
467                    while j < bytes.len() && bytes[j] == b'`' {
468                        close_count += 1;
469                        j += 1;
470                    }
471                    if close_count == backtick_count {
472                        // Found matching pair: code span covers open_start..j
473                        if byte_pos >= open_start && byte_pos < j {
474                            return true;
475                        }
476                        i = j;
477                        break;
478                    }
479                } else {
480                    j += 1;
481                }
482            }
483
484            if j >= bytes.len() {
485                // No matching close found, remaining text is not a code span
486                break;
487            }
488        } else {
489            i += 1;
490        }
491    }
492
493    false
494}
495
496/// Check if a byte position is within an HTML tag. O(log n) via binary search.
497fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
498    let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
499    idx > 0 && byte_pos < html_tags[idx - 1].byte_end
500}
501
502/// Check if a byte position is within HTML code content (`<code>...</code>`).
503/// Uses pre-computed code ranges for O(log n) lookup via binary search.
504fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
505    let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
506    idx > 0 && byte_pos < code_ranges[idx - 1].1
507}
508
509/// Pre-compute ranges covered by `<code>...</code>` HTML tags.
510/// Returns sorted Vec of (start, end) byte ranges.
511pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
512    let mut ranges = Vec::new();
513    let mut open_code_end: Option<usize> = None;
514
515    for tag in html_tags {
516        if tag.tag_name == "code" {
517            if tag.is_self_closing {
518                continue;
519            } else if !tag.is_closing {
520                open_code_end = Some(tag.byte_end);
521            } else if tag.is_closing {
522                if let Some(start) = open_code_end {
523                    ranges.push((start, tag.byte_offset));
524                }
525                open_code_end = None;
526            }
527        }
528    }
529    // Handle unclosed <code> tag
530    if let Some(start) = open_code_end {
531        ranges.push((start, usize::MAX));
532    }
533    ranges
534}
535
536/// Determine whether an emphasis or strong span starting at `span_start` should be
537/// skipped because it falls inside a non-prose context: code blocks/spans, inline
538/// code, links, HTML tags or `<code>` content, MkDocs/PyMdown markup, math, JSX
539/// expressions, MDX comments, front matter, or mkdocstrings blocks.
540///
541/// `html_tags` and `html_code_ranges` are passed in so callers iterating many spans
542/// can compute them once via [`compute_html_code_ranges`].
543pub(crate) fn should_skip_emphasis_span(
544    ctx: &LintContext,
545    html_tags: &[HtmlTag],
546    html_code_ranges: &[(usize, usize)],
547    span_start: usize,
548) -> bool {
549    let lines = ctx.raw_lines();
550    let (line_num, col) = ctx.offset_to_line_col(span_start);
551
552    // Skip matches in front matter or mkdocstrings blocks
553    if ctx
554        .line_info(line_num)
555        .is_some_and(|info| info.in_front_matter || info.in_mkdocstrings)
556    {
557        return true;
558    }
559
560    // Check MkDocs markup
561    let in_mkdocs_markup = lines
562        .get(line_num.saturating_sub(1))
563        .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
564
565    // Line-level inline code fallback for cases pulldown-cmark misses
566    let in_inline_code = lines
567        .get(line_num.saturating_sub(1))
568        .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
569
570    ctx.is_in_code_block_or_span(span_start)
571        || in_inline_code
572        || ctx.is_in_link(span_start)
573        || is_byte_in_html_tag(html_tags, span_start)
574        || is_byte_in_html_code_content(html_code_ranges, span_start)
575        || in_mkdocs_markup
576        || is_in_math_context(ctx, span_start)
577        || is_in_jsx_expression(ctx, span_start)
578        || is_in_mdx_comment(ctx, span_start)
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn test_html_comment_detection() {
587        let content = "Text <!-- comment --> more text";
588        let ranges = compute_html_comment_ranges(content);
589        assert!(is_in_html_comment_ranges(&ranges, 10)); // Inside comment
590        assert!(!is_in_html_comment_ranges(&ranges, 0)); // Before comment
591        assert!(!is_in_html_comment_ranges(&ranges, 25)); // After comment
592    }
593
594    #[test]
595    fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
596        // `<!--` and `-->` inside inline code spans on different lines must not
597        // pair into a multi-line HTML comment (issue #679).
598        let content = "a `<!--` b\n\nc `-->` d";
599        let open = content.find("<!--").unwrap();
600        let close = content.find("-->").unwrap();
601        // Code spans covering the two backtick-delimited tokens.
602        let code_spans = [
603            (content.find('`').unwrap(), open + "<!--".len() + 1),
604            (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
605        ];
606
607        // Without code-span awareness the pattern spans open..close (the bug).
608        assert!(
609            !compute_html_comment_ranges(content).is_empty(),
610            "sanity: raw pattern matches across the code spans"
611        );
612        // With code-span awareness the spurious match is dropped.
613        assert!(
614            compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
615            "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
616        );
617    }
618
619    #[test]
620    fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
621        // A `<!--` inside a code block must not pair with a later `-->` outside it
622        // (the code-block counterpart of the code-span case).
623        let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
624        let block_end = content.find("```\n\n").unwrap() + "```".len();
625        let code_blocks = [(0usize, block_end)];
626        assert!(
627            compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
628            "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
629        );
630        // A real comment whose opener is outside the block is still detected.
631        let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
632        let real_block_end = real.find("```\n\n").unwrap() + "```".len();
633        let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
634        assert_eq!(ranges.len(), 1);
635        assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
636    }
637
638    #[test]
639    fn test_compute_html_comment_ranges_keeps_real_comments() {
640        // A genuine comment whose `<!--` is not inside a code span is still
641        // detected, even when an unrelated code span exists elsewhere.
642        let content = "text `code` <!-- real comment --> more";
643        let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
644        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
645        assert_eq!(ranges.len(), 1, "the real comment must still be detected");
646        let comment_start = content.find("<!--").unwrap();
647        assert_eq!(ranges[0].start, comment_start);
648    }
649
650    #[test]
651    fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
652        // A code span containing `<!--` must not consume a real comment that
653        // follows it: skipping the literal opener, the scan must still discover
654        // the genuine `<!-- ... -->` and mark its content as a comment.
655        let content = "a `<!--` then <!-- real --> end";
656        let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
657        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
658        assert_eq!(
659            ranges.len(),
660            1,
661            "the real comment after a code-span opener must be detected"
662        );
663        let real_open = content.find("<!-- real").unwrap();
664        assert_eq!(
665            ranges[0].start, real_open,
666            "range must start at the real comment, not the code-span opener"
667        );
668        assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
669    }
670
671    #[test]
672    fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
673        // A real comment's closing `-->` that lands inside a code span is literal;
674        // the scan must continue to the next real `-->`.
675        let content = "<!-- open `-->` still open --> done";
676        let first_close = content.find("`-->`").unwrap() + 1;
677        let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
678        let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
679        assert_eq!(ranges.len(), 1);
680        assert_eq!(ranges[0].start, 0);
681        let real_close_end = content.find("--> done").unwrap() + "-->".len();
682        assert_eq!(
683            ranges[0].end, real_close_end,
684            "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
685        );
686    }
687
688    #[test]
689    fn test_is_line_entirely_in_html_comment() {
690        // Test 1: Multi-line comment with content after closing
691        let content = "<!--\ncomment\n--> Content after comment";
692        let ranges = compute_html_comment_ranges(content);
693        // Line 0: "<!--" (bytes 0-4) - entirely in comment
694        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
695        // Line 1: "comment" (bytes 5-12) - entirely in comment
696        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
697        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
698        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
699
700        // Test 2: Single-line comment with content after
701        let content2 = "<!-- comment --> Not a comment";
702        let ranges2 = compute_html_comment_ranges(content2);
703        // The entire line is NOT entirely in the comment
704        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
705
706        // Test 3: Single-line comment alone
707        let content3 = "<!-- comment -->";
708        let ranges3 = compute_html_comment_ranges(content3);
709        // The entire line IS entirely in the comment
710        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
711
712        // Test 4: Content before comment
713        let content4 = "Text before <!-- comment -->";
714        let ranges4 = compute_html_comment_ranges(content4);
715        // Line start is NOT in the comment range
716        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
717    }
718
719    #[test]
720    fn test_is_line_entirely_in_html_comment_indented() {
721        // An indented single-line comment: callers pass the trimmed content bounds
722        // (start at the `<!--`, end after the `-->`), so it is recognised as being
723        // entirely inside the comment even though the line starts with whitespace.
724        let content = "    <!-- comment -->";
725        let ranges = compute_html_comment_ranges(content);
726        let content_start = content.find("<!--").unwrap();
727        let content_end = content.trim_end().len();
728        assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
729        // Passing the raw column-0 line start would miss it (regression guard for #755).
730        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
731    }
732
733    #[test]
734    fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
735        // Trailing whitespace after the closer must not push content_end past the range.
736        let content = "<!-- comment -->   ";
737        let ranges = compute_html_comment_ranges(content);
738        let content_end = content.trim_end().len();
739        assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
740        // With the raw line length (incl. trailing spaces) it would fall outside the range.
741        assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
742    }
743
744    #[test]
745    fn test_math_block_detection() {
746        let content = "Text\n$$\nmath content\n$$\nmore text";
747        assert!(is_in_math_block(content, 8)); // On opening $$
748        assert!(is_in_math_block(content, 15)); // Inside math block
749        assert!(!is_in_math_block(content, 0)); // Before math block
750        assert!(!is_in_math_block(content, 30)); // After math block
751    }
752
753    #[test]
754    fn test_stray_double_dollar_in_prose_is_not_math() {
755        // Two `$$` tokens inside a prose line must NOT pair into a math block:
756        // a multi-line block only opens on a `$$` that begins its line. This
757        // keeps the byte-level result consistent with the line-level map.
758        let content = "Note: $$ is used for display math and $$ closes it";
759        let between = content.find("is used").unwrap();
760        assert!(
761            !is_in_math_block(content, between),
762            "stray paired `$$` in prose must not be treated as a math block"
763        );
764        assert!(math_block_ranges(content).is_empty());
765    }
766
767    #[test]
768    fn test_blockquoted_double_dollar_opens_block() {
769        // A `$$` opener is still recognized behind a blockquote prefix.
770        let content = "> $$\n> x = y\n> $$\n";
771        let inside = content.find("x = y").unwrap();
772        assert!(is_in_math_block(content, inside), "blockquoted math interior");
773    }
774
775    #[test]
776    fn test_self_contained_single_line_block_leaves_trailing_prose() {
777        // `$$ a $$` at line start is math; prose after the closing `$$` is not.
778        let content = "$$ a $$ and __not math__\n";
779        let in_math = content.find('a').unwrap();
780        assert!(is_in_math_block(content, in_math), "single-line math interior");
781        let after = content.find("not math").unwrap();
782        assert!(!is_in_math_block(content, after), "trailing prose is lintable");
783    }
784
785    #[test]
786    fn test_math_block_closes_with_content_before_fence() {
787        // A display-math block whose closing `$$` shares its line with
788        // content (e.g. `\end{aligned}$$`) must still close the block.
789        // Content after the block is prose, not math.
790        let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
791
792        let inside = content.find("x = y").unwrap();
793        assert!(is_in_math_block(content, inside), "interior must be math");
794
795        let after = content.find("after").unwrap();
796        assert!(
797            !is_in_math_block(content, after),
798            "content after a content-sharing closing fence must NOT be math"
799        );
800    }
801
802    #[test]
803    fn test_inline_math_detection() {
804        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
805        assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
806        // The mid-line `$$a^2 + b^2$$` is display syntax, not a line-start
807        // block, so it is a literal under the shared math model - neither the
808        // inline path nor `math_block_ranges` treats it as math.
809        assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
810        assert!(
811            !is_in_math_block(content, 20),
812            "mid-line $$...$$ is not a line-start display block"
813        );
814        assert!(!is_in_inline_math(content, 0), "before any math");
815        assert!(!is_in_inline_math(content, 35), "after the spans");
816    }
817
818    #[test]
819    fn test_table_line_detection() {
820        assert!(is_table_line("| Header | Column |"));
821        assert!(is_table_line("|--------|--------|"));
822        assert!(is_table_line("| Cell 1 | Cell 2 |"));
823        assert!(!is_table_line("Regular text"));
824        assert!(!is_table_line("Just a pipe | here"));
825    }
826
827    #[test]
828    fn test_is_in_icon_shortcode() {
829        let line = "Click :material-check: to confirm";
830        // Position 0-5 is "Click"
831        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
832        // Position 6-22 is ":material-check:"
833        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
834        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
835        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
836        // Position 22+ is " to confirm"
837        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
838    }
839
840    #[test]
841    fn test_is_in_pymdown_markup() {
842        // Test Keys notation
843        let line = "Press ++ctrl+c++ to copy";
844        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
845        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
846        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
847        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
848
849        // Test Mark notation
850        let line2 = "This is ==highlighted== text";
851        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
852        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
853        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
854        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
855
856        // Should not match for Standard flavor
857        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
858    }
859
860    #[test]
861    fn test_is_in_mkdocs_markup() {
862        // Should combine both icon and pymdown
863        let line = ":material-check: and ++ctrl++";
864        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
865        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
866        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
867    }
868
869    // ==================== Obsidian highlight tests ====================
870
871    #[test]
872    fn test_obsidian_highlight_basic() {
873        // Obsidian flavor should recognize ==highlight== syntax
874        let line = "This is ==highlighted== text";
875        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
876        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
877        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
878        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
879        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
880        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
881    }
882
883    #[test]
884    fn test_obsidian_highlight_multiple() {
885        // Multiple highlights on one line
886        let line = "Both ==one== and ==two== here";
887        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
888        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
889        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
890        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
891    }
892
893    #[test]
894    fn test_obsidian_highlight_not_standard_flavor() {
895        // Standard flavor should NOT recognize ==highlight== as special
896        let line = "This is ==highlighted== text";
897        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
898        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
899    }
900
901    #[test]
902    fn test_obsidian_highlight_with_spaces_inside() {
903        // Highlights can have spaces inside the content
904        let line = "This is ==text with spaces== here";
905        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
906        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
907        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
908    }
909
910    #[test]
911    fn test_obsidian_does_not_support_keys_notation() {
912        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
913        let line = "Press ++ctrl+c++ to copy";
914        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
915        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
916    }
917
918    #[test]
919    fn test_obsidian_mkdocs_markup_function() {
920        // is_in_mkdocs_markup should also work for Obsidian highlights
921        let line = "This is ==highlighted== text";
922        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
923        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
924    }
925
926    #[test]
927    fn test_obsidian_highlight_edge_cases() {
928        // Empty highlight (====) should not match
929        let line = "Test ==== here";
930        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
931        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
932
933        // Single character highlight
934        let line2 = "Test ==a== here";
935        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
936        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
937        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
938
939        // Triple equals (===) should not create highlight
940        let line3 = "a === b";
941        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
942    }
943
944    #[test]
945    fn test_obsidian_highlight_unclosed() {
946        // Unclosed highlight should not match
947        let line = "This ==starts but never ends";
948        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
949        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
950    }
951
952    #[test]
953    fn test_inline_html_code_basic() {
954        let line = "The formula is <code>a * b * c</code> in math.";
955        // Position inside <code> content
956        assert!(is_in_inline_html_code(line, 21)); // 'a'
957        assert!(is_in_inline_html_code(line, 25)); // '*'
958        // Position outside <code> content
959        assert!(!is_in_inline_html_code(line, 0)); // 'T'
960        assert!(!is_in_inline_html_code(line, 40)); // after </code>
961    }
962
963    #[test]
964    fn test_inline_html_code_multiple_tags() {
965        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
966        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
967        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
968        assert!(!is_in_inline_html_code(line, 16)); // '+'
969    }
970
971    #[test]
972    fn test_inline_html_code_with_attributes() {
973        let line = r#"<code class="lang">x * y</code>"#;
974        assert!(is_in_inline_html_code(line, 19)); // 'x'
975        assert!(is_in_inline_html_code(line, 23)); // '*'
976        assert!(!is_in_inline_html_code(line, 0)); // before tag
977    }
978
979    #[test]
980    fn test_inline_html_code_case_insensitive() {
981        let line = "<CODE>a * b</CODE>";
982        assert!(is_in_inline_html_code(line, 6)); // 'a'
983        assert!(is_in_inline_html_code(line, 8)); // '*'
984    }
985
986    #[test]
987    fn test_inline_html_code_var_and_pre() {
988        let line = "<var>x * y</var> and <pre>a * b</pre>";
989        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
990        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
991        assert!(!is_in_inline_html_code(line, 17)); // 'and'
992    }
993
994    #[test]
995    fn test_inline_html_code_unclosed() {
996        // Unclosed tag should not match
997        let line = "<code>a * b without closing";
998        assert!(!is_in_inline_html_code(line, 6));
999    }
1000
1001    #[test]
1002    fn test_inline_html_code_no_substring_match() {
1003        // <variable> should NOT be treated as <var>
1004        let line = "<variable>a * b</variable>";
1005        assert!(!is_in_inline_html_code(line, 11));
1006
1007        // <keyboard> should NOT be treated as <kbd>
1008        let line2 = "<keyboard>x * y</keyboard>";
1009        assert!(!is_in_inline_html_code(line2, 11));
1010    }
1011}