use crate::config::MarkdownFlavor;
use crate::utils::mkdocs_html_markdown::MarkdownHtmlTracker;
use super::ByteRanges;
use super::types::*;
struct FencedCodeTracker {
in_fenced_code: bool,
fence_marker: Option<String>,
}
impl FencedCodeTracker {
fn new() -> Self {
Self {
in_fenced_code: false,
fence_marker: None,
}
}
fn process_line(&mut self, trimmed: &str) -> bool {
if !self.in_fenced_code {
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence_char = trimmed.chars().next().unwrap();
let fence_len = trimmed.chars().take_while(|&c| c == fence_char).count();
if fence_len >= 3 {
self.in_fenced_code = true;
self.fence_marker = Some(fence_char.to_string().repeat(fence_len));
}
}
self.in_fenced_code
} else if let Some(ref marker) = self.fence_marker {
let fence_char = marker.chars().next().unwrap();
if trimmed.starts_with(marker.as_str())
&& trimmed
.chars()
.skip(marker.len())
.all(|c| c == fence_char || c.is_whitespace())
{
self.in_fenced_code = false;
self.fence_marker = None;
return true;
}
true
} else {
self.in_fenced_code
}
}
fn reset(&mut self) {
self.in_fenced_code = false;
self.fence_marker = None;
}
}
pub(super) fn detect_esm_blocks(content: &str, lines: &mut [LineInfo], flavor: MarkdownFlavor) {
if !flavor.supports_esm_blocks() {
return;
}
let mut in_multiline_import = false;
for line in lines.iter_mut() {
if line.in_code_block || line.in_front_matter || line.in_html_comment {
in_multiline_import = false;
continue;
}
let line_content = line.content(content);
let trimmed = line_content.trim();
if in_multiline_import {
line.in_esm_block = true;
if trimmed.ends_with('\'')
|| trimmed.ends_with('"')
|| trimmed.ends_with("';")
|| trimmed.ends_with("\";")
|| line_content.contains(';')
{
in_multiline_import = false;
}
continue;
}
if line.is_blank {
continue;
}
if trimmed.starts_with("import ") || trimmed.starts_with("export ") {
line.in_esm_block = true;
let is_import = trimmed.starts_with("import ");
let is_complete =
trimmed.ends_with(';')
|| (trimmed.contains(" from ") && (trimmed.ends_with('\'') || trimmed.ends_with('"')))
|| (!is_import && !trimmed.contains(" from ") && (
trimmed.starts_with("export const ")
|| trimmed.starts_with("export let ")
|| trimmed.starts_with("export var ")
|| trimmed.starts_with("export function ")
|| trimmed.starts_with("export class ")
|| trimmed.starts_with("export default ")
));
if !is_complete && is_import {
if trimmed.contains('{') && !trimmed.contains('}') {
in_multiline_import = true;
}
}
}
}
}
pub(super) fn detect_jsx_blocks(content: &str, lines: &mut [LineInfo], flavor: MarkdownFlavor) {
if !flavor.supports_jsx() {
return;
}
let mut tag_stack: Vec<(String, usize)> = Vec::new();
for i in 0..lines.len() {
if lines[i].in_front_matter || lines[i].in_html_comment {
continue;
}
let line_content = lines[i].content(content);
let trimmed = line_content.trim();
if lines[i].in_code_block && !trimmed.contains('<') {
continue;
}
for tag in scan_jsx_tags(trimmed) {
if tag.is_self_closing {
lines[i].in_jsx_block = true;
continue;
}
if tag.is_closing {
if let Some(pos) = tag_stack.iter().rposition(|(name, _)| name == tag.name) {
let (_tag_name, start_idx) = tag_stack.remove(pos);
for line in &mut lines[start_idx..=i] {
line.in_jsx_block = true;
}
}
} else {
let after_tag = &trimmed[tag.end_offset..];
if has_closing_tag(after_tag, tag.name) {
lines[i].in_jsx_block = true;
} else {
tag_stack.push((tag.name.to_owned(), i));
}
}
}
}
let mut fenced_code = FencedCodeTracker::new();
for line in lines.iter_mut() {
if line.in_jsx_block {
let trimmed = line.content(content).trim();
let in_fenced = fenced_code.process_line(trimmed);
if !in_fenced {
line.in_code_block = false;
}
} else {
fenced_code.reset();
}
}
}
struct JsxTag<'a> {
name: &'a str,
is_closing: bool,
is_self_closing: bool,
end_offset: usize,
}
fn scan_jsx_tags(line: &str) -> Vec<JsxTag<'_>> {
let mut tags = Vec::new();
let bytes = line.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
if bytes[pos] != b'<' {
pos += 1;
continue;
}
let rest = &line[pos..];
let after_bracket = &rest[1..];
let is_closing = after_bracket.starts_with('/');
let tag_start_str = if is_closing { &after_bracket[1..] } else { after_bracket };
match tag_start_str.as_bytes().first() {
Some(&c) if c.is_ascii_uppercase() => {}
_ => {
pos += 1;
continue;
}
}
let name_len = tag_start_str
.bytes()
.take_while(|c| c.is_ascii_alphanumeric() || *c == b'.' || *c == b'_')
.count();
if name_len == 0 {
pos += 1;
continue;
}
let name = &tag_start_str[..name_len];
let scan_start = pos + 1 + usize::from(is_closing) + name_len;
let mut j = scan_start;
let mut in_string = false;
let mut string_char = b'"';
let mut found_end = false;
let mut is_self_closing = false;
while j < bytes.len() {
let c = bytes[j];
if in_string {
if c == string_char && (j == 0 || bytes[j - 1] != b'\\') {
in_string = false;
}
} else if c == b'"' || c == b'\'' {
in_string = true;
string_char = c;
} else if c == b'>' {
is_self_closing = !is_closing && j > 0 && bytes[j - 1] == b'/';
found_end = true;
j += 1;
break;
}
j += 1;
}
if !found_end {
tags.push(JsxTag {
name,
is_closing,
is_self_closing: false,
end_offset: line.len(),
});
break;
}
tags.push(JsxTag {
name,
is_closing,
is_self_closing,
end_offset: j,
});
pos = j;
}
tags
}
fn has_closing_tag(haystack: &str, tag_name: &str) -> bool {
let bytes = haystack.as_bytes();
let pattern_len = 2 + tag_name.len() + 1; if bytes.len() < pattern_len {
return false;
}
let mut i = 0;
while i + pattern_len <= bytes.len() {
if bytes[i] == b'<'
&& bytes[i + 1] == b'/'
&& haystack[i + 2..].starts_with(tag_name)
&& bytes[i + 2 + tag_name.len()] == b'>'
{
return true;
}
i += 1;
}
false
}
pub(super) fn detect_jsx_and_mdx_comments(
content: &str,
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
code_blocks: &[(usize, usize)],
) -> (ByteRanges, ByteRanges) {
if !flavor.supports_jsx() {
return (Vec::new(), Vec::new());
}
let mut jsx_expression_ranges: Vec<(usize, usize)> = Vec::new();
let mut mdx_comment_ranges: Vec<(usize, usize)> = Vec::new();
if !content.contains('{') {
return (jsx_expression_ranges, mdx_comment_ranges);
}
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
if code_blocks.iter().any(|(start, end)| i >= *start && i < *end) {
i += 1;
continue;
}
let start = i;
if i + 2 < bytes.len() && &bytes[i + 1..i + 3] == b"/*" {
let mut j = i + 3;
while j + 2 < bytes.len() {
if &bytes[j..j + 2] == b"*/" && j + 2 < bytes.len() && bytes[j + 2] == b'}' {
let end = j + 3;
mdx_comment_ranges.push((start, end));
mark_lines_in_range(lines, content, start, end, |line| {
line.in_mdx_comment = true;
});
i = end;
break;
}
j += 1;
}
if j + 2 >= bytes.len() {
mdx_comment_ranges.push((start, bytes.len()));
mark_lines_in_range(lines, content, start, bytes.len(), |line| {
line.in_mdx_comment = true;
});
break;
}
} else {
let mut brace_depth = 1;
let mut j = i + 1;
let mut in_string = false;
let mut string_char = b'"';
while j < bytes.len() && brace_depth > 0 {
let c = bytes[j];
if !in_string && (c == b'"' || c == b'\'' || c == b'`') {
in_string = true;
string_char = c;
} else if in_string && c == string_char && (j == 0 || bytes[j - 1] != b'\\') {
in_string = false;
} else if !in_string {
if c == b'{' {
brace_depth += 1;
} else if c == b'}' {
brace_depth -= 1;
}
}
j += 1;
}
if brace_depth == 0 {
let end = j;
jsx_expression_ranges.push((start, end));
mark_lines_in_range(lines, content, start, end, |line| {
line.in_jsx_expression = true;
});
i = end;
} else {
i += 1;
}
}
} else {
i += 1;
}
}
(jsx_expression_ranges, mdx_comment_ranges)
}
pub(super) fn detect_markdown_html_blocks(content_lines: &[&str], lines: &mut [LineInfo]) {
let mut markdown_html_tracker = MarkdownHtmlTracker::new();
let mut html_markdown_fence = FencedCodeTracker::new();
for (i, line) in content_lines.iter().enumerate() {
if i >= lines.len() {
break;
}
lines[i].in_mkdocs_html_markdown = markdown_html_tracker.process_line(line);
if lines[i].in_mkdocs_html_markdown {
let in_fenced = html_markdown_fence.process_line(line.trim());
if !in_fenced {
lines[i].in_code_block = false;
}
} else {
html_markdown_fence.reset();
}
}
}
pub(super) fn detect_mkdocs_line_info(content_lines: &[&str], lines: &mut [LineInfo], flavor: MarkdownFlavor) {
if flavor != MarkdownFlavor::MkDocs {
return;
}
use crate::utils::mkdocs_admonitions;
use crate::utils::mkdocs_definition_lists;
use crate::utils::mkdocs_tabs;
let mut in_admonition = false;
let mut admonition_indent = 0;
let mut admonition_fence = FencedCodeTracker::new();
let mut in_tab = false;
let mut tab_indent = 0;
let mut tab_fence = FencedCodeTracker::new();
let mut in_definition = false;
for (i, line) in content_lines.iter().enumerate() {
if i >= lines.len() {
break;
}
if mkdocs_admonitions::is_admonition_start(line) {
in_admonition = true;
admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
lines[i].in_admonition = true;
lines[i].in_code_block = false;
admonition_fence.reset();
} else if in_admonition {
let in_fenced = admonition_fence.process_line(line.trim());
if line.trim().is_empty() || mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
lines[i].in_admonition = true;
if !in_fenced {
lines[i].in_code_block = false;
}
} else {
in_admonition = false;
admonition_fence.reset();
if mkdocs_admonitions::is_admonition_start(line) {
in_admonition = true;
admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
lines[i].in_admonition = true;
}
}
}
if mkdocs_tabs::is_tab_marker(line) {
in_tab = true;
tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
lines[i].in_content_tab = true;
tab_fence.reset();
} else if in_tab {
let in_fenced = tab_fence.process_line(line.trim());
if line.trim().is_empty() || mkdocs_tabs::is_tab_content(line, tab_indent) {
lines[i].in_content_tab = true;
if !in_fenced {
lines[i].in_code_block = false;
}
} else {
in_tab = false;
tab_fence.reset();
if mkdocs_tabs::is_tab_marker(line) {
in_tab = true;
tab_indent = mkdocs_tabs::get_tab_indent(line).unwrap_or(0);
lines[i].in_content_tab = true;
}
}
}
if lines[i].in_code_block {
continue;
}
if mkdocs_definition_lists::is_definition_line(line) {
in_definition = true;
lines[i].in_definition_list = true;
} else if in_definition {
if mkdocs_definition_lists::is_definition_continuation(line) {
lines[i].in_definition_list = true;
} else if line.trim().is_empty() {
lines[i].in_definition_list = true;
} else if mkdocs_definition_lists::could_be_term_line(line) {
if i + 1 < content_lines.len() && mkdocs_definition_lists::is_definition_line(content_lines[i + 1]) {
lines[i].in_definition_list = true;
} else {
in_definition = false;
}
} else {
in_definition = false;
}
} else if mkdocs_definition_lists::could_be_term_line(line) {
if i + 1 < content_lines.len() && mkdocs_definition_lists::is_definition_line(content_lines[i + 1]) {
lines[i].in_definition_list = true;
in_definition = true;
}
}
}
}
pub(super) fn detect_obsidian_comments(
content: &str,
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
code_span_ranges: &[(usize, usize)],
) -> Vec<(usize, usize)> {
if flavor != MarkdownFlavor::Obsidian {
return Vec::new();
}
let comment_ranges = compute_obsidian_comment_ranges(content, lines, code_span_ranges);
for range in &comment_ranges {
for line in lines.iter_mut() {
if line.in_code_block || line.in_html_comment {
continue;
}
let line_start = line.byte_offset;
let line_end = line.byte_offset + line.byte_len;
if line_start >= range.0 && line_end <= range.1 {
line.in_obsidian_comment = true;
} else if line_start < range.1 && line_end > range.0 {
let line_content_start = line_start;
let line_content_end = line_end;
if line_content_start >= range.0 && line_content_end <= range.1 {
line.in_obsidian_comment = true;
}
}
}
}
comment_ranges
}
pub(super) fn compute_obsidian_comment_ranges(
content: &str,
lines: &[LineInfo],
code_span_ranges: &[(usize, usize)],
) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
if !content.contains("%%") {
return ranges;
}
let mut skip_ranges: Vec<(usize, usize)> = Vec::new();
for line in lines {
if line.in_code_block || line.in_html_comment {
skip_ranges.push((line.byte_offset, line.byte_offset + line.byte_len));
}
}
skip_ranges.extend(code_span_ranges.iter().copied());
if !skip_ranges.is_empty() {
skip_ranges.sort_by_key(|(start, _)| *start);
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(skip_ranges.len());
for (start, end) in skip_ranges {
if let Some((_, last_end)) = merged.last_mut()
&& start <= *last_end
{
*last_end = (*last_end).max(end);
continue;
}
merged.push((start, end));
}
skip_ranges = merged;
}
let content_bytes = content.as_bytes();
let len = content.len();
let mut i = 0;
let mut in_comment = false;
let mut comment_start = 0;
let mut skip_idx = 0;
while i < len.saturating_sub(1) {
if skip_idx < skip_ranges.len() {
let (skip_start, skip_end) = skip_ranges[skip_idx];
if i >= skip_end {
skip_idx += 1;
continue;
}
if i >= skip_start {
i = skip_end;
continue;
}
}
if content_bytes[i] == b'%' && content_bytes[i + 1] == b'%' {
if !in_comment {
in_comment = true;
comment_start = i;
i += 2;
} else {
let comment_end = i + 2;
ranges.push((comment_start, comment_end));
in_comment = false;
i += 2;
}
} else {
i += 1;
}
}
if in_comment {
ranges.push((comment_start, len));
}
ranges
}
pub(super) fn detect_kramdown_line_info(content: &str, lines: &mut [LineInfo], flavor: MarkdownFlavor) {
if !flavor.supports_kramdown_syntax() {
return;
}
use crate::utils::kramdown_utils;
let mut in_extension_block = false;
for line in lines.iter_mut() {
let line_content = line.content(content);
let trimmed = line_content.trim();
if in_extension_block {
line.in_kramdown_extension_block = true;
if kramdown_utils::is_kramdown_extension_close(trimmed) {
in_extension_block = false;
}
continue;
}
if line.in_code_block || line.in_front_matter || line.in_html_comment {
continue;
}
if kramdown_utils::is_kramdown_extension_self_closing(trimmed) {
line.in_kramdown_extension_block = true;
continue;
}
if kramdown_utils::is_kramdown_extension_open(trimmed) {
line.in_kramdown_extension_block = true;
in_extension_block = true;
continue;
}
if kramdown_utils::is_kramdown_block_attribute(trimmed) {
line.is_kramdown_block_ial = true;
}
}
}
pub(super) fn mark_lines_in_range<F>(lines: &mut [LineInfo], content: &str, start: usize, end: usize, mut f: F)
where
F: FnMut(&mut LineInfo),
{
for line in lines.iter_mut() {
let line_start = line.byte_offset;
let line_end = line.byte_offset + line.byte_len;
if line_start < end && line_end > start {
f(line);
}
}
let _ = content;
}
fn count_leading_spaces(s: &str) -> usize {
s.bytes().take_while(|&b| b == b' ').count()
}
fn is_colon_fence_opener(line: &str) -> bool {
let spaces = count_leading_spaces(line);
if spaces > 3 {
return false;
}
let rest = &line[spaces..];
if rest.starts_with('\t') {
return false;
}
rest.starts_with(":::") && !rest[3..].trim().is_empty()
}
fn is_colon_fence_closer(line: &str) -> bool {
let spaces = count_leading_spaces(line);
if spaces > 3 {
return false;
}
let rest = &line[spaces..];
if rest.starts_with('\t') {
return false;
}
rest.starts_with(":::") && rest[3..].trim().is_empty()
}
pub(super) fn detect_azure_colon_fences(
content: &str,
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
) -> Vec<(usize, usize)> {
if !flavor.supports_colon_code_fences() {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut fence_byte_start: Option<usize> = None;
for line in lines.iter_mut() {
if line.in_front_matter || line.in_html_comment {
continue;
}
let line_content = line.content(content);
if fence_byte_start.is_none() {
if is_colon_fence_opener(line_content) {
fence_byte_start = Some(line.byte_offset);
line.in_code_block = true;
}
} else {
line.in_code_block = true;
if is_colon_fence_closer(line_content) {
let start = fence_byte_start.take().unwrap();
let end = (line.byte_offset + line.byte_len + 1).min(content.len());
ranges.push((start, end));
}
}
}
if let Some(start) = fence_byte_start {
ranges.push((start, content.len()));
}
ranges
}
#[cfg(test)]
mod colon_fence_tests {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
fn azure_ctx(content: &str) -> LintContext<'_> {
LintContext::new(content, MarkdownFlavor::AzureDevOps, None)
}
fn standard_ctx(content: &str) -> LintContext<'_> {
LintContext::new(content, MarkdownFlavor::Standard, None)
}
#[test]
fn test_colon_fence_basic_marks_content_as_code_block() {
let content = "::: mermaid\nflowchart LR\n A --> B\n:::\n";
let ctx = azure_ctx(content);
assert!(ctx.lines[0].in_code_block, "opener should be in_code_block");
assert!(ctx.lines[1].in_code_block, "content should be in_code_block");
assert!(ctx.lines[2].in_code_block, "content should be in_code_block");
assert!(ctx.lines[3].in_code_block, "closer should be in_code_block");
}
#[test]
fn test_colon_fence_no_space_variant() {
let content = ":::mermaid\ndata\n:::\n";
let ctx = azure_ctx(content);
assert!(ctx.lines[0].in_code_block);
assert!(ctx.lines[1].in_code_block);
assert!(ctx.lines[2].in_code_block);
}
#[test]
fn test_colon_fence_space_variant() {
let content = "::: mermaid\ndata\n:::\n";
let ctx = azure_ctx(content);
assert!(ctx.lines[0].in_code_block);
assert!(ctx.lines[1].in_code_block);
assert!(ctx.lines[2].in_code_block);
}
#[test]
fn test_bare_colon_without_opener_is_not_a_block() {
let content = "Some text\n:::\nMore text\n";
let ctx = azure_ctx(content);
assert!(!ctx.lines[0].in_code_block);
assert!(
!ctx.lines[1].in_code_block,
"bare ::: without opener should not be code block"
);
assert!(!ctx.lines[2].in_code_block);
}
#[test]
fn test_four_leading_spaces_is_not_opener() {
let content = " ::: mermaid\ndata\n:::\n";
let ctx = azure_ctx(content);
assert!(!ctx.lines[1].in_code_block, "content should not be in_code_block");
}
#[test]
fn test_three_leading_spaces_is_opener() {
let content = " ::: mermaid\ndata\n :::\n";
let ctx = azure_ctx(content);
assert!(ctx.lines[0].in_code_block);
assert!(ctx.lines[1].in_code_block);
assert!(ctx.lines[2].in_code_block);
}
#[test]
fn test_colon_fence_inside_front_matter_ignored() {
let content = "---\ntitle: test\n---\n::: mermaid\ndata\n:::\n";
let ctx = azure_ctx(content);
assert!(ctx.lines[3].in_code_block);
assert!(ctx.lines[4].in_code_block);
assert!(ctx.lines[5].in_code_block);
}
#[test]
fn test_standard_flavor_does_not_treat_colon_as_code_block() {
let content = "::: mermaid\nflowchart LR\n A --> B\n:::\n";
let ctx = standard_ctx(content);
for line in &ctx.lines {
assert!(
!line.in_code_block,
"standard flavor should not mark colon blocks as code"
);
}
}
#[test]
fn test_colon_fence_byte_ranges_in_code_blocks() {
let content = "text\n::: mermaid\ndiagram\n:::\nafter\n";
let ctx = azure_ctx(content);
let diagram_line_start = ctx.lines[2].byte_offset;
let in_block = ctx
.code_blocks
.iter()
.any(|&(s, e)| diagram_line_start >= s && diagram_line_start < e);
assert!(in_block, "diagram line should be in code_blocks byte ranges");
}
#[test]
fn test_colon_fence_content_not_flagged_by_md013() {
use crate::rule::Rule;
use crate::rules::md013_line_length::MD013LineLength;
let long_line = "A".repeat(200);
let content = format!("::: mermaid\n{long_line}\n:::\n");
let ctx = azure_ctx(&content);
let rule = MD013LineLength::default();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MD013 should not fire inside colon fence: {warnings:?}"
);
}
}
fn myst_colon_directive_opener(line: &str) -> Option<usize> {
let spaces = count_leading_spaces(line);
if spaces > 3 {
return None;
}
let rest = &line[spaces..];
if rest.starts_with('\t') {
return None;
}
let colon_count = rest.bytes().take_while(|&b| b == b':').count();
if colon_count < 3 {
return None;
}
let after_colons = &rest[colon_count..];
if after_colons.starts_with('{') && after_colons.contains('}') {
let name = after_colons.trim_start_matches('{').split('}').next().unwrap_or("");
if !name.is_empty() && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') {
return Some(colon_count);
}
}
None
}
pub(super) fn detect_myst_colon_directives(
content: &str,
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
) -> Vec<(usize, usize)> {
if !flavor.supports_myst_directives() {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut stack: Vec<(usize, usize)> = Vec::new();
for line in lines.iter_mut() {
if line.in_front_matter || line.in_html_comment || line.in_code_block {
continue;
}
let line_content = line.content(content);
if let Some(colon_count) = myst_colon_directive_opener(line_content) {
stack.push((colon_count, line.byte_offset));
line.in_myst_directive = true;
} else if !stack.is_empty() {
let spaces = count_leading_spaces(line_content);
let rest = if spaces <= 3 { &line_content[spaces..] } else { "" };
let colon_count = rest.bytes().take_while(|&b| b == b':').count();
let is_bare_colons = colon_count >= 3 && rest[colon_count..].trim().is_empty();
if is_bare_colons {
if let Some(pos) = stack.iter().rposition(|&(c, _)| c <= colon_count) {
let (_, start) = stack.remove(pos);
stack.truncate(pos);
let end = (line.byte_offset + line.byte_len + 1).min(content.len());
ranges.push((start, end));
line.in_myst_directive = true;
} else {
line.in_myst_directive = true;
}
} else {
line.in_myst_directive = true;
}
}
}
for (_, start) in stack {
ranges.push((start, content.len()));
}
ranges.sort_by_key(|&(s, _)| s);
ranges
}
pub(super) fn detect_myst_comments(
content: &str,
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
) -> Vec<(usize, usize)> {
if !flavor.supports_myst_comments() {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
for line in lines.iter_mut() {
if line.in_code_block || line.in_front_matter || line.in_html_comment || line.in_myst_directive {
continue;
}
let line_content = line.content(content);
let spaces = count_leading_spaces(line_content);
if spaces > 3 {
continue;
}
let rest = &line_content[spaces..];
if rest.starts_with('%') && (rest.len() == 1 || rest.as_bytes().get(1) == Some(&b' ')) {
line.is_myst_comment = true;
let end = (line.byte_offset + line.byte_len + 1).min(content.len());
ranges.push((line.byte_offset, end));
}
}
ranges
}
const MYST_CONTENT_DIRECTIVES: &[&str] = &[
"note",
"warning",
"tip",
"hint",
"important",
"caution",
"danger",
"admonition",
"attention",
"error",
"seealso",
"topic",
"sidebar",
"margin",
"exercise",
"solution",
"dropdown",
"tab-item",
"grid",
"card",
"tab-set",
"toggle",
"proof",
"prf:proof",
"prf:theorem",
"prf:lemma",
"prf:definition",
"prf:criterion",
"prf:remark",
"prf:conjecture",
"prf:corollary",
"prf:algorithm",
"prf:example",
"prf:property",
"prf:observation",
"prf:proposition",
"prf:assumption",
"figure",
"table",
"list-table",
"csv-table",
];
fn is_myst_content_directive(name: &str) -> bool {
MYST_CONTENT_DIRECTIVES.contains(&name)
}
pub(super) fn detect_myst_backtick_directives(
content: &str,
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
code_block_details: &[crate::utils::code_block_utils::CodeBlockDetail],
line_offsets: &[usize],
) {
if !flavor.supports_myst_directives() {
return;
}
for block in code_block_details {
if !block.is_fenced {
continue;
}
let info = block.info_string.trim();
if !info.starts_with('{') || !info.contains('}') {
continue;
}
let name = info.trim_start_matches('{').split('}').next().unwrap_or("");
if name.is_empty() || !name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') {
continue;
}
let start_line_idx = line_offsets
.partition_point(|&offset| offset <= block.start)
.saturating_sub(1);
let end_line_idx = line_offsets
.partition_point(|&offset| offset < block.end)
.min(lines.len());
if let Some(line) = lines.get_mut(start_line_idx) {
line.in_myst_directive = true;
}
if is_myst_content_directive(name) {
let mut past_options = false;
let mut fence_tracker = FencedCodeTracker::new();
for i in (start_line_idx + 1)..end_line_idx {
if i >= lines.len() {
break;
}
let line_content = lines[i].content(content);
let trimmed = line_content.trim();
let is_closer =
trimmed.starts_with("```") && trimmed.chars().skip(3).all(|c| c == '`' || c.is_whitespace());
if is_closer {
lines[i].in_myst_directive = true;
lines[i].in_code_block = false;
break;
}
if !past_options {
if trimmed.starts_with(':') && trimmed.len() > 1 && trimmed[1..].contains(':') {
lines[i].in_myst_directive = true;
lines[i].in_code_block = false;
continue;
} else if trimmed.starts_with("---") {
lines[i].in_myst_directive = true;
lines[i].in_code_block = false;
continue;
}
past_options = true;
}
let in_nested_fence = fence_tracker.process_line(trimmed);
lines[i].in_myst_directive = true;
if !in_nested_fence {
lines[i].in_code_block = false;
}
}
} else {
if end_line_idx > 0
&& let Some(closer_line) = lines.get_mut(end_line_idx - 1)
{
closer_line.in_myst_directive = true;
}
}
}
}
pub(super) fn detect_myst_role_ranges(
content: &str,
lines: &[LineInfo],
flavor: MarkdownFlavor,
code_blocks: &[(usize, usize)],
) -> Vec<(usize, usize)> {
if !flavor.supports_myst_roles() {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
let bytes = content.as_bytes();
for line in lines {
if line.in_code_block || line.in_front_matter || line.in_html_comment {
continue;
}
let line_start = line.byte_offset;
let line_end = line.byte_offset + line.byte_len;
let line_bytes = &bytes[line_start..line_end];
let mut i = 0;
while i < line_bytes.len() {
if line_bytes[i] != b'{' {
i += 1;
continue;
}
let role_start = line_start + i;
if code_blocks.iter().any(|&(s, e)| role_start >= s && role_start < e) {
i += 1;
continue;
}
let mut j = i + 1;
if j >= line_bytes.len() || !(line_bytes[j].is_ascii_alphabetic() || line_bytes[j] == b'_') {
i += 1;
continue;
}
while j < line_bytes.len()
&& (line_bytes[j].is_ascii_alphanumeric()
|| line_bytes[j] == b'-'
|| line_bytes[j] == b'_'
|| line_bytes[j] == b':'
|| line_bytes[j] == b'.')
{
j += 1;
}
if j >= line_bytes.len() || line_bytes[j] != b'}' {
i += 1;
continue;
}
j += 1;
if j >= line_bytes.len() || line_bytes[j] != b'`' {
i += 1;
continue;
}
let backtick_start = j;
while j < line_bytes.len() && line_bytes[j] == b'`' {
j += 1;
}
let backtick_count = j - backtick_start;
let mut found_close = false;
while j + backtick_count <= line_bytes.len() {
if line_bytes[j] == b'`' {
let close_count = line_bytes[j..].iter().take_while(|&&b| b == b'`').count();
if close_count == backtick_count {
j += close_count;
found_close = true;
break;
}
j += close_count;
} else {
j += 1;
}
}
if found_close {
let role_end = line_start + j;
ranges.push((role_start, role_end));
i = j;
} else {
i += 1;
}
}
}
ranges.sort_by_key(|&(s, _)| s);
ranges
}
#[cfg(test)]
mod myst_tests {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
fn myst_ctx(content: &str) -> LintContext<'_> {
LintContext::new(content, MarkdownFlavor::MyST, None)
}
#[test]
fn test_myst_colon_directive_basic() {
let content = ":::{note}\nThis is a note.\n:::\n";
let ctx = myst_ctx(content);
assert!(ctx.lines[0].in_myst_directive);
assert!(ctx.lines[1].in_myst_directive);
assert!(ctx.lines[2].in_myst_directive);
assert!(!ctx.lines[0].in_code_block);
assert!(!ctx.lines[1].in_code_block);
}
#[test]
fn test_myst_colon_directive_nested() {
let content = "::::{note}\n:::{warning}\nInner content\n:::\nOuter content\n::::\n";
let ctx = myst_ctx(content);
for i in 0..6 {
assert!(ctx.lines[i].in_myst_directive, "line {i} should be in_myst_directive");
}
}
#[test]
fn test_myst_comment() {
let content = "% This is a comment\nRegular text\n";
let ctx = myst_ctx(content);
assert!(ctx.lines[0].is_myst_comment);
assert!(!ctx.lines[1].is_myst_comment);
}
#[test]
fn test_myst_comment_not_in_standard_flavor() {
let content = "% This is a comment\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.lines[0].is_myst_comment);
}
#[test]
fn test_myst_backtick_content_directive() {
let content = "```{note}\nThis is **markdown** content.\n```\n";
let ctx = myst_ctx(content);
assert!(ctx.lines[0].in_myst_directive);
assert!(ctx.lines[1].in_myst_directive);
assert!(
!ctx.lines[1].in_code_block,
"content directive body should not be in_code_block"
);
}
#[test]
fn test_myst_backtick_code_directive() {
let content = "```{code-cell} python\nprint('hello')\n```\n";
let ctx = myst_ctx(content);
assert!(ctx.lines[0].in_myst_directive);
assert!(
ctx.lines[1].in_code_block,
"code directive body should remain in_code_block"
);
}
#[test]
fn test_myst_role_detection() {
let content = "See {ref}`my-label` for details.\n";
let ctx = myst_ctx(content);
assert!(ctx.is_in_myst_role(4)); }
#[test]
fn test_myst_role_not_in_standard_flavor() {
let content = "See {ref}`my-label` for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_myst_role(4));
}
}