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::LintContext;
8use crate::utils::kramdown_utils::is_math_block_delimiter;
9use crate::utils::mkdocs_admonitions;
10use crate::utils::mkdocs_critic;
11use crate::utils::mkdocs_extensions;
12use crate::utils::mkdocs_footnotes;
13use crate::utils::mkdocs_icons;
14use crate::utils::mkdocs_snippets;
15use crate::utils::mkdocs_tabs;
16use crate::utils::regex_cache::HTML_COMMENT_PATTERN;
17use regex::Regex;
18use std::sync::LazyLock;
19
20/// Enhanced inline math pattern that handles both single $ and double $$ delimiters.
21/// Matches:
22/// - Display math: $$...$$ (zero or more non-$ characters)
23/// - Inline math: $...$ (zero or more non-$ non-newline characters)
24///
25/// The display math pattern is tried first to correctly handle $$content$$.
26/// Critically, both patterns allow ZERO characters between delimiters,
27/// so empty math like $$ or $ $ is consumed and won't pair with other $ signs.
28static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
29
30/// Range representing a span of bytes (start inclusive, end exclusive)
31#[derive(Debug, Clone, Copy)]
32pub struct ByteRange {
33    pub start: usize,
34    pub end: usize,
35}
36
37/// Pre-compute all HTML comment ranges in the content
38/// Returns a sorted vector of byte ranges for efficient lookup
39pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
40    HTML_COMMENT_PATTERN
41        .find_iter(content)
42        .map(|m| ByteRange {
43            start: m.start(),
44            end: m.end(),
45        })
46        .collect()
47}
48
49/// Check if a byte position is within any of the pre-computed HTML comment ranges
50/// Uses binary search for O(log n) complexity
51pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
52    // Binary search to find a range that might contain byte_pos
53    ranges
54        .binary_search_by(|range| {
55            if byte_pos < range.start {
56                std::cmp::Ordering::Greater
57            } else if byte_pos >= range.end {
58                std::cmp::Ordering::Less
59            } else {
60                std::cmp::Ordering::Equal
61            }
62        })
63        .is_ok()
64}
65
66/// Check if a line is ENTIRELY within a single HTML comment
67/// Returns true only if both the line start AND end are within the same comment range
68pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], line_start: usize, line_end: usize) -> bool {
69    for range in ranges {
70        // If line start is within this range, check if line end is also within it
71        if line_start >= range.start && line_start < range.end {
72            return line_end <= range.end;
73        }
74    }
75    false
76}
77
78/// Check if a byte position is within a JSX expression (MDX: {expression})
79#[inline]
80pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
81    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
82}
83
84/// Check if a byte position is within an MDX comment ({/* ... */})
85#[inline]
86pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
87    ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
88}
89
90/// Check if a line should be skipped due to MkDocs snippet syntax
91pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
92    flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
93}
94
95/// Check if a line is a MkDocs admonition marker
96pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
97    flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
98}
99
100/// Check if a line is a MkDocs footnote definition
101pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
102    flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
103}
104
105/// Check if a line is a MkDocs tab marker
106pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
107    flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
108}
109
110/// Check if a line contains MkDocs Critic Markup
111pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
112    flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
113}
114
115/// Check if a byte position is within an HTML comment
116pub fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
117    for m in HTML_COMMENT_PATTERN.find_iter(content) {
118        if m.start() <= byte_pos && byte_pos < m.end() {
119            return true;
120        }
121    }
122    false
123}
124
125/// Check if a byte position is within an HTML tag
126pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
127    for html_tag in ctx.html_tags().iter() {
128        if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
129            return true;
130        }
131    }
132    false
133}
134
135/// Check if a byte position is within a math context (block or inline)
136pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
137    let content = ctx.content;
138
139    // Check if we're in a math block
140    if is_in_math_block(content, byte_pos) {
141        return true;
142    }
143
144    // Check if we're in inline math
145    if is_in_inline_math(content, byte_pos) {
146        return true;
147    }
148
149    false
150}
151
152/// Check if a byte position is within a math block ($$...$$)
153pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
154    let mut in_math_block = false;
155    let mut current_pos = 0;
156
157    for line in content.lines() {
158        let line_start = current_pos;
159        let line_end = current_pos + line.len();
160
161        // Check if this line is a math block delimiter
162        if is_math_block_delimiter(line) {
163            if byte_pos >= line_start && byte_pos <= line_end {
164                // Position is on the delimiter line itself
165                return true;
166            }
167            in_math_block = !in_math_block;
168        } else if in_math_block && byte_pos >= line_start && byte_pos <= line_end {
169            // Position is inside a math block
170            return true;
171        }
172
173        current_pos = line_end + 1; // +1 for newline
174    }
175
176    false
177}
178
179/// Check if a byte position is within inline math ($...$)
180pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
181    // Find all inline math spans
182    for m in INLINE_MATH_REGEX.find_iter(content) {
183        if m.start() <= byte_pos && byte_pos < m.end() {
184            return true;
185        }
186    }
187    false
188}
189
190/// Check if a position is within a table cell
191pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
192    // Check if this line is part of a table
193    for table_row in ctx.table_rows().iter() {
194        if table_row.line == line_num {
195            // This line is part of a table
196            // For now, we'll skip the entire table row
197            // Future enhancement: check specific column boundaries
198            return true;
199        }
200    }
201    false
202}
203
204/// Check if a line contains table syntax
205pub fn is_table_line(line: &str) -> bool {
206    let trimmed = line.trim();
207
208    // Check for table separator line
209    if trimmed
210        .chars()
211        .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
212        && trimmed.contains('|')
213        && trimmed.contains('-')
214    {
215        return true;
216    }
217
218    // Check for table content line (starts and/or ends with |)
219    if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
220        return true;
221    }
222
223    false
224}
225
226/// Check if a byte position is within an MkDocs icon shortcode
227/// Icon shortcodes use format like `:material-check:`, `:octicons-mark-github-16:`
228pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
229    // Only skip for MkDocs flavor, but check pattern for all flavors
230    // since emoji shortcodes are universal
231    mkdocs_icons::is_in_any_shortcode(line, position)
232}
233
234/// Check if a byte position is within PyMdown extension markup
235/// Includes: Keys (++ctrl+alt++), Caret (^text^), Insert (^^text^^), Mark (==text==)
236///
237/// For MkDocs flavor: supports all PyMdown extensions
238/// For Obsidian flavor: only supports Mark (==highlight==) syntax
239pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
240    match flavor {
241        MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
242        MarkdownFlavor::Obsidian => {
243            // Obsidian supports ==highlight== syntax (same as PyMdown Mark)
244            mkdocs_extensions::is_in_mark(line, position)
245        }
246        _ => false,
247    }
248}
249
250/// Check whether a position on a line falls inside an inline HTML code-like element.
251///
252/// Handles `<code>`, `<pre>`, `<samp>`, `<kbd>`, and `<var>` tags (case-insensitive).
253/// These are inline elements whose content should not be interpreted as markdown emphasis.
254pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
255    // Tags whose content should not be parsed as markdown
256    const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
257
258    let bytes = line.as_bytes();
259
260    for tag in TAGS {
261        let open_bytes = format!("<{tag}").into_bytes();
262        let close_pattern = format!("</{tag}>").into_bytes();
263
264        let mut search_from = 0;
265        while search_from + open_bytes.len() <= bytes.len() {
266            // Find opening tag (case-insensitive byte search)
267            let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
268                break;
269            };
270
271            let after_tag = open_abs + open_bytes.len();
272
273            // Verify the character after the tag name is '>' or whitespace (not a longer tag name)
274            if after_tag < bytes.len() {
275                let next = bytes[after_tag];
276                if next != b'>' && next != b' ' && next != b'\t' {
277                    search_from = after_tag;
278                    continue;
279                }
280            }
281
282            // Find the end of the opening tag
283            let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
284                break;
285            };
286            let content_start = after_tag + tag_close + 1;
287
288            // Find the closing tag (case-insensitive)
289            let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
290                break;
291            };
292            let content_end = close_start;
293
294            if position >= content_start && position < content_end {
295                return true;
296            }
297
298            search_from = close_start + close_pattern.len();
299        }
300    }
301    false
302}
303
304/// Case-insensitive byte search within a slice, starting at `from`.
305fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
306    if needle.is_empty() || from + needle.len() > haystack.len() {
307        return None;
308    }
309    for i in from..=haystack.len() - needle.len() {
310        if haystack[i..i + needle.len()]
311            .iter()
312            .zip(needle.iter())
313            .all(|(h, n)| h.eq_ignore_ascii_case(n))
314        {
315            return Some(i);
316        }
317    }
318    None
319}
320
321/// Check if a byte position is within flavor-specific markup
322/// For MkDocs: icon shortcodes and PyMdown extensions
323/// For Obsidian: highlight syntax (==text==)
324pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
325    if is_in_icon_shortcode(line, position, flavor) {
326        return true;
327    }
328    if is_in_pymdown_markup(line, position, flavor) {
329        return true;
330    }
331    false
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_html_comment_detection() {
340        let content = "Text <!-- comment --> more text";
341        assert!(is_in_html_comment(content, 10)); // Inside comment
342        assert!(!is_in_html_comment(content, 0)); // Before comment
343        assert!(!is_in_html_comment(content, 25)); // After comment
344    }
345
346    #[test]
347    fn test_is_line_entirely_in_html_comment() {
348        // Test 1: Multi-line comment with content after closing
349        let content = "<!--\ncomment\n--> Content after comment";
350        let ranges = compute_html_comment_ranges(content);
351        // Line 0: "<!--" (bytes 0-4) - entirely in comment
352        assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
353        // Line 1: "comment" (bytes 5-12) - entirely in comment
354        assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
355        // Line 2: "--> Content after comment" (bytes 13-38) - NOT entirely in comment
356        assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
357
358        // Test 2: Single-line comment with content after
359        let content2 = "<!-- comment --> Not a comment";
360        let ranges2 = compute_html_comment_ranges(content2);
361        // The entire line is NOT entirely in the comment
362        assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
363
364        // Test 3: Single-line comment alone
365        let content3 = "<!-- comment -->";
366        let ranges3 = compute_html_comment_ranges(content3);
367        // The entire line IS entirely in the comment
368        assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
369
370        // Test 4: Content before comment
371        let content4 = "Text before <!-- comment -->";
372        let ranges4 = compute_html_comment_ranges(content4);
373        // Line start is NOT in the comment range
374        assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
375    }
376
377    #[test]
378    fn test_math_block_detection() {
379        let content = "Text\n$$\nmath content\n$$\nmore text";
380        assert!(is_in_math_block(content, 8)); // On opening $$
381        assert!(is_in_math_block(content, 15)); // Inside math block
382        assert!(!is_in_math_block(content, 0)); // Before math block
383        assert!(!is_in_math_block(content, 30)); // After math block
384    }
385
386    #[test]
387    fn test_inline_math_detection() {
388        let content = "Text $x + y$ and $$a^2 + b^2$$ here";
389        assert!(is_in_inline_math(content, 7)); // Inside first math
390        assert!(is_in_inline_math(content, 20)); // Inside second math
391        assert!(!is_in_inline_math(content, 0)); // Before math
392        assert!(!is_in_inline_math(content, 35)); // After math
393    }
394
395    #[test]
396    fn test_table_line_detection() {
397        assert!(is_table_line("| Header | Column |"));
398        assert!(is_table_line("|--------|--------|"));
399        assert!(is_table_line("| Cell 1 | Cell 2 |"));
400        assert!(!is_table_line("Regular text"));
401        assert!(!is_table_line("Just a pipe | here"));
402    }
403
404    #[test]
405    fn test_is_in_icon_shortcode() {
406        let line = "Click :material-check: to confirm";
407        // Position 0-5 is "Click"
408        assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
409        // Position 6-22 is ":material-check:"
410        assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
411        assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
412        assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
413        // Position 22+ is " to confirm"
414        assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
415    }
416
417    #[test]
418    fn test_is_in_pymdown_markup() {
419        // Test Keys notation
420        let line = "Press ++ctrl+c++ to copy";
421        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
422        assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
423        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
424        assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
425
426        // Test Mark notation
427        let line2 = "This is ==highlighted== text";
428        assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
429        assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
430        assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
431        assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
432
433        // Should not match for Standard flavor
434        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
435    }
436
437    #[test]
438    fn test_is_in_mkdocs_markup() {
439        // Should combine both icon and pymdown
440        let line = ":material-check: and ++ctrl++";
441        assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); // In icon
442        assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); // In keys
443        assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); // In " and "
444    }
445
446    // ==================== Obsidian highlight tests ====================
447
448    #[test]
449    fn test_obsidian_highlight_basic() {
450        // Obsidian flavor should recognize ==highlight== syntax
451        let line = "This is ==highlighted== text";
452        assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); // "T"
453        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // First "="
454        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "h"
455        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "g"
456        assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); // Last "="
457        assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); // " "
458    }
459
460    #[test]
461    fn test_obsidian_highlight_multiple() {
462        // Multiple highlights on one line
463        let line = "Both ==one== and ==two== here";
464        assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // In first
465        assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); // "o"
466        assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); // Space after
467        assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); // In second
468    }
469
470    #[test]
471    fn test_obsidian_highlight_not_standard_flavor() {
472        // Standard flavor should NOT recognize ==highlight== as special
473        let line = "This is ==highlighted== text";
474        assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
475        assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
476    }
477
478    #[test]
479    fn test_obsidian_highlight_with_spaces_inside() {
480        // Highlights can have spaces inside the content
481        let line = "This is ==text with spaces== here";
482        assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); // "t"
483        assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); // "w"
484        assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); // "="
485    }
486
487    #[test]
488    fn test_obsidian_does_not_support_keys_notation() {
489        // Obsidian flavor should NOT recognize ++keys++ syntax (that's MkDocs-specific)
490        let line = "Press ++ctrl+c++ to copy";
491        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
492        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
493    }
494
495    #[test]
496    fn test_obsidian_mkdocs_markup_function() {
497        // is_in_mkdocs_markup should also work for Obsidian highlights
498        let line = "This is ==highlighted== text";
499        assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); // In highlight
500        assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); // Not in highlight
501    }
502
503    #[test]
504    fn test_obsidian_highlight_edge_cases() {
505        // Empty highlight (====) should not match
506        let line = "Test ==== here";
507        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); // Position at first =
508        assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
509
510        // Single character highlight
511        let line2 = "Test ==a== here";
512        assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
513        assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); // "a"
514        assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); // last =
515
516        // Triple equals (===) should not create highlight
517        let line3 = "a === b";
518        assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
519    }
520
521    #[test]
522    fn test_obsidian_highlight_unclosed() {
523        // Unclosed highlight should not match
524        let line = "This ==starts but never ends";
525        assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
526        assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
527    }
528
529    #[test]
530    fn test_inline_html_code_basic() {
531        let line = "The formula is <code>a * b * c</code> in math.";
532        // Position inside <code> content
533        assert!(is_in_inline_html_code(line, 21)); // 'a'
534        assert!(is_in_inline_html_code(line, 25)); // '*'
535        // Position outside <code> content
536        assert!(!is_in_inline_html_code(line, 0)); // 'T'
537        assert!(!is_in_inline_html_code(line, 40)); // after </code>
538    }
539
540    #[test]
541    fn test_inline_html_code_multiple_tags() {
542        let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
543        assert!(is_in_inline_html_code(line, 5)); // 'C' in Ctrl
544        assert!(is_in_inline_html_code(line, 24)); // 'o' in output
545        assert!(!is_in_inline_html_code(line, 16)); // '+'
546    }
547
548    #[test]
549    fn test_inline_html_code_with_attributes() {
550        let line = r#"<code class="lang">x * y</code>"#;
551        assert!(is_in_inline_html_code(line, 19)); // 'x'
552        assert!(is_in_inline_html_code(line, 23)); // '*'
553        assert!(!is_in_inline_html_code(line, 0)); // before tag
554    }
555
556    #[test]
557    fn test_inline_html_code_case_insensitive() {
558        let line = "<CODE>a * b</CODE>";
559        assert!(is_in_inline_html_code(line, 6)); // 'a'
560        assert!(is_in_inline_html_code(line, 8)); // '*'
561    }
562
563    #[test]
564    fn test_inline_html_code_var_and_pre() {
565        let line = "<var>x * y</var> and <pre>a * b</pre>";
566        assert!(is_in_inline_html_code(line, 5)); // 'x' in var
567        assert!(is_in_inline_html_code(line, 26)); // 'a' in pre
568        assert!(!is_in_inline_html_code(line, 17)); // 'and'
569    }
570
571    #[test]
572    fn test_inline_html_code_unclosed() {
573        // Unclosed tag should not match
574        let line = "<code>a * b without closing";
575        assert!(!is_in_inline_html_code(line, 6));
576    }
577
578    #[test]
579    fn test_inline_html_code_no_substring_match() {
580        // <variable> should NOT be treated as <var>
581        let line = "<variable>a * b</variable>";
582        assert!(!is_in_inline_html_code(line, 11));
583
584        // <keyboard> should NOT be treated as <kbd>
585        let line2 = "<keyboard>x * y</keyboard>";
586        assert!(!is_in_inline_html_code(line2, 11));
587    }
588}