use crate::config::MarkdownFlavor;
use crate::lint_context::{HtmlTag, LintContext, SourcePositions};
use crate::utils::mkdocs_admonitions;
use crate::utils::mkdocs_critic;
use crate::utils::mkdocs_extensions;
use crate::utils::mkdocs_footnotes;
use crate::utils::mkdocs_icons;
use crate::utils::mkdocs_snippets;
use crate::utils::mkdocs_tabs;
use regex::Regex;
use std::cell::OnceCell;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
#[derive(Debug, Clone, Copy)]
pub struct ByteRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Default)]
pub struct HtmlCommentScan {
pub ranges: Vec<ByteRange>,
pub unterminated: Option<usize>,
}
pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
compute_html_comment_ranges_filtered(content, &[], &[])
}
pub fn compute_html_comment_ranges_filtered(
content: &str,
code_span_ranges: &[(usize, usize)],
code_block_ranges: &[(usize, usize)],
) -> Vec<ByteRange> {
scan_html_comments(content, code_span_ranges, code_block_ranges, 0).ranges
}
pub fn scan_html_comments(
content: &str,
code_span_ranges: &[(usize, usize)],
code_block_ranges: &[(usize, usize)],
scan_from: usize,
) -> HtmlCommentScan {
let code_ranges = OnceCell::new();
let in_code = |pos: usize| {
code_ranges
.get_or_init(|| SkipRegions::new(code_span_ranges.iter().chain(code_block_ranges).copied().collect()))
.contains(pos)
};
let mut ranges = Vec::new();
let mut search_from = scan_from.min(content.len());
while let Some(rel) = super::find_ascii_delimiter(&content[search_from..], "<!--") {
let open = search_from + rel;
if in_code(open) {
search_from = open + "<!--".len();
continue;
}
let mut close_from = open + 2;
let end = loop {
let Some(crel) = super::find_ascii_delimiter(&content[close_from..], "-->") else {
break None;
};
let close = close_from + crel;
if in_code(close) {
close_from = close + "-->".len();
continue;
}
break Some(close + "-->".len());
};
match end {
Some(end) => {
ranges.push(ByteRange { start: open, end });
search_from = end;
}
None => {
return HtmlCommentScan {
ranges,
unterminated: Some(open),
};
}
}
}
HtmlCommentScan {
ranges,
unterminated: None,
}
}
#[cfg(test)]
mod comment_tests;
pub fn unterminated_html_comment_outside(
reported: Option<usize>,
hidden_ranges: &[(usize, usize)],
content: &str,
code_span_ranges: &[(usize, usize)],
code_block_ranges: &[(usize, usize)],
scan_from: usize,
) -> Option<usize> {
let offset = reported?;
if !hidden_ranges
.iter()
.any(|&(start, end)| offset >= start && offset < end)
{
return reported;
}
let mut literal_ranges = code_block_ranges.to_vec();
literal_ranges.extend_from_slice(hidden_ranges);
scan_html_comments(content, code_span_ranges, &literal_ranges, scan_from).unterminated
}
pub fn unterminated_comment_range(opener: usize, html_blocks: &[(usize, usize)]) -> Option<ByteRange> {
html_blocks
.iter()
.find(|&&(start, end)| opener >= start && opener < end)
.map(|&(_, end)| ByteRange { start: opener, end })
}
pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
ranges
.binary_search_by(|range| {
if byte_pos < range.start {
std::cmp::Ordering::Greater
} else if byte_pos >= range.end {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.is_ok()
}
pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], content_start: usize, content_end: usize) -> bool {
for range in ranges {
if content_start >= range.start && content_start < range.end {
return content_end <= range.end;
}
}
false
}
pub(crate) fn is_line_entirely_in_sorted_html_comment(
ranges: &[ByteRange],
content_start: usize,
content_end: usize,
) -> bool {
let index = ranges.partition_point(|range| range.start <= content_start);
index > 0 && content_start < ranges[index - 1].end && content_end <= ranges[index - 1].end
}
#[inline]
pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
}
#[inline]
pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
}
pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
}
pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
}
pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
}
pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
}
pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
}
pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
for html_tag in ctx.html_tags().iter() {
if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
return true;
}
}
false
}
pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
ctx.math_byte_ranges()
.iter()
.any(|&(start, end)| byte_pos >= start && byte_pos < end)
}
pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
if !content.contains("$$") {
return Vec::new();
}
let bytes = content.as_bytes();
let mut ranges = Vec::new();
let mut open: Option<usize> = None;
let mut line_start = 0usize;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\n' => {
line_start = i + 1;
i += 1;
}
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
match open {
None => {
let starts_line = bytes[line_start..i]
.iter()
.all(|&b| b == b' ' || b == b'\t' || b == b'>');
if starts_line {
open = Some(i);
}
}
Some(start) => {
ranges.push((start, i + 2));
open = None;
}
}
i += 2;
}
_ => i += 1,
}
}
ranges
}
pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
math_block_ranges(content)
.iter()
.any(|&(start, end)| byte_pos >= start && byte_pos < end)
}
pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
for m in INLINE_MATH_REGEX.find_iter(content) {
if content[m.start()..m.end()].starts_with("$$") {
continue;
}
if m.start() <= byte_pos && byte_pos < m.end() {
return true;
}
}
false
}
pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
if !content.contains('$') {
return Vec::new();
}
let mut ranges = math_block_ranges(content);
for m in INLINE_MATH_REGEX.find_iter(content) {
if content[m.start()..m.end()].starts_with("$$") {
continue;
}
ranges.push((m.start(), m.end()));
}
ranges
}
pub fn is_table_line(line: &str) -> bool {
let trimmed = line.trim();
if trimmed
.chars()
.all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
&& trimmed.contains('|')
&& trimmed.contains('-')
{
return true;
}
if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
return true;
}
false
}
pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
mkdocs_icons::is_in_any_shortcode(line, position)
}
pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
match flavor {
MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
MarkdownFlavor::Obsidian => {
mkdocs_extensions::is_in_mark(line, position)
}
_ => false,
}
}
pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
visit_inline_html_code_ranges(line, |start, end| start <= position && position < end)
}
fn visit_inline_html_code_ranges(line: &str, mut visit: impl FnMut(usize, usize) -> bool) -> bool {
if !line.contains('<') {
return false;
}
const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
let bytes = line.as_bytes();
for tag in TAGS {
let open_bytes = format!("<{tag}").into_bytes();
let close_pattern = format!("</{tag}>").into_bytes();
let mut search_from = 0;
while search_from + open_bytes.len() <= bytes.len() {
let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
break;
};
let after_tag = open_abs + open_bytes.len();
if after_tag < bytes.len() {
let next = bytes[after_tag];
if next != b'>' && next != b' ' && next != b'\t' {
search_from = after_tag;
continue;
}
}
let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
break;
};
let content_start = after_tag + tag_close + 1;
let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
break;
};
let content_end = close_start;
if visit(content_start, content_end) {
return true;
}
search_from = close_start + close_pattern.len();
}
}
false
}
fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if needle.is_empty() || from + needle.len() > haystack.len() {
return None;
}
for i in from..=haystack.len() - needle.len() {
if haystack[i..i + needle.len()]
.iter()
.zip(needle.iter())
.all(|(h, n)| h.eq_ignore_ascii_case(n))
{
return Some(i);
}
}
None
}
pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
if is_in_icon_shortcode(line, position, flavor) {
return true;
}
if is_in_pymdown_markup(line, position, flavor) {
return true;
}
false
}
fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
visit_inline_code_ranges(line, |start, end| start <= byte_pos && byte_pos < end)
}
fn visit_inline_code_ranges(line: &str, mut visit: impl FnMut(usize, usize) -> bool) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'`' {
let open_start = i;
let mut backtick_count = 0;
while i < bytes.len() && bytes[i] == b'`' {
backtick_count += 1;
i += 1;
}
let mut j = i;
while j < bytes.len() {
if bytes[j] == b'`' {
let mut close_count = 0;
while j < bytes.len() && bytes[j] == b'`' {
close_count += 1;
j += 1;
}
if close_count == backtick_count {
if visit(open_start, j) {
return true;
}
i = j;
break;
}
} else {
j += 1;
}
}
if j >= bytes.len() {
break;
}
} else {
i += 1;
}
}
false
}
fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
idx > 0 && byte_pos < html_tags[idx - 1].byte_end
}
fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
idx > 0 && byte_pos < code_ranges[idx - 1].1
}
pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let mut open_code_end: Option<usize> = None;
for tag in html_tags {
if tag.tag_name == "code" {
if tag.is_self_closing {
continue;
} else if !tag.is_closing {
open_code_end = Some(tag.byte_end);
} else if tag.is_closing {
if let Some(start) = open_code_end {
ranges.push((start, tag.byte_offset));
}
open_code_end = None;
}
}
}
if let Some(start) = open_code_end {
ranges.push((start, usize::MAX));
}
ranges
}
pub(crate) fn mask_mdx_inline_code<'a>(ctx: &LintContext, source: &'a str, start: usize) -> std::borrow::Cow<'a, str> {
let end = start + source.len();
let mut ranges = ctx.mdx_inline_code_ranges(start, end).peekable();
if ranges.peek().is_none() {
return std::borrow::Cow::Borrowed(source);
}
let mut bytes = source.as_bytes().to_vec();
for (range_start, range_end) in ranges {
bytes[range_start.max(start) - start..range_end.min(end) - start].fill(b'X');
}
std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII masking preserves UTF-8"))
}
#[derive(Default)]
pub(crate) struct SkipRegions<T = usize>(Vec<(T, T)>);
impl<T: Ord + Copy> SkipRegions<T> {
pub(crate) fn new(mut ranges: Vec<(T, T)>) -> Self {
ranges.sort_unstable_by_key(|&(start, _)| start);
let mut end = None;
for range in &mut ranges {
let maximum = end.map_or(range.1, |previous: T| previous.max(range.1));
range.1 = maximum;
end = Some(maximum);
}
Self(ranges)
}
pub(crate) fn contains_interior(&self, position: T) -> bool {
let index = self.0.partition_point(|&(start, _)| start < position);
index > 0 && position < self.0[index - 1].1
}
fn contains(&self, position: T) -> bool {
let index = self.0.partition_point(|&(start, _)| start <= position);
index > 0 && position < self.0[index - 1].1
}
}
impl SkipRegions {
fn on_line(line: &str, flavor: MarkdownFlavor) -> Self {
let mut ranges = Vec::new();
visit_inline_code_ranges(line, |start, end| {
ranges.push((start, end));
false
});
append_markup_ranges(&mut ranges, line, flavor);
Self::new(ranges)
}
}
fn append_markup_ranges(ranges: &mut Vec<(usize, usize)>, line: &str, flavor: MarkdownFlavor) {
ranges.extend(mkdocs_icons::shortcode_spans(line));
match flavor {
MarkdownFlavor::MkDocs => ranges.extend(mkdocs_extensions::pymdown_spans(line)),
MarkdownFlavor::Obsidian => ranges.extend(mkdocs_extensions::find_mark_spans(line)),
_ => {}
}
}
pub(crate) struct MarkupExclusions<'ctx, 'src> {
ctx: &'ctx LintContext<'src>,
lines: HashMap<usize, SkipRegions>,
}
impl<'ctx, 'src> MarkupExclusions<'ctx, 'src> {
pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
Self {
ctx,
lines: HashMap::new(),
}
}
pub(crate) fn contains(&mut self, index: usize, byte: usize) -> bool {
let Some(line) = self.ctx.raw_lines().get(index) else {
return false;
};
if line.len() < 256 {
return is_in_mkdocs_markup(line, byte, self.ctx.flavor);
}
self.lines
.entry(index)
.or_insert_with(|| {
let mut ranges = Vec::new();
append_markup_ranges(&mut ranges, line, self.ctx.flavor);
SkipRegions::new(ranges)
})
.contains(byte)
}
}
pub(crate) struct SpacingExclusions<'ctx, 'src> {
ctx: &'ctx LintContext<'src>,
lines: HashMap<usize, SkipRegions>,
math: OnceCell<SkipRegions>,
images: OnceCell<SkipRegions>,
code: OnceCell<SkipRegions<(usize, usize)>>,
}
impl<'ctx, 'src> SpacingExclusions<'ctx, 'src> {
pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
Self {
ctx,
lines: HashMap::new(),
math: OnceCell::new(),
images: OnceCell::new(),
code: OnceCell::new(),
}
}
pub(crate) fn in_image(&self, byte: usize) -> bool {
self.images
.get_or_init(|| {
SkipRegions::new(
self.ctx
.images()
.iter()
.map(|image| (image.byte_offset, image.byte_end))
.collect(),
)
})
.contains(byte)
}
pub(crate) fn in_math(&self, byte: usize) -> bool {
self.math
.get_or_init(|| SkipRegions::new(self.ctx.math_byte_ranges().to_vec()))
.contains(byte)
}
pub(crate) fn in_code_span(&self, line: usize, column: usize) -> bool {
if line == 0 || line > self.ctx.lines.len() {
return false;
}
self.code
.get_or_init(|| {
SkipRegions::new(
self.ctx
.source_code_spans()
.iter()
.map(|span| ((span.line, span.start_col), (span.end_line, span.end_col)))
.collect(),
)
})
.contains((line, column.saturating_sub(1)))
}
pub(crate) fn in_html_code_or_markup(&mut self, index: usize, byte: usize) -> bool {
let Some(line) = self.ctx.raw_lines().get(index) else {
return false;
};
if line.len() < 256 {
return is_in_inline_html_code(line, byte) || is_in_mkdocs_markup(line, byte, self.ctx.flavor);
}
self.lines
.entry(index)
.or_insert_with(|| {
let mut ranges = Vec::new();
visit_inline_html_code_ranges(line, |start, end| {
ranges.push((start, end));
false
});
append_markup_ranges(&mut ranges, line, self.ctx.flavor);
SkipRegions::new(ranges)
})
.contains(byte)
}
}
pub(crate) struct EmphasisContext<'ctx, 'src> {
ctx: &'ctx LintContext<'src>,
positions: SourcePositions<'ctx>,
html_tags: Arc<Vec<HtmlTag>>,
html_code_ranges: Vec<(usize, usize)>,
long_lines: HashMap<usize, SkipRegions>,
math: OnceCell<SkipRegions>,
}
impl<'ctx, 'src> EmphasisContext<'ctx, 'src> {
pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
let html_tags = ctx.html_tags();
let html_code_ranges = compute_html_code_ranges(&html_tags);
Self {
ctx,
positions: SourcePositions::new(ctx.content, &ctx.lines),
html_tags,
html_code_ranges,
long_lines: HashMap::new(),
math: OnceCell::new(),
}
}
pub(crate) fn single_line_range(&mut self, start: usize, end: usize) -> (usize, usize, usize, usize) {
let (index, line, column) = self.positions.position(start);
(line, column + 1, line, self.positions.column_on_line(index, end) + 1)
}
pub(crate) fn should_skip(&mut self, span_start: usize, span_end: usize) -> bool {
let ctx = self.ctx;
let (line_index, line_num, column) = self.positions.position(span_start);
if ctx.line_info(line_num).is_some_and(|info| {
info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_comment
}) {
return true;
}
let in_line_markup = ctx.raw_lines().get(line_index).is_some_and(|line| {
if line.len() < 256 {
is_in_mkdocs_markup(line, column, ctx.flavor) || is_in_inline_code_on_line(line, column)
} else {
self.long_lines
.entry(line_index)
.or_insert_with(|| SkipRegions::on_line(line, ctx.flavor))
.contains(column)
}
});
ctx.is_in_jinja_string(span_start)
|| ctx.is_in_jinja_string(span_end.saturating_sub(1))
|| ctx.is_in_shortcode(span_start)
|| ctx.is_in_shortcode(span_end.saturating_sub(1))
|| ctx.is_in_code_block_or_span(span_start)
|| in_line_markup
|| ctx.is_in_link(span_start)
|| is_byte_in_html_tag(&self.html_tags, span_start)
|| ctx.is_in_jsx_component_tag(span_start)
|| ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
|| is_byte_in_html_code_content(&self.html_code_ranges, span_start)
|| self
.math
.get_or_init(|| SkipRegions::new(ctx.math_byte_ranges().to_vec()))
.contains(span_start)
|| is_in_jsx_expression(ctx, span_start)
|| is_in_mdx_comment(ctx, span_start)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_regions_preserve_endpoints_and_extreme_offsets() {
let ranges = vec![
(20, 30),
(4, 4),
(10, 3),
(0, 2),
(2, 5),
(1, 8),
(20, 22),
(usize::MAX - 2, usize::MAX),
];
let index = SkipRegions::new(ranges.clone());
for position in (0..=32).chain([usize::MAX - 2, usize::MAX - 1, usize::MAX]) {
assert_eq!(
index.contains_interior(position),
ranges.iter().any(|&(start, end)| start < position && position < end)
);
}
}
proptest::proptest! {
#[test]
fn strict_regions_match_scalar_membership(ranges in proptest::collection::vec((0usize..128, 0usize..128), 0..60)) {
let index = SkipRegions::new(ranges.clone());
for position in 0..=129 {
proptest::prop_assert_eq!(index.contains_interior(position), ranges.iter().any(|&(start, end)| start < position && position < end));
}
}
}
#[test]
fn markup_exclusions_match_scalar_at_each_byte() {
for padding in [0, 40] {
let prefix = "é日本🙂e\u{301}\t".repeat(padding);
let source = format!(
"{prefix}:smile: :material-check: :icon_with_underscore: ^^insert^^ ^super^ ~sub~ ~~strike~~\n\
{prefix}==mark== ++ctrl+alt++ {{--delete--}} {{++add++}} \\==escaped== ^unclosed\n"
);
for flavor in crate::format_proof::ALL_FLAVORS {
for ending in ["\n", "\r\n"] {
let content = source.replace('\n', ending);
let ctx = LintContext::new(&content, flavor, None);
let mut index = MarkupExclusions::new(&ctx);
for (line_index, line) in ctx.raw_lines().iter().enumerate().rev() {
for byte in (0..=line.len() + 1).rev() {
assert_eq!(
index.contains(line_index, byte),
is_in_mkdocs_markup(line, byte, flavor),
"{flavor:?}, line={line_index}, byte={byte}",
);
}
}
assert!(!index.contains(ctx.raw_lines().len(), 0));
if padding == 0 {
assert!(index.lines.is_empty(), "short lines should not allocate indexes");
}
}
}
}
}
fn legacy_should_skip_emphasis_span(
ctx: &LintContext,
html_tags: &[HtmlTag],
html_code_ranges: &[(usize, usize)],
span_start: usize,
span_end: usize,
) -> bool {
let lines = ctx.raw_lines();
let (line_num, col) = ctx.offset_to_line_col(span_start);
if ctx.line_info(line_num).is_some_and(|info| {
info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_comment
}) {
return true;
}
let in_mkdocs_markup = lines
.get(line_num.saturating_sub(1))
.is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
let in_inline_code = lines
.get(line_num.saturating_sub(1))
.is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
ctx.is_in_jinja_string(span_start)
|| ctx.is_in_jinja_string(span_end.saturating_sub(1))
|| ctx.is_in_shortcode(span_start)
|| ctx.is_in_shortcode(span_end.saturating_sub(1))
|| ctx.is_in_code_block_or_span(span_start)
|| in_inline_code
|| ctx.is_in_link(span_start)
|| is_byte_in_html_tag(html_tags, span_start)
|| ctx.is_in_jsx_component_tag(span_start)
|| ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
|| is_byte_in_html_code_content(html_code_ranges, span_start)
|| in_mkdocs_markup
|| is_in_math_context(ctx, span_start)
|| is_in_jsx_expression(ctx, span_start)
|| is_in_mdx_comment(ctx, span_start)
}
fn legacy_inline_html_code(line: &str, position: usize) -> bool {
const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
let bytes = line.as_bytes();
for tag in TAGS {
let open_bytes = format!("<{tag}").into_bytes();
let close_pattern = format!("</{tag}>").into_bytes();
let mut search_from = 0;
while search_from + open_bytes.len() <= bytes.len() {
let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
break;
};
let after_tag = open_abs + open_bytes.len();
if after_tag < bytes.len() {
let next = bytes[after_tag];
if next != b'>' && next != b' ' && next != b'\t' {
search_from = after_tag;
continue;
}
}
let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
break;
};
let content_start = after_tag + tag_close + 1;
let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
break;
};
let content_end = close_start;
if position >= content_start && position < content_end {
return true;
}
search_from = close_start + close_pattern.len();
}
}
false
}
#[test]
fn spacing_exclusions_preserve_scalar_membership() {
let prefix = "é日本🙂e\u{301} ".repeat(40);
let source = format!(
"{prefix}<code>a <code>b</code> c</code> <VAR x='>'>literal</VAR> :smile: ^^insert^^ ==mark== ++ctrl+alt++ ^super^ ~sub~ ~~strike~~ {{--delete--}}
{prefix} [](z) $x$ `é` ``a ` b``
`multi
line
code` text
"
);
for flavor in crate::format_proof::ALL_FLAVORS {
for ending in ["\n", "\r\n"] {
let content = source.replace('\n', ending);
let ctx = LintContext::new(&content, flavor, None);
let mut index = SpacingExclusions::new(&ctx);
for byte in (0..=content.len() + 1).rev() {
assert_eq!(index.in_math(byte), is_in_math_context(&ctx, byte));
assert_eq!(
index.in_image(byte),
ctx.images().iter().any(|s| s.byte_offset <= byte && byte < s.byte_end)
);
}
for line in 0..=ctx.lines.len() + 1 {
let width = line
.checked_sub(1)
.and_then(|i| ctx.raw_lines().get(i))
.map_or(0, |s| s.chars().count());
for column in (0..=width + 2).rev() {
assert_eq!(
index.in_code_span(line, column),
ctx.is_in_code_span(line, column),
"line={line}, column={column}"
);
}
}
for (i, line) in ctx.raw_lines().iter().enumerate().rev() {
for byte in (0..=line.len() + 1).rev() {
assert_eq!(
index.in_html_code_or_markup(i, byte),
legacy_inline_html_code(line, byte) || is_in_mkdocs_markup(line, byte, flavor)
);
}
}
}
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(128))]
#[test]
fn indexed_inline_html_matches_legacy(
tokens in proptest::collection::vec(proptest::sample::select(vec!["text", "é日🙂", "<code>", "</code>", "<CODE x='>'>", "<codeword>", "<code/>", "<code", "<pre>", "</pre>", "<var>", "</VAR>", "<samp>", "</samp>", "<kbd\t>", "</kbd>", ">", "<", "\n"]), 0..60),
) {
let source = tokens.concat();
let mut ranges = Vec::new();
visit_inline_html_code_ranges(&source, |start, end| { ranges.push((start, end)); false });
let index = SkipRegions::new(ranges);
for byte in 0..=source.len() + 1 {
proptest::prop_assert_eq!(index.contains(byte), legacy_inline_html_code(&source, byte));
proptest::prop_assert_eq!(is_in_inline_html_code(&source, byte), legacy_inline_html_code(&source, byte));
}
}
}
#[test]
fn inline_code_range_visitor_preserves_boundaries() {
for (line, expected) in [
("plain", vec![]),
("`x`", vec![(0, 3)]),
("a `x` b ``y``", vec![(2, 5), (8, 13)]),
("`x `` y` z", vec![(0, 8)]),
("```unclosed `paired`", vec![]),
("`x` ``unclosed", vec![(0, 3)]),
("é `日`", vec![(3, 8)]),
] {
let mut ranges = Vec::new();
visit_inline_code_ranges(line, |start, end| {
ranges.push((start, end));
false
});
assert_eq!(ranges, expected, "{line:?}");
for pos in 0..=line.len() + 1 {
assert_eq!(
is_in_inline_code_on_line(line, pos),
expected.iter().any(|&(a, b)| a <= pos && pos < b)
);
}
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn indexed_line_regions_match_legacy_predicates(
tokens in proptest::collection::vec(0usize..18, 0..80),
queries in proptest::collection::vec(0usize..10000, 1..40),
) {
let choices = ["plain ", "é日本🙂 ", "`code` ", "``x ` y`` ", "`", ":smile:", ":material-check:",
"++ctrl+alt++", "^^insert^^", "^super^", "~~strike~~", "~sub~", "==mark==", "...", "(c)", "**bold**", "{>>comment<<}", "{~~a~>b~~}"];
let line: String = tokens.into_iter().map(|i| choices[i]).collect();
for flavor in crate::format_proof::ALL_FLAVORS {
let index = SkipRegions::on_line(&line, flavor);
for pos in queries.iter().map(|q| q % (line.len() + 2)).chain([0, line.len()]) {
proptest::prop_assert_eq!(index.contains(pos),
is_in_inline_code_on_line(&line, pos) || is_in_mkdocs_markup(&line, pos, flavor));
}
}
}
}
#[test]
fn emphasis_context_preserves_skip_decisions_and_clipped_ranges() {
let prefix = "é日本🙂e\u{301} ".repeat(40);
let source = format!(
"---\ntitle: '**frontmatter**'\n---\n\n\
{prefix}**good** __other__ `**literal**` :icon_with_underscore: $x$ **end**\n\n\
{prefix}^^insert^^ ^super^ ++ctrl+alt++ ==mark== ~~strike~~ ~sub~ ... __visible__\n\n\
{prefix}**multi\nline** __multi\nline__ [**link**](target) <code>__code__</code>\n\n\
| a | b |\n| - | - |\n| `x|__code__` | **text** |\n\n\
!!! note\n\n **note** __note__\n\n\
{{/* **comment** */}}\n\n{{{{< note title=\"**literal**\" >}}}} __last__\n"
);
for flavor in crate::format_proof::ALL_FLAVORS {
for newline in ["\n", "\r\n"] {
let content = source.replace('\n', newline);
let ctx = LintContext::new(&content, flavor, None);
let mut indexed = EmphasisContext::new(&ctx);
let html_tags = ctx.html_tags();
let html_code_ranges = compute_html_code_ranges(&html_tags);
let mut spans: Vec<_> = ctx.strong_spans.iter().map(|s| (s.start, s.end)).collect();
spans.extend(ctx.source_emphasis_spans().iter().map(|s| (s.byte_offset, s.byte_end)));
assert!(!spans.is_empty());
for (start, end) in spans.iter().copied().chain(spans.iter().copied().rev()) {
assert_eq!(
indexed.should_skip(start, end),
legacy_should_skip_emphasis_span(&ctx, &html_tags, &html_code_ranges, start, end),
"{flavor:?} at {start}..{end}"
);
let (line, _) = ctx.offset_to_line_col(start);
let line_start = ctx.line_start_byte(line).unwrap();
let text = ctx.raw_lines()[line - 1];
assert_eq!(
indexed.single_line_range(start, end),
crate::utils::range_utils::calculate_match_range(line, text, start - line_start, end - start),
"{flavor:?} at {start}..{end}"
);
}
}
}
}
#[test]
fn test_html_comment_detection() {
let content = "Text <!-- comment --> more text";
let ranges = compute_html_comment_ranges(content);
assert!(is_in_html_comment_ranges(&ranges, 10)); assert!(!is_in_html_comment_ranges(&ranges, 0)); assert!(!is_in_html_comment_ranges(&ranges, 25)); }
#[test]
fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
let content = "a `<!--` b\n\nc `-->` d";
let open = content.find("<!--").unwrap();
let close = content.find("-->").unwrap();
let code_spans = [
(content.find('`').unwrap(), open + "<!--".len() + 1),
(content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
];
assert!(
!compute_html_comment_ranges(content).is_empty(),
"sanity: raw pattern matches across the code spans"
);
assert!(
compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
"a `<!--`/`-->` pair inside code spans must not be treated as a comment"
);
}
#[test]
fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
let block_end = content.find("```\n\n").unwrap() + "```".len();
let code_blocks = [(0usize, block_end)];
assert!(
compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
"a `<!--` inside a code block must not open a comment that spans to a later `-->`"
);
let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
let real_block_end = real.find("```\n\n").unwrap() + "```".len();
let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
}
#[test]
fn test_compute_html_comment_ranges_keeps_real_comments() {
let content = "text `code` <!-- real comment --> more";
let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
assert_eq!(ranges.len(), 1, "the real comment must still be detected");
let comment_start = content.find("<!--").unwrap();
assert_eq!(ranges[0].start, comment_start);
}
#[test]
fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
let content = "a `<!--` then <!-- real --> end";
let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
assert_eq!(
ranges.len(),
1,
"the real comment after a code-span opener must be detected"
);
let real_open = content.find("<!-- real").unwrap();
assert_eq!(
ranges[0].start, real_open,
"range must start at the real comment, not the code-span opener"
);
assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
}
#[test]
fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
let content = "<!-- open `-->` still open --> done";
let first_close = content.find("`-->`").unwrap() + 1;
let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
let real_close_end = content.find("--> done").unwrap() + "-->".len();
assert_eq!(
ranges[0].end, real_close_end,
"must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
);
}
#[test]
fn test_scan_html_comments_starts_at_the_given_offset() {
let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
let body_start = content.rfind("---\n").unwrap() + "---\n".len();
let scan = scan_html_comments(content, &[], &[], body_start);
assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
assert_eq!(scan.unterminated, None);
let unscoped = scan_html_comments(content, &[], &[], 0);
assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
}
#[test]
fn test_scan_html_comments_reports_the_unterminated_opener() {
let content = "<!-- closed --> text <!-- open";
let scan = scan_html_comments(content, &[], &[], 0);
assert_eq!(scan.ranges.len(), 1);
assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
assert_eq!(closed.unterminated, None);
}
#[test]
fn test_unterminated_html_comment_outside_hidden_ranges() {
let content = "%% note <!-- marker %%\n\n<!-- real\n";
let hidden = [(0, content.find("\n\n").unwrap())];
let reported = scan_html_comments(content, &[], &[], 0).unterminated;
assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
assert_eq!(
unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
Some(content.rfind("<!--").unwrap())
);
let only_hidden = "%% note <!-- marker %%\n";
let hidden_all = [(0, only_hidden.len())];
let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
assert!(reported.is_some(), "the scan sees the hidden opener");
assert_eq!(
unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
None
);
let visible = "<!-- open\n\n%% a note %%\n";
let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
assert_eq!(
unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
Some(0)
);
}
#[test]
fn test_unterminated_comment_range_follows_the_block() {
let blocks = [(0, 40)];
let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
assert_eq!((range.start, range.end), (0, 40));
let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
assert_eq!((range.start, range.end), (7, 40));
assert!(unterminated_comment_range(60, &blocks).is_none());
assert!(unterminated_comment_range(0, &[]).is_none());
assert!(unterminated_comment_range(40, &blocks).is_none());
}
#[test]
fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
for (content, expected_end) in [
("<!--> text", 5),
("<!---> text", 6),
("<!----> text", 7),
("<!-- x --> text", 10),
] {
let ranges = compute_html_comment_ranges(content);
assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
assert_eq!(ranges[0].start, 0);
assert_eq!(
ranges[0].end, expected_end,
"{content:?} ends its comment at the first -->"
);
}
}
#[test]
fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
let content = "<!--> visible <!-- hidden --> visible";
let ranges = compute_html_comment_ranges(content);
assert_eq!(ranges.len(), 2);
assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
assert_eq!(
(ranges[1].start, ranges[1].end),
(content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
);
}
#[test]
fn test_is_line_entirely_in_html_comment() {
let content = "<!--\ncomment\n--> Content after comment";
let ranges = compute_html_comment_ranges(content);
assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
let content2 = "<!-- comment --> Not a comment";
let ranges2 = compute_html_comment_ranges(content2);
assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
let content3 = "<!-- comment -->";
let ranges3 = compute_html_comment_ranges(content3);
assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
let content4 = "Text before <!-- comment -->";
let ranges4 = compute_html_comment_ranges(content4);
assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
}
#[test]
fn test_is_line_entirely_in_html_comment_indented() {
let content = " <!-- comment -->";
let ranges = compute_html_comment_ranges(content);
let content_start = content.find("<!--").unwrap();
let content_end = content.trim_end().len();
assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
}
#[test]
fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
let content = "<!-- comment --> ";
let ranges = compute_html_comment_ranges(content);
let content_end = content.trim_end().len();
assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
}
#[test]
fn test_math_block_detection() {
let content = "Text\n$$\nmath content\n$$\nmore text";
assert!(is_in_math_block(content, 8)); assert!(is_in_math_block(content, 15)); assert!(!is_in_math_block(content, 0)); assert!(!is_in_math_block(content, 30)); }
#[test]
fn test_stray_double_dollar_in_prose_is_not_math() {
let content = "Note: $$ is used for display math and $$ closes it";
let between = content.find("is used").unwrap();
assert!(
!is_in_math_block(content, between),
"stray paired `$$` in prose must not be treated as a math block"
);
assert!(math_block_ranges(content).is_empty());
}
#[test]
fn test_blockquoted_double_dollar_opens_block() {
let content = "> $$\n> x = y\n> $$\n";
let inside = content.find("x = y").unwrap();
assert!(is_in_math_block(content, inside), "blockquoted math interior");
}
#[test]
fn test_self_contained_single_line_block_leaves_trailing_prose() {
let content = "$$ a $$ and __not math__\n";
let in_math = content.find('a').unwrap();
assert!(is_in_math_block(content, in_math), "single-line math interior");
let after = content.find("not math").unwrap();
assert!(!is_in_math_block(content, after), "trailing prose is lintable");
}
#[test]
fn test_math_block_closes_with_content_before_fence() {
let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
let inside = content.find("x = y").unwrap();
assert!(is_in_math_block(content, inside), "interior must be math");
let after = content.find("after").unwrap();
assert!(
!is_in_math_block(content, after),
"content after a content-sharing closing fence must NOT be math"
);
}
#[test]
fn test_inline_math_detection() {
let content = "Text $x + y$ and $$a^2 + b^2$$ here";
assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
assert!(
!is_in_math_block(content, 20),
"mid-line $$...$$ is not a line-start display block"
);
assert!(!is_in_inline_math(content, 0), "before any math");
assert!(!is_in_inline_math(content, 35), "after the spans");
}
#[test]
fn test_table_line_detection() {
assert!(is_table_line("| Header | Column |"));
assert!(is_table_line("|--------|--------|"));
assert!(is_table_line("| Cell 1 | Cell 2 |"));
assert!(!is_table_line("Regular text"));
assert!(!is_table_line("Just a pipe | here"));
}
#[test]
fn test_is_in_icon_shortcode() {
let line = "Click :material-check: to confirm";
assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
}
#[test]
fn test_is_in_pymdown_markup() {
let line = "Press ++ctrl+c++ to copy";
assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
let line2 = "This is ==highlighted== text";
assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
}
#[test]
fn test_is_in_mkdocs_markup() {
let line = ":material-check: and ++ctrl++";
assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); }
#[test]
fn test_obsidian_highlight_basic() {
let line = "This is ==highlighted== text";
assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); }
#[test]
fn test_obsidian_highlight_multiple() {
let line = "Both ==one== and ==two== here";
assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); }
#[test]
fn test_obsidian_highlight_not_standard_flavor() {
let line = "This is ==highlighted== text";
assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
}
#[test]
fn test_obsidian_highlight_with_spaces_inside() {
let line = "This is ==text with spaces== here";
assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); }
#[test]
fn test_obsidian_does_not_support_keys_notation() {
let line = "Press ++ctrl+c++ to copy";
assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
}
#[test]
fn test_obsidian_mkdocs_markup_function() {
let line = "This is ==highlighted== text";
assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
#[test]
fn test_obsidian_highlight_edge_cases() {
let line = "Test ==== here";
assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
let line2 = "Test ==a== here";
assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian));
let line3 = "a === b";
assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
}
#[test]
fn test_obsidian_highlight_unclosed() {
let line = "This ==starts but never ends";
assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
}
#[test]
fn test_inline_html_code_basic() {
let line = "The formula is <code>a * b * c</code> in math.";
assert!(is_in_inline_html_code(line, 21)); assert!(is_in_inline_html_code(line, 25)); assert!(!is_in_inline_html_code(line, 0)); assert!(!is_in_inline_html_code(line, 40)); }
#[test]
fn test_inline_html_code_multiple_tags() {
let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 24)); assert!(!is_in_inline_html_code(line, 16)); }
#[test]
fn test_inline_html_code_with_attributes() {
let line = r#"<code class="lang">x * y</code>"#;
assert!(is_in_inline_html_code(line, 19)); assert!(is_in_inline_html_code(line, 23)); assert!(!is_in_inline_html_code(line, 0)); }
#[test]
fn test_inline_html_code_case_insensitive() {
let line = "<CODE>a * b</CODE>";
assert!(is_in_inline_html_code(line, 6)); assert!(is_in_inline_html_code(line, 8)); }
#[test]
fn test_inline_html_code_var_and_pre() {
let line = "<var>x * y</var> and <pre>a * b</pre>";
assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 26)); assert!(!is_in_inline_html_code(line, 17)); }
#[test]
fn test_inline_html_code_unclosed() {
let line = "<code>a * b without closing";
assert!(!is_in_inline_html_code(line, 6));
}
#[test]
fn test_inline_html_code_no_substring_match() {
let line = "<variable>a * b</variable>";
assert!(!is_in_inline_html_code(line, 11));
let line2 = "<keyboard>x * y</keyboard>";
assert!(!is_in_inline_html_code(line2, 11));
}
}