use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
use crate::utils::kramdown_utils::is_math_block_delimiter;
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 crate::utils::regex_cache::HTML_COMMENT_PATTERN;
use regex::Regex;
use std::sync::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,
}
pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
HTML_COMMENT_PATTERN
.find_iter(content)
.map(|m| ByteRange {
start: m.start(),
end: m.end(),
})
.collect()
}
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], line_start: usize, line_end: usize) -> bool {
for range in ranges {
if line_start >= range.start && line_start < range.end {
return line_end <= range.end;
}
}
false
}
pub fn is_in_front_matter(content: &str, line_num: usize) -> bool {
let lines: Vec<&str> = content.lines().collect();
if !lines.is_empty() && lines[0] == "---" {
for (i, line) in lines.iter().enumerate().skip(1) {
if *line == "---" {
return line_num <= i;
}
}
}
if !lines.is_empty() && lines[0] == "+++" {
for (i, line) in lines.iter().enumerate().skip(1) {
if *line == "+++" {
return line_num <= i;
}
}
}
false
}
#[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_comment(content: &str, byte_pos: usize) -> bool {
for m in HTML_COMMENT_PATTERN.find_iter(content) {
if m.start() <= byte_pos && byte_pos < m.end() {
return true;
}
}
false
}
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 {
let content = ctx.content;
if is_in_math_block(content, byte_pos) {
return true;
}
if is_in_inline_math(content, byte_pos) {
return true;
}
false
}
pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
let mut in_math_block = false;
let mut current_pos = 0;
for line in content.lines() {
let line_start = current_pos;
let line_end = current_pos + line.len();
if is_math_block_delimiter(line) {
if byte_pos >= line_start && byte_pos <= line_end {
return true;
}
in_math_block = !in_math_block;
} else if in_math_block && byte_pos >= line_start && byte_pos <= line_end {
return true;
}
current_pos = line_end + 1; }
false
}
pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
for m in INLINE_MATH_REGEX.find_iter(content) {
if m.start() <= byte_pos && byte_pos < m.end() {
return true;
}
}
false
}
pub fn is_in_table_cell(ctx: &LintContext, line_num: usize, _col: usize) -> bool {
for table_row in ctx.table_rows().iter() {
if table_row.line == line_num {
return true;
}
}
false
}
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 {
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
}
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_html_comment_detection() {
let content = "Text <!-- comment --> more text";
assert!(is_in_html_comment(content, 10)); assert!(!is_in_html_comment(content, 0)); assert!(!is_in_html_comment(content, 25)); }
#[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_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_inline_math_detection() {
let content = "Text $x + y$ and $$a^2 + b^2$$ here";
assert!(is_in_inline_math(content, 7)); assert!(is_in_inline_math(content, 20)); assert!(!is_in_inline_math(content, 0)); assert!(!is_in_inline_math(content, 35)); }
#[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_front_matter() {
let yaml_content = r#"---
title: "My Post"
tags: ["test", "example"]
---
# Content"#;
assert!(
is_in_front_matter(yaml_content, 0),
"Line 1 should be in YAML front matter"
);
assert!(
is_in_front_matter(yaml_content, 2),
"Line 3 should be in YAML front matter"
);
assert!(
is_in_front_matter(yaml_content, 3),
"Line 4 should be in YAML front matter"
);
assert!(
!is_in_front_matter(yaml_content, 4),
"Line 5 should NOT be in front matter"
);
let toml_content = r#"+++
title = "My Post"
tags = ["test", "example"]
+++
# Content"#;
assert!(
is_in_front_matter(toml_content, 0),
"Line 1 should be in TOML front matter"
);
assert!(
is_in_front_matter(toml_content, 2),
"Line 3 should be in TOML front matter"
);
assert!(
is_in_front_matter(toml_content, 3),
"Line 4 should be in TOML front matter"
);
assert!(
!is_in_front_matter(toml_content, 4),
"Line 5 should NOT be in front matter"
);
let mixed_content = r#"# Content
+++
title = "Not frontmatter"
+++
More content"#;
assert!(
!is_in_front_matter(mixed_content, 2),
"TOML block not at beginning should NOT be front matter"
);
assert!(
!is_in_front_matter(mixed_content, 3),
"TOML block not at beginning should NOT be front matter"
);
assert!(
!is_in_front_matter(mixed_content, 4),
"TOML block not at beginning should NOT be front matter"
);
}
#[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));
}
}