use crate::config::MarkdownFlavor;
use crate::markdownlint_config::markdownlint_to_rumdl_rule_key;
use crate::utils::code_block_utils::CodeBlockUtils;
use serde_json::Value as JsonValue;
use std::collections::{HashMap, HashSet};
pub(crate) fn normalize_rule_name(rule: &str) -> String {
markdownlint_to_rumdl_rule_key(rule).map_or_else(|| rule.to_uppercase(), std::string::ToString::to_string)
}
pub fn has_inline_config_markers(content: &str) -> bool {
let mut quotes = BlockquoteTracker::new();
memchr::memmem::find_iter(content.as_bytes(), COMMENT_OPEN.as_bytes()).any(|offset| {
let after_open = comment_body(
&content[offset + COMMENT_OPEN.len()..],
quotes.quote_depth_at(content, offset),
);
after_open.starts_with("rumdl-")
|| after_open.starts_with("markdownlint-")
|| prettier_ignore_len(after_open).is_some()
})
}
pub(crate) fn inline_rule_option_candidate_offsets(content: &str) -> impl Iterator<Item = usize> + '_ {
let mut quotes = BlockquoteTracker::new();
memchr::memmem::find_iter(content.as_bytes(), COMMENT_OPEN.as_bytes()).filter(move |&offset| {
let body = comment_body(
&content[offset + COMMENT_OPEN.len()..],
quotes.quote_depth_at(content, offset),
);
TOOL_PREFIXES.iter().any(|prefix| {
body.strip_prefix(prefix)
.is_some_and(|tail| tail.starts_with(CONFIGURE_FILE_KEYWORD))
})
})
}
const COMMENT_OPEN: &str = "<!--";
pub(crate) fn is_comment_space(ch: char) -> bool {
ch.is_whitespace() || ch == '\u{feff}'
}
fn comment_body(after_open: &str, depth: usize) -> &str {
let mut line_lead = false;
let mut markers_left = depth;
for (idx, ch) in after_open.char_indices() {
match ch {
'\n' => {
line_lead = true;
markers_left = depth;
}
'>' if line_lead && markers_left > 0 => markers_left -= 1,
_ if is_comment_space(ch) => {}
_ => return &after_open[idx..],
}
}
""
}
fn comment_continuation(line: &str, depth: usize) -> &str {
let mut markers_left = depth;
line.trim_start_matches(|ch: char| {
if ch == '>' && markers_left > 0 {
markers_left -= 1;
return true;
}
is_comment_space(ch)
})
}
fn line_quote_depth(line: &str) -> usize {
let mut rest = line;
let mut depth = 0;
loop {
rest = rest.trim_start_matches([' ', '\t']);
if let Some(after) = rest.strip_prefix('>') {
depth += 1;
rest = after;
continue;
}
let after_marker = if let Some(after) = rest.strip_prefix(['-', '+', '*']) {
Some(after)
} else {
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
if (1..=9).contains(&digits) {
rest[digits..].strip_prefix(['.', ')'])
} else {
None
}
};
match after_marker {
Some(after) if after.starts_with([' ', '\t']) => rest = after,
_ => return depth,
}
}
}
struct BlockquoteTracker {
scanned_to: usize,
classified: bool,
depth: usize,
}
impl BlockquoteTracker {
fn new() -> Self {
Self {
scanned_to: 0,
classified: false,
depth: 0,
}
}
fn quote_depth_at(&mut self, text: &str, open: usize) -> usize {
let line_start = match text[self.scanned_to..open].rfind('\n') {
Some(newline) => Some(self.scanned_to + newline + 1),
None if !self.classified => Some(0),
None => None,
};
if let Some(line_start) = line_start {
self.depth = line_quote_depth(&text[line_start..]);
self.classified = true;
}
self.scanned_to = open;
self.depth
}
}
fn prettier_ignore_len(body: &str) -> Option<usize> {
const KEYWORD: &str = "prettier-ignore";
let after_keyword = body.strip_prefix(KEYWORD)?;
let after_space = after_keyword.trim_start_matches(is_comment_space);
let after_close = after_space.strip_prefix("-->")?;
Some(body.len() - after_close.len())
}
fn is_tool_comment_body(body: &str, depth: usize) -> bool {
TOOL_PREFIXES.iter().any(|tool| body.starts_with(tool)) || is_vale_body(body, depth) || is_lint_body(body, depth)
}
fn at_word_end(rest: &str) -> bool {
rest.is_empty() || rest.starts_with(is_comment_space) || rest.starts_with("-->")
}
fn is_vale_body(body: &str, depth: usize) -> bool {
let Some(rest) = body.strip_prefix("vale") else {
return false;
};
if !rest.starts_with(is_comment_space) {
return false;
}
let rest = comment_body(rest, depth);
if ["off", "on"]
.iter()
.any(|keyword| rest.strip_prefix(keyword).is_some_and(at_word_end))
{
return true;
}
let name_end = rest
.find(|ch: char| is_comment_space(ch) || ch == '=')
.unwrap_or(rest.len());
name_end > 0 && comment_body(&rest[name_end..], depth).starts_with('=')
}
fn is_lint_body(body: &str, depth: usize) -> bool {
let Some(rest) = body.strip_prefix("lint") else {
return false;
};
if !rest.starts_with(is_comment_space) {
return false;
}
let rest = comment_body(rest, depth);
["disable", "enable", "ignore"]
.iter()
.any(|keyword| rest.strip_prefix(keyword).is_some_and(at_word_end))
}
pub(crate) fn config_comment_ranges(content: &str, in_code: impl Fn(usize) -> bool) -> Vec<std::ops::Range<usize>> {
let mut ranges = Vec::new();
let mut pos = 0;
let mut quotes = BlockquoteTracker::new();
let mut next_close: Option<usize> = None;
let mut closers_left = true;
while let Some(open_offset) = content[pos..].find(COMMENT_OPEN) {
let comment_start = pos + open_offset;
let depth = quotes.quote_depth_at(content, comment_start);
let body = comment_body(&content[comment_start + COMMENT_OPEN.len()..], depth);
pos = comment_start + COMMENT_OPEN.len();
if !is_tool_comment_body(body, depth) || in_code(comment_start) {
continue;
}
let body_start = content.len() - body.len();
if closers_left && next_close.is_none_or(|close| close < body_start) {
next_close = body.find("-->").map(|close| body_start + close);
closers_left = next_close.is_some();
}
let comment_end = match next_close {
Some(close) => close + 3,
None => content[comment_start..]
.find('\n')
.map_or(content.len(), |newline| comment_start + newline),
};
pos = comment_end;
ranges.push(comment_start..comment_end);
}
ranges
}
pub(crate) fn offset_in_ranges(ranges: &[std::ops::Range<usize>], offset: usize) -> bool {
let after = ranges.partition_point(|range| range.end <= offset);
ranges.get(after).is_some_and(|range| range.start <= offset)
}
pub type FileIndexExport = (
HashSet<String>,
Vec<(usize, HashSet<String>, HashSet<String>)>,
HashMap<usize, HashSet<String>>,
);
#[derive(Debug, Clone)]
struct StateTransition {
line: usize,
disabled: HashSet<String>,
enabled: HashSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisableLayer {
File,
Block,
Line,
}
#[derive(Debug, Clone)]
pub struct InlineConfig {
transitions: Vec<StateTransition>,
line_disabled_rules: HashMap<usize, HashSet<String>>,
file_disabled_rules: HashSet<String>,
file_enabled_rules: HashSet<String>,
file_rule_config: HashMap<String, JsonValue>,
}
impl Default for InlineConfig {
fn default() -> Self {
Self::new()
}
}
impl InlineConfig {
pub fn new() -> Self {
Self {
transitions: Vec::new(),
line_disabled_rules: HashMap::new(),
file_disabled_rules: HashSet::new(),
file_enabled_rules: HashSet::new(),
file_rule_config: HashMap::new(),
}
}
fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
if self.transitions.is_empty() {
return None;
}
match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
Ok(idx) => Some(&self.transitions[idx]),
Err(idx) => {
if idx > 0 {
Some(&self.transitions[idx - 1])
} else {
None
}
}
}
}
pub fn from_content(content: &str) -> Self {
if !has_inline_config_markers(content) {
return Self::new();
}
let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
Self::from_content_with_code_blocks_internal(content, &parsed.code_blocks, &parsed.code_spans)
}
pub fn from_content_with_code_blocks(
content: &str,
code_blocks: &[(usize, usize)],
code_spans: &[(usize, usize)],
) -> Self {
if !has_inline_config_markers(content) {
return Self::new();
}
Self::from_content_with_code_blocks_internal(content, code_blocks, code_spans)
}
fn from_content_with_code_blocks_internal(
content: &str,
code_blocks: &[(usize, usize)],
code_spans: &[(usize, usize)],
) -> Self {
let mut config = Self::new();
for (offset, json_config) in scan_configure_file_comments(content) {
if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
continue;
}
let Some(obj) = json_config.as_object() else {
continue;
};
for (rule_name, rule_config) in obj {
let normalized = normalize_rule_name(rule_name);
if let Some(enabled) = rule_config.as_bool() {
if enabled {
config.file_disabled_rules.remove(&normalized);
} else {
config.file_disabled_rules.insert(normalized);
}
continue;
}
config.file_rule_config.insert(normalized, rule_config.clone());
}
}
let mut currently_disabled: HashSet<String> = HashSet::new();
let mut currently_enabled: HashSet<String> = HashSet::new();
let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
let mut prev_disabled: HashSet<String> = HashSet::new();
let mut prev_enabled: HashSet<String> = HashSet::new();
config.transitions.push(StateTransition {
line: 1,
disabled: HashSet::new(),
enabled: HashSet::new(),
});
let mut multiline_by_end_line: HashMap<usize, Vec<MultilineDirective<'_>>> = HashMap::new();
let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
multiline_by_end_line
.entry(directive.end_line)
.or_default()
.push(directive);
}
let mut position = 0;
let mut line_count = 0;
for (idx, line) in content.split('\n').enumerate() {
let line_num = idx + 1; line_count = line_num;
let line_position = position;
position += line.len() + 1;
if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
config.transitions.push(StateTransition {
line: line_num,
disabled: currently_disabled.clone(),
enabled: currently_enabled.clone(),
});
prev_disabled.clone_from(¤tly_disabled);
prev_enabled.clone_from(¤tly_enabled);
}
let in_code_block = line_in_code_block(line_position, line, code_blocks);
let ending_here = multiline_by_end_line.remove(&line_num).unwrap_or_default();
let single_line = if in_code_block {
Vec::new()
} else {
directives_outside_code_spans(line, line_position, code_spans)
};
let directives: Vec<InlineDirective<'_>> = ending_here
.into_iter()
.map(|multiline| InlineDirective {
kind: multiline.kind,
rules: multiline.rules,
span: 0..0,
})
.chain(single_line)
.collect();
let has_prettier_ignore = !in_code_block
&& line.match_indices(COMMENT_OPEN).any(|(offset, _)| {
let body = comment_body(&line[offset + COMMENT_OPEN.len()..], 0);
prettier_ignore_len(body).is_some() && !offset_in_code_span(line_position + offset, code_spans)
});
for directive in &directives {
match directive.kind {
DirectiveKind::DisableFile => {
if directive.rules.is_empty() {
config.file_disabled_rules.clear();
config.file_disabled_rules.insert("*".to_string());
} else if config.file_disabled_rules.contains("*") {
for rule in &directive.rules {
config.file_enabled_rules.remove(&normalize_rule_name(rule));
}
} else {
for rule in &directive.rules {
config.file_disabled_rules.insert(normalize_rule_name(rule));
}
}
}
DirectiveKind::EnableFile => {
if directive.rules.is_empty() {
config.file_disabled_rules.clear();
config.file_enabled_rules.clear();
} else if config.file_disabled_rules.contains("*") {
for rule in &directive.rules {
config.file_enabled_rules.insert(normalize_rule_name(rule));
}
} else {
for rule in &directive.rules {
config.file_disabled_rules.remove(&normalize_rule_name(rule));
}
}
}
_ => {}
}
}
for directive in &directives {
match directive.kind {
DirectiveKind::DisableNextLine => {
let next_line = line_num + 1;
let line_rules = config.line_disabled_rules.entry(next_line).or_default();
if directive.rules.is_empty() {
line_rules.insert("*".to_string());
} else {
for rule in &directive.rules {
line_rules.insert(normalize_rule_name(rule));
}
}
}
DirectiveKind::DisableLine => {
let line_rules = config.line_disabled_rules.entry(line_num).or_default();
if directive.rules.is_empty() {
line_rules.insert("*".to_string());
} else {
for rule in &directive.rules {
line_rules.insert(normalize_rule_name(rule));
}
}
}
DirectiveKind::Disable => {
if directive.rules.is_empty() {
currently_disabled.clear();
currently_disabled.insert("*".to_string());
currently_enabled.clear();
} else if currently_disabled.contains("*") {
for rule in &directive.rules {
currently_enabled.remove(&normalize_rule_name(rule));
}
} else {
for rule in &directive.rules {
currently_disabled.insert(normalize_rule_name(rule));
}
}
}
DirectiveKind::Enable => {
if directive.rules.is_empty() {
currently_disabled.clear();
currently_enabled.clear();
} else if currently_disabled.contains("*") {
for rule in &directive.rules {
currently_enabled.insert(normalize_rule_name(rule));
}
} else {
for rule in &directive.rules {
currently_disabled.remove(&normalize_rule_name(rule));
}
}
}
DirectiveKind::Capture => {
capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
}
DirectiveKind::Restore => {
if let Some((disabled, enabled)) = capture_stack.pop() {
currently_disabled = disabled;
currently_enabled = enabled;
}
}
DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
}
}
if has_prettier_ignore {
let next_line = line_num + 1;
let line_rules = config.line_disabled_rules.entry(next_line).or_default();
line_rules.insert("*".to_string());
}
}
if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
config.transitions.push(StateTransition {
line: line_count + 1,
disabled: currently_disabled,
enabled: currently_enabled,
});
}
config
}
pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
self.disabling_layer(rule_name, line_number).is_some()
}
pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
if self.file_disabled_rules.contains("*") {
return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
} else if self.file_disabled_rules.contains(rule_name) {
return Some(DisableLayer::File);
}
if let Some(transition) = self.find_transition(line_number) {
let disabled = if transition.disabled.contains("*") {
!transition.enabled.contains(rule_name)
} else {
transition.disabled.contains(rule_name)
};
if disabled {
return Some(DisableLayer::Block);
}
}
if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
&& (line_rules.contains("*") || line_rules.contains(rule_name))
{
return Some(DisableLayer::Line);
}
None
}
pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
let mut disabled = HashSet::new();
if let Some(transition) = self.find_transition(line_number) {
if transition.disabled.contains("*") {
disabled.insert("*".to_string());
} else {
for rule in &transition.disabled {
disabled.insert(rule.clone());
}
}
}
if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
for rule in line_rules {
disabled.insert(rule.clone());
}
}
disabled
}
pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
self.file_rule_config.get(rule_name)
}
pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
&self.file_rule_config
}
pub fn export_for_file_index(&self) -> FileIndexExport {
let file_disabled = self.file_disabled_rules.clone();
let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
.transitions
.iter()
.map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
.collect();
let line_disabled = self.line_disabled_rules.clone();
(file_disabled, persistent_transitions, line_disabled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectiveKind {
Disable,
DisableLine,
DisableNextLine,
DisableFile,
Enable,
EnableFile,
Capture,
Restore,
ConfigureFile,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InlineDirective<'a> {
pub kind: DirectiveKind,
pub rules: Vec<&'a str>,
pub span: std::ops::Range<usize>,
}
const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
(DirectiveKind::DisableNextLine, "disable-next-line"),
(DirectiveKind::DisableLine, "disable-line"),
(DirectiveKind::DisableFile, "disable-file"),
(DirectiveKind::Disable, "disable"),
(DirectiveKind::EnableFile, "enable-file"),
(DirectiveKind::Enable, "enable"),
(DirectiveKind::ConfigureFile, "configure-file"),
(DirectiveKind::Capture, "capture"),
(DirectiveKind::Restore, "restore"),
];
fn try_parse_directive(s: &str, depth: usize) -> Option<(DirectiveKind, Vec<&str>, usize)> {
for tool in TOOL_PREFIXES {
if !s.starts_with(tool) {
continue;
}
let after_tool = &s[tool.len()..];
for &(kind, keyword) in DIRECTIVE_KEYWORDS {
if !after_tool.starts_with(keyword) {
continue;
}
let after_kw = &after_tool[keyword.len()..];
if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
continue;
}
let close_offset = after_kw.find("-->")?;
let mut lines = after_kw[..close_offset].split('\n');
let first_line = lines.next().unwrap_or("");
let rules: Vec<&str> = std::iter::once(first_line)
.chain(lines.map(|line| comment_continuation(line, depth)))
.flat_map(|line| line.split(is_comment_space))
.filter(|rule| !rule.is_empty())
.collect();
let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((kind, rules, consumed));
}
return None;
}
None
}
pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
let mut results = Vec::new();
let mut pos = 0;
while pos < line.len() {
let remaining = &line[pos..];
let Some(open_offset) = remaining.find(COMMENT_OPEN) else {
break;
};
let comment_start = pos + open_offset;
let after_open = &line[comment_start + COMMENT_OPEN.len()..];
let body = comment_body(after_open, 0);
let body_start = line.len() - body.len();
if let Some((kind, rules, consumed)) = try_parse_directive(body, 0) {
let comment_end = body_start + consumed;
results.push(InlineDirective {
kind,
rules,
span: comment_start..comment_end,
});
pos = comment_end;
} else {
pos = comment_start + COMMENT_OPEN.len();
}
}
results
}
#[derive(Debug, Clone, PartialEq)]
struct MultilineDirective<'a> {
kind: DirectiveKind,
rules: Vec<&'a str>,
open: usize,
end: usize,
start_line: usize,
end_line: usize,
}
fn scan_multiline_directives(text: &str, in_code: impl Fn(usize) -> bool) -> Vec<MultilineDirective<'_>> {
let mut found = Vec::new();
let mut pos = 0;
let mut counted_to = 0;
let mut lines_before = 0;
let mut next_close: Option<usize> = None;
let mut next_newline = 0;
let mut quotes = BlockquoteTracker::new();
while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
let open = pos + open_offset;
pos = open + COMMENT_OPEN.len();
let close = match next_close.filter(|&close| close >= pos) {
Some(close) => close,
None => match text[pos..].find("-->") {
Some(found) => *next_close.insert(pos + found),
None => break,
},
};
if next_newline < pos {
next_newline = text[pos..].find('\n').map_or(text.len(), |newline| pos + newline);
}
let spans_lines = next_newline < close;
if spans_lines && in_code(open) {
continue;
}
let depth = quotes.quote_depth_at(text, open);
let body = comment_body(&text[pos..], depth);
let body_start = text.len() - body.len();
let Some((kind, rules, consumed)) = try_parse_directive(body, depth) else {
continue;
};
let end = body_start + consumed;
let newlines = text[open..end].matches('\n').count();
if newlines == 0 {
pos = end;
continue;
}
pos = end;
if kind == DirectiveKind::ConfigureFile {
continue;
}
lines_before += text[counted_to..open].matches('\n').count();
counted_to = open;
let start_line = lines_before + 1;
found.push(MultilineDirective {
kind,
rules,
open,
end,
start_line,
end_line: start_line + newlines,
});
}
found
}
struct CommentCodeTest<'a> {
content: &'a str,
code_blocks: &'a [(usize, usize)],
code_spans: &'a [(usize, usize)],
line: std::cell::Cell<Option<(usize, usize, bool)>>,
}
impl<'a> CommentCodeTest<'a> {
fn new(content: &'a str, code_blocks: &'a [(usize, usize)], code_spans: &'a [(usize, usize)]) -> Self {
Self {
content,
code_blocks,
code_spans,
line: std::cell::Cell::new(None),
}
}
fn contains(&self, open: usize) -> bool {
if offset_in_code_block(open, self.code_blocks) || offset_in_code_span(open, self.code_spans) {
return true;
}
if let Some((start, end, in_code)) = self.line.get()
&& (start..end).contains(&open)
{
return in_code;
}
let line_start = self.content[..open].rfind('\n').map_or(0, |newline| newline + 1);
let line_end = self.content[line_start..]
.find('\n')
.map_or(self.content.len(), |newline| line_start + newline);
let in_code = line_in_code_block(line_start, &self.content[line_start..line_end], self.code_blocks);
self.line.set(Some((line_start, line_end.max(line_start + 1), in_code)));
in_code
}
}
fn directive_comment_type(kind: DirectiveKind) -> Option<&'static str> {
match kind {
DirectiveKind::Disable => Some("disable"),
DirectiveKind::Enable => Some("enable"),
DirectiveKind::DisableLine => Some("disable-line"),
DirectiveKind::DisableNextLine => Some("disable-next-line"),
DirectiveKind::DisableFile => Some("disable-file"),
DirectiveKind::EnableFile => Some("enable-file"),
DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => None,
}
}
fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
parse_inline_directives(line)
.into_iter()
.find(|d| d.kind == kind)
.map(|d| d.rules)
}
pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
find_directive_rules(line, DirectiveKind::Disable)
}
pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
find_directive_rules(line, DirectiveKind::Enable)
}
pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
find_directive_rules(line, DirectiveKind::DisableLine)
}
pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
find_directive_rules(line, DirectiveKind::DisableNextLine)
}
pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
find_directive_rules(line, DirectiveKind::DisableFile)
}
pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
find_directive_rules(line, DirectiveKind::EnableFile)
}
pub fn is_capture_comment(line: &str) -> bool {
parse_inline_directives(line)
.iter()
.any(|d| d.kind == DirectiveKind::Capture)
}
pub fn is_restore_comment(line: &str) -> bool {
parse_inline_directives(line)
.iter()
.any(|d| d.kind == DirectiveKind::Restore)
}
const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
fn offset_within(offset: usize, ranges: &[(usize, usize)]) -> bool {
ranges.iter().any(|&(start, end)| offset >= start && offset < end)
}
fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
offset_within(offset, code_blocks)
}
fn offset_in_code_span(offset: usize, code_spans: &[(usize, usize)]) -> bool {
offset_within(offset, code_spans)
}
fn directives_outside_code_spans<'a>(
line: &'a str,
line_start: usize,
code_spans: &[(usize, usize)],
) -> Vec<InlineDirective<'a>> {
let mut directives = parse_inline_directives(line);
if !code_spans.is_empty() {
directives.retain(|directive| !offset_in_code_span(line_start + directive.span.start, code_spans));
}
directives
}
fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
let probe = line
.find(|c: char| !c.is_whitespace())
.map_or(line_start, |indent| line_start + indent);
offset_in_code_block(probe, code_blocks)
}
fn line_of_offset(text: &str, offset: usize) -> usize {
text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
}
type LocatedWarning = (InlineConfigWarning, usize);
fn drop_warnings_inside_code(
content: &str,
flavor: MarkdownFlavor,
mut warnings: Vec<LocatedWarning>,
) -> Vec<InlineConfigWarning> {
if warnings.is_empty() {
return Vec::new();
}
let code = crate::lint_context::code_ranges(content, flavor);
if !code.blocks.is_empty() {
let line_spans: Vec<(usize, &str)> = {
let mut spans = Vec::new();
let mut start = 0;
for line in content.split('\n') {
spans.push((start, line));
start += line.len() + 1;
}
spans
};
warnings.retain(|(warning, _)| {
let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
return true;
};
!line_in_code_block(line_start, line, &code.blocks)
});
}
if !code.spans.is_empty() {
warnings.retain(|&(_, offset)| !offset_in_code_span(offset, &code.spans));
}
warnings.into_iter().map(|(warning, _)| warning).collect()
}
fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
let mut found = Vec::new();
let mut pos = 0;
let mut quotes = BlockquoteTracker::new();
while let Some(open_offset) = text[pos..].find(COMMENT_OPEN) {
let comment_start = pos + open_offset;
let after_open = comment_body(
&text[comment_start + COMMENT_OPEN.len()..],
quotes.quote_depth_at(text, comment_start),
);
let body_start = text.len() - after_open.len();
pos = comment_start + COMMENT_OPEN.len();
for tool in TOOL_PREFIXES {
let Some(after_tool) = after_open.strip_prefix(tool) else {
continue;
};
let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
break;
};
if !after_kw.is_empty() && !after_kw.starts_with(is_comment_space) && !after_kw.starts_with("-->") {
break;
}
let Some(close_offset) = after_kw.find("-->") else {
break;
};
let json_str = after_kw[..close_offset].trim_matches(is_comment_space);
if !json_str.is_empty()
&& let Ok(value) = serde_json::from_str(json_str)
{
found.push((comment_start, value));
}
pos = body_start + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
break;
}
}
found
}
pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisableScope {
Line(usize),
Block(usize),
File,
}
impl DisableScope {
pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
match (self, layer) {
(Self::Line(target), DisableLayer::Line) => target == line,
(Self::Block(start), DisableLayer::Block) => start < line,
(Self::File, DisableLayer::File) => true,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisableSite {
pub line: usize,
pub span: std::ops::Range<usize>,
pub kind: &'static str,
pub rules: Vec<String>,
pub scope: DisableScope,
}
pub fn collect_disable_sites(
content: &str,
code_blocks: &[(usize, usize)],
code_spans: &[(usize, usize)],
) -> Vec<DisableSite> {
if !has_inline_config_markers(content) {
return Vec::new();
}
let line_spans: Vec<(usize, &str)> = {
let mut spans = Vec::new();
let mut start = 0;
for line in content.split('\n') {
spans.push((start, line));
start += line.len() + 1;
}
spans
};
let mut sites = Vec::new();
for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
if line_in_code_block(line_start, line, code_blocks) {
continue;
}
let line_num = idx + 1;
for directive in directives_outside_code_spans(line, line_start, code_spans) {
let (kind, scope) = match directive.kind {
DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
DirectiveKind::Enable
| DirectiveKind::EnableFile
| DirectiveKind::Capture
| DirectiveKind::Restore
| DirectiveKind::ConfigureFile => continue,
};
sites.push(DisableSite {
line: line_num,
span: directive.span,
kind,
rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
scope,
});
}
}
let in_code = CommentCodeTest::new(content, code_blocks, code_spans);
for directive in scan_multiline_directives(content, |open| in_code.contains(open)) {
let (kind, scope) = match directive.kind {
DirectiveKind::Disable => ("disable", DisableScope::Block(directive.end_line)),
DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(directive.end_line)),
DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(directive.end_line + 1)),
DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
_ => continue,
};
let Some(&(line_start, line)) = line_spans.get(directive.start_line - 1) else {
continue;
};
sites.push(DisableSite {
line: directive.start_line,
span: (directive.open - line_start)..(directive.end - line_start).min(line.len()),
kind,
rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
scope,
});
}
for (offset, json_config) in scan_configure_file_comments(content) {
if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
continue;
}
let Some(obj) = json_config.as_object() else {
continue;
};
let rules: Vec<String> = obj
.iter()
.filter(|(_, value)| value.as_bool() == Some(false))
.map(|(name, _)| name.clone())
.collect();
if rules.is_empty() {
continue;
}
let line_num = line_of_offset(content, offset);
let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
continue;
};
let comment_end = content[offset..]
.find("-->")
.map_or(content.len(), |close| offset + close + 3);
sites.push(DisableSite {
line: line_num,
span: (offset - line_start)..(comment_end - line_start).min(line.len()),
kind: "configure-file",
rules,
scope: DisableScope::File,
});
}
sites.sort_by_key(|site| (site.line, site.span.start));
sites
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InlineConfigProblem {
UnknownRule,
UnknownOption { key: String },
EnableHasNoEffect { reason: EnableNoEffectReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnableNoEffectReason {
NotEnabled,
IgnoredForFile,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineConfigWarning {
pub line_number: usize,
pub rule_name: String,
pub comment_type: String,
pub suggestion: Option<String>,
pub problem: InlineConfigProblem,
}
impl InlineConfigWarning {
pub fn format_message(&self) -> String {
match &self.problem {
InlineConfigProblem::UnknownOption { key } => match self.suggestion {
Some(ref suggestion) => format!(
"Unknown option for rule {}: {} (did you mean: {}?)",
self.rule_name, key, suggestion
),
None => format!("Unknown option for rule {}: {}", self.rule_name, key),
},
InlineConfigProblem::UnknownRule => match self.suggestion {
Some(ref suggestion) => format!(
"Unknown rule in inline {} comment: {} (did you mean: {}?)",
self.comment_type, self.rule_name, suggestion
),
None => format!(
"Unknown rule in inline {} comment: {}",
self.comment_type, self.rule_name
),
},
InlineConfigProblem::EnableHasNoEffect {
reason: EnableNoEffectReason::NotEnabled,
} => format!(
"Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
self.rule_name, self.comment_type
),
InlineConfigProblem::EnableHasNoEffect {
reason: EnableNoEffectReason::IgnoredForFile,
} => format!(
"Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
self.rule_name, self.comment_type
),
}
}
pub fn print_warning(&self, file_path: &str) {
eprintln!(
"{} {}:{}: {}",
crate::utils::warning_label("inline config warning"),
file_path,
self.line_number,
self.format_message()
);
}
pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
let line_width = content
.lines()
.nth(self.line_number.saturating_sub(1))
.map_or(0, |line| line.chars().count());
crate::rule::LintWarning {
message: self.format_message(),
line: self.line_number,
column: 1,
end_line: self.line_number,
end_column: line_width + 1,
severity: crate::rule::Severity::Warning,
fix: None,
rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
}
}
}
pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
let (warnings, _) = collect_inline_config_warnings(content, flavor, true, None);
drop_warnings_inside_code(content, flavor, warnings)
}
pub fn validate_inline_enables_against_active_rules(
content: &str,
flavor: MarkdownFlavor,
active_rules: &HashSet<String>,
ignored_for_file: &HashSet<String>,
) -> Vec<InlineConfigWarning> {
let (_, warnings) = collect_inline_config_warnings(content, flavor, false, Some((active_rules, ignored_for_file)));
drop_warnings_inside_code(content, flavor, warnings)
}
pub fn validate_inline_configuration(
content: &str,
flavor: MarkdownFlavor,
active_rules: &HashSet<String>,
ignored_for_file: &HashSet<String>,
) -> Vec<InlineConfigWarning> {
let (mut warnings, enables) =
collect_inline_config_warnings(content, flavor, true, Some((active_rules, ignored_for_file)));
warnings.extend(enables);
drop_warnings_inside_code(content, flavor, warnings)
}
fn collect_inline_config_warnings(
content: &str,
flavor: MarkdownFlavor,
validate_names: bool,
active: Option<(&HashSet<String>, &HashSet<String>)>,
) -> (Vec<LocatedWarning>, Vec<LocatedWarning>) {
use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
if !has_inline_config_markers(content) {
return (Vec::new(), Vec::new());
}
let mut warnings = Vec::new();
let mut enables = Vec::new();
let all_rule_names = std::cell::OnceCell::<Vec<String>>::new();
let suggest = |name: &str| {
let names =
all_rule_names.get_or_init(|| RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect());
suggest_similar_key(name, names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
};
let mut flag_enable = |name: &str, comment_type: &str, line: usize, offset: usize| {
let Some((active_rules, ignored_for_file)) = active else {
return;
};
if !is_valid_rule_name(name) {
return;
}
let canonical = normalize_rule_name(name);
let reason = if !active_rules.contains(&canonical) {
EnableNoEffectReason::NotEnabled
} else if ignored_for_file.contains(&canonical) {
EnableNoEffectReason::IgnoredForFile
} else {
return;
};
enables.push((
InlineConfigWarning {
line_number: line,
rule_name: canonical,
comment_type: comment_type.to_string(),
suggestion: None,
problem: InlineConfigProblem::EnableHasNoEffect { reason },
},
offset,
));
};
for (offset, json_config) in scan_configure_file_comments(content) {
let Some(obj) = json_config.as_object() else {
continue;
};
let line_number = line_of_offset(content, offset);
for (rule_name, rule_config) in obj {
if validate_names {
if !is_valid_rule_name(rule_name) {
warnings.push((
InlineConfigWarning {
line_number,
rule_name: rule_name.clone(),
comment_type: "configure-file".to_string(),
suggestion: suggest(rule_name),
problem: InlineConfigProblem::UnknownRule,
},
offset,
));
continue;
}
if let Some(options) = rule_config.as_object() {
let canonical = normalize_rule_name(rule_name);
if let Some(valid_keys) = crate::config::default_registry().config_keys_for(&canonical) {
let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
for key in options.keys().filter(|key| !valid_keys.contains(*key)) {
warnings.push((
InlineConfigWarning {
line_number,
rule_name: canonical.clone(),
comment_type: "configure-file".to_string(),
suggestion: suggest_similar_key(key, &valid_keys_vec),
problem: InlineConfigProblem::UnknownOption { key: key.clone() },
},
offset,
));
}
}
}
}
if rule_config.as_bool() == Some(true) {
flag_enable(rule_name, "configure-file", line_number, offset);
}
}
}
let mut named: Vec<(DirectiveKind, Vec<&str>, usize, usize)> = Vec::new();
let mut line_start = 0;
for (idx, line) in content.split('\n').enumerate() {
for directive in parse_inline_directives(line) {
named.push((
directive.kind,
directive.rules,
idx + 1,
line_start + directive.span.start,
));
}
line_start += line.len() + 1;
}
let code = std::cell::OnceCell::new();
let code_test = std::cell::OnceCell::new();
let multiline_in_code = |open: usize| {
code_test
.get_or_init(|| {
let ranges = code.get_or_init(|| crate::lint_context::code_ranges(content, flavor));
CommentCodeTest::new(content, &ranges.blocks, &ranges.spans)
})
.contains(open)
};
for directive in scan_multiline_directives(content, multiline_in_code) {
named.push((directive.kind, directive.rules, directive.start_line, directive.open));
}
for (kind, rules, line_number, offset) in named {
let Some(comment_type) = directive_comment_type(kind) else {
continue;
};
for rule_name in rules {
if validate_names && !is_valid_rule_name(rule_name) {
warnings.push((
InlineConfigWarning {
line_number,
rule_name: rule_name.to_string(),
comment_type: comment_type.to_string(),
suggestion: suggest(rule_name),
problem: InlineConfigProblem::UnknownRule,
},
offset,
));
}
if matches!(kind, DirectiveKind::Enable | DirectiveKind::EnableFile) {
flag_enable(rule_name, comment_type, line_number, offset);
}
}
}
warnings.sort_by_key(|(warning, _)| warning.line_number);
enables.sort_by_key(|(warning, _)| warning.line_number);
(warnings, enables)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combined_validation_preserves_warning_order_and_literal_code() {
let source = concat!(
"<!-- rumdl-enable MD009 -->\n",
"<!-- rumdl-disable MD999 -->\n",
"<!-- rumdl-configure-file {\"MD011\":true,\"MD013\":{\"line-lenght\":40}} -->\n",
"<!-- rumdl-enable MD013 -->\n",
"```markdown\n",
"<!-- rumdl-enable MD010 -->\n",
"<!-- rumdl-disable MD998 -->\n",
"```\n",
"`<!-- rumdl-enable MD012 -->`\n",
);
let active = HashSet::from(["MD013".to_string()]);
let ignored = active.clone();
for ending in ["\n", "\r\n"] {
let content = source.replace('\n', ending);
for flavor in [
MarkdownFlavor::Standard,
MarkdownFlavor::MkDocs,
MarkdownFlavor::MDX,
MarkdownFlavor::Pandoc,
MarkdownFlavor::Quarto,
MarkdownFlavor::Obsidian,
MarkdownFlavor::Kramdown,
MarkdownFlavor::AzureDevOps,
MarkdownFlavor::MyST,
MarkdownFlavor::Hugo,
MarkdownFlavor::MDG,
MarkdownFlavor::GhAw,
] {
let mut separate = validate_inline_config_rules(&content, flavor);
separate.extend(validate_inline_enables_against_active_rules(
&content, flavor, &active, &ignored,
));
let combined = validate_inline_configuration(&content, flavor, &active, &ignored);
assert_eq!(format!("{combined:?}"), format!("{separate:?}"), "{flavor:?}");
assert_eq!(
combined
.iter()
.map(|warning| (warning.rule_name.as_str(), warning.line_number))
.collect::<Vec<_>>(),
vec![("MD999", 2), ("MD013", 3), ("MD009", 1), ("MD011", 3), ("MD013", 4)],
"{flavor:?}",
);
}
}
}
const COMPACT_OPENERS: &[&str] = &["<!--", "<!-- ", "<!-- ", "<!--\t", "<!-- \t "];
const COMPACT_CLOSERS: &[&str] = &["-->", " -->", " -->", "\t-->"];
#[test]
fn test_parse_inline_directives_whitespace_around_delimiters_is_optional() {
let cases: &[(&str, DirectiveKind, &[&str])] = &[
("rumdl-disable MD013", DirectiveKind::Disable, &["MD013"]),
(
"markdownlint-disable MD013 MD009",
DirectiveKind::Disable,
&["MD013", "MD009"],
),
("markdownlint-disable", DirectiveKind::Disable, &[]),
("rumdl-enable", DirectiveKind::Enable, &[]),
("markdownlint-enable MD013", DirectiveKind::Enable, &["MD013"]),
("rumdl-disable-line MD013", DirectiveKind::DisableLine, &["MD013"]),
(
"markdownlint-disable-next-line MD013",
DirectiveKind::DisableNextLine,
&["MD013"],
),
(
"markdownlint-disable-file MD013",
DirectiveKind::DisableFile,
&["MD013"],
),
("rumdl-enable-file", DirectiveKind::EnableFile, &[]),
("markdownlint-capture", DirectiveKind::Capture, &[]),
("rumdl-restore", DirectiveKind::Restore, &[]),
("markdownlint-configure-file {}", DirectiveKind::ConfigureFile, &["{}"]),
];
for &(body, kind, rules) in cases {
for open in COMPACT_OPENERS {
for close in COMPACT_CLOSERS {
let comment = format!("{open}{body}{close}");
let line = format!("text {comment} tail");
let directives = parse_inline_directives(&line);
assert_eq!(directives.len(), 1, "{line:?}");
assert_eq!(directives[0].kind, kind, "{line:?}");
assert_eq!(directives[0].rules, rules, "{line:?}");
assert_eq!(&line[directives[0].span.clone()], comment, "{line:?}");
}
}
}
}
#[test]
fn test_parse_inline_directives_compact_not_a_directive() {
for line in [
"<!--markdownlint-disablefoo -->",
"<!--rumdl-disableMD013-->",
"<!--rumdl-enablebar-->",
"<!--rumdl-captures-->",
"<!--RUMDL-disable MD009 -->",
"<!--Markdownlint-Disable-->",
"<!--note rumdl-disable MD009 -->",
"<!---- rumdl-disable MD009 -->",
"<!--rumdl-disable MD009",
"<!--rumdl-",
"<!--",
] {
assert!(parse_inline_directives(line).is_empty(), "{line:?}");
}
}
#[test]
fn test_parse_inline_directives_finds_compact_directive_after_other_comment() {
let line = "<!-- other <!--rumdl-disable MD009--> <!--markdownlint-enable MD009-->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 2, "{line:?}");
assert_eq!(directives[0].kind, DirectiveKind::Disable);
assert_eq!(directives[1].kind, DirectiveKind::Enable);
}
#[test]
fn test_scan_configure_file_compact_and_multiline() {
for text in [
"<!--rumdl-configure-file {\"MD013\":false}-->",
"<!--markdownlint-configure-file {\"MD013\":false} -->",
"<!--\trumdl-configure-file\n{\n\"MD013\":false\n}\n-->",
"<!--\nrumdl-configure-file {\"MD013\":false}-->",
"x <!-- other --> <!--rumdl-configure-file {\"MD013\":false}-->",
] {
let found = scan_configure_file_comments(text);
assert_eq!(found.len(), 1, "{text:?}");
assert_eq!(found[0].1, serde_json::json!({"MD013": false}), "{text:?}");
assert_eq!(&text[found[0].0..found[0].0 + 4], "<!--", "{text:?}");
}
for text in [
"<!--rumdl-configure-files {\"MD013\":false}-->",
"<!--rumdl-configure-file{\"MD013\":false}-->",
"<!--RUMDL-configure-file {\"MD013\":false}-->",
"<!--rumdl-configure-file {\"MD013\":false}",
] {
assert!(scan_configure_file_comments(text).is_empty(), "{text:?}");
}
let two = "<!--rumdl-configure-file {\"MD013\":false}--><!--rumdl-configure-file {\"MD009\":false}-->";
assert_eq!(scan_configure_file_comments(two).len(), 2);
}
#[test]
fn test_from_content_compact_directives_match_spaced_form() {
let pairs: &[(&str, &str)] = &[
(
"<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n",
"<!-- rumdl-disable MD013 -->\nA\n<!-- rumdl-enable MD013 -->\nB\n",
),
(
"A <!--markdownlint-disable-line MD013-->\nB\n",
"A <!-- markdownlint-disable-line MD013 -->\nB\n",
),
(
"<!--markdownlint-disable-next-line MD013-->\nA\nB\n",
"<!-- markdownlint-disable-next-line MD013 -->\nA\nB\n",
),
(
"<!--rumdl-disable-file MD013-->\nA\n",
"<!-- rumdl-disable-file MD013 -->\nA\n",
),
(
"<!--rumdl-disable-file-->\n<!--rumdl-enable-file MD013-->\nA\n",
"<!-- rumdl-disable-file -->\n<!-- rumdl-enable-file MD013 -->\nA\n",
),
(
"<!--markdownlint-disable MD013-->\n<!--markdownlint-capture-->\n<!--markdownlint-enable-->\nA\n<!--markdownlint-restore-->\nB\n",
"<!-- markdownlint-disable MD013 -->\n<!-- markdownlint-capture -->\n<!-- markdownlint-enable -->\nA\n<!-- markdownlint-restore -->\nB\n",
),
("<!--prettier-ignore-->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
("<!--prettier-ignore -->\nA\nB\n", "<!-- prettier-ignore -->\nA\nB\n"),
(
"<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n",
"<!-- rumdl-configure-file {\"MD013\":{\"line-length\":10}} -->\nA\n",
),
(
"<!--markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
"<!-- markdownlint-configure-file\n{\"MD013\":false}\n-->\nA\n",
),
(
"> <!--rumdl-disable-next-line MD013-->\n> A\n",
"> <!-- rumdl-disable-next-line MD013 -->\n> A\n",
),
(
"- item\n\n <!--rumdl-disable-next-line MD013-->\n A\n",
"- item\n\n <!-- rumdl-disable-next-line MD013 -->\n A\n",
),
];
for (compact, spaced) in pairs {
let a = InlineConfig::from_content(compact);
let b = InlineConfig::from_content(spaced);
let lines = compact.split('\n').count() + 2;
for line in 1..=lines {
for rule in ["MD013", "MD009"] {
assert_eq!(
a.disabling_layer(rule, line),
b.disabling_layer(rule, line),
"{compact:?} vs {spaced:?}, {rule} at line {line}"
);
}
}
assert_eq!(a.get_all_rule_configs(), b.get_all_rule_configs(), "{compact:?}");
}
let c = InlineConfig::from_content("<!--rumdl-disable MD013-->\nA\n<!--rumdl-enable MD013-->\nB\n");
assert!(c.is_rule_disabled("MD013", 2));
assert!(!c.is_rule_disabled("MD013", 4));
let c = InlineConfig::from_content("<!--prettier-ignore-->\nA\nB\n");
assert!(c.is_rule_disabled("MD013", 2));
assert!(!c.is_rule_disabled("MD013", 3));
let c = InlineConfig::from_content("<!--rumdl-configure-file {\"MD013\":{\"line-length\":10}}-->\nA\n");
assert!(c.get_rule_config("MD013").is_some());
}
#[test]
fn test_from_content_compact_directive_in_code_configures_nothing() {
for content in [
"`<!--rumdl-disable MD013-->`\nA\n",
"```\n<!--rumdl-disable MD013-->\n```\nA\n",
" <!--rumdl-disable MD013-->\n\nA\n",
"`<!--rumdl-disable-file MD013-->`\nA\n",
"`<!--prettier-ignore-->`\nA\n",
"`<!--rumdl-configure-file {\"MD013\":false}-->`\nA\n",
] {
let config = InlineConfig::from_content(content);
for line in 1..=6 {
assert!(!config.is_rule_disabled("MD013", line), "{content:?} line {line}");
}
assert!(config.get_all_rule_configs().is_empty(), "{content:?}");
}
}
#[test]
fn test_validate_inline_config_reports_unknown_rule_in_compact_directive() {
let warnings = validate_inline_config_rules("<!--rumdl-disable MD998-->\n", MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line_number, 1);
}
#[test]
fn candidate_scan_keeps_nested_and_multiline_directives() {
for source in [
"<!-- rumdl-disable MD009 -->",
"prose <!-- markdownlint-enable-file MD013 -->",
"<!-- rumdl-configure-file {\n\"MD013\":false\n} -->",
"<!-- other <!-- rumdl-disable-next-line MD009 -->",
"<!-- prettier-ignore -->",
"<!--rumdl-disable MD009 -->",
"<!--markdownlint-disable-->",
"<!--\trumdl-enable-->",
"<!--prettier-ignore-->",
"<!-- prettier-ignore-->",
"<!--prettier-ignore -->",
"<!--\nrumdl-configure-file {} -->",
] {
assert!(has_inline_config_markers(source), "{source:?}");
}
for source in [
"rumdl prose",
"<!-- unrelated --> rumdl",
"<!-- RUMDL-disable MD009 -->",
"<!--RUMDL-disable MD009 -->",
"<!---- rumdl-disable MD009 -->",
"<!--prettier-ignore-start-->",
"<!-- prettier-ignore-end -->",
"<!--x--> rumdl-disable",
] {
assert!(!has_inline_config_markers(source), "{source:?}");
assert!(validate_inline_config_rules(source, MarkdownFlavor::Standard).is_empty());
}
}
#[test]
fn test_parse_inline_directives_all_kinds() {
let cases: &[(&str, DirectiveKind)] = &[
("<!-- rumdl-disable -->", DirectiveKind::Disable),
("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
("<!-- rumdl-enable -->", DirectiveKind::Enable),
("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
("<!-- rumdl-capture -->", DirectiveKind::Capture),
("<!-- rumdl-restore -->", DirectiveKind::Restore),
("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
("<!-- markdownlint-disable -->", DirectiveKind::Disable),
("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
(
"<!-- markdownlint-disable-next-line -->",
DirectiveKind::DisableNextLine,
),
("<!-- markdownlint-enable -->", DirectiveKind::Enable),
("<!-- markdownlint-capture -->", DirectiveKind::Capture),
("<!-- markdownlint-restore -->", DirectiveKind::Restore),
];
for (input, expected_kind) in cases {
let directives = parse_inline_directives(input);
assert_eq!(
directives.len(),
1,
"Expected 1 directive for {input:?}, got {directives:?}"
);
assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
}
}
#[test]
fn test_parse_inline_directives_disambiguation() {
let line = "<!-- rumdl-disable-line MD001 -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
let line = "<!-- rumdl-disable-next-line -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
let line = "<!-- rumdl-disable-file MD001 -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
let line = "<!-- rumdl-enable-file -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
}
#[test]
fn test_parse_inline_directives_no_space_before_close() {
let directives = parse_inline_directives("<!-- rumdl-disable-->");
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].kind, DirectiveKind::Disable);
assert!(directives[0].rules.is_empty());
let directives = parse_inline_directives("<!-- rumdl-enable-->");
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].kind, DirectiveKind::Enable);
}
#[test]
fn test_parse_inline_directives_multiple_on_one_line() {
let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 2);
assert_eq!(directives[0].kind, DirectiveKind::Disable);
assert_eq!(directives[0].rules, vec!["MD001"]);
assert_eq!(directives[1].kind, DirectiveKind::Enable);
assert_eq!(directives[1].rules, vec!["MD001"]);
}
#[test]
fn test_parse_inline_directives_global_disable_then_specific_enable() {
let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 2);
assert_eq!(directives[0].kind, DirectiveKind::Disable);
assert!(directives[0].rules.is_empty());
assert_eq!(directives[1].kind, DirectiveKind::Enable);
assert_eq!(directives[1].rules, vec!["MD001"]);
}
#[test]
fn test_parse_inline_directives_word_boundary() {
assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
}
#[test]
fn test_parse_inline_directives_no_closing_tag() {
assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
}
#[test]
fn test_parse_inline_directives_not_a_comment() {
assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
assert!(parse_inline_directives("Some regular text").is_empty());
assert!(parse_inline_directives("").is_empty());
}
#[test]
fn test_parse_inline_directives_case_sensitive() {
assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
}
#[test]
fn test_parse_inline_directives_rules_extraction() {
let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
assert_eq!(directives[0].rules, vec!["MD001"]);
}
#[test]
fn test_parse_inline_directives_embedded_in_text() {
let line = "Some text <!-- rumdl-disable MD001 --> more text";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].rules, vec!["MD001"]);
let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 1);
assert_eq!(directives[0].rules, vec!["MD001"]);
}
#[test]
fn test_parse_inline_directives_mixed_tools_same_line() {
let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
let directives = parse_inline_directives(line);
assert_eq!(directives.len(), 2);
assert_eq!(directives[0].kind, DirectiveKind::Disable);
assert_eq!(directives[0].rules, vec!["MD001"]);
assert_eq!(directives[1].kind, DirectiveKind::Enable);
assert_eq!(directives[1].rules, vec!["MD002"]);
}
#[test]
fn test_parse_disable_comment() {
assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
assert_eq!(
parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
Some(vec!["MD001", "MD002"])
);
assert_eq!(parse_disable_comment("Some regular text"), None);
}
#[test]
fn test_parse_disable_line_comment() {
assert_eq!(
parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
Some(vec![])
);
assert_eq!(
parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
Some(vec!["MD013"])
);
assert_eq!(parse_disable_line_comment("Some regular text"), None);
}
#[test]
fn test_inline_config_from_content() {
let content = r#"# Test Document
<!-- markdownlint-disable MD013 -->
This is a very long line that would normally trigger MD013 but it's disabled
<!-- markdownlint-enable MD013 -->
This line will be checked again
<!-- markdownlint-disable-next-line MD001 -->
# This heading will not be checked for MD001
## But this one will
Some text <!-- markdownlint-disable-line MD013 -->
<!-- markdownlint-capture -->
<!-- markdownlint-disable MD001 MD002 -->
# Heading with MD001 disabled
<!-- markdownlint-restore -->
# Heading with MD001 enabled again
"#;
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD013", 4));
assert!(!config.is_rule_disabled("MD013", 7));
assert!(config.is_rule_disabled("MD001", 10));
assert!(!config.is_rule_disabled("MD001", 11));
assert!(config.is_rule_disabled("MD013", 13));
assert!(!config.is_rule_disabled("MD001", 19));
}
#[test]
fn test_capture_restore() {
let content = r#"<!-- markdownlint-disable MD001 -->
<!-- markdownlint-capture -->
<!-- markdownlint-disable MD002 MD003 -->
<!-- markdownlint-restore -->
Some content after restore
"#;
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 5));
assert!(!config.is_rule_disabled("MD002", 5));
assert!(!config.is_rule_disabled("MD003", 5));
}
#[test]
fn test_validate_inline_config_rules_unknown_rule() {
let content = "<!-- rumdl-disable abc -->\nSome content";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].line_number, 1);
assert_eq!(warnings[0].rule_name, "abc");
assert_eq!(warnings[0].comment_type, "disable");
}
#[test]
fn test_validate_inline_config_rules_valid_rule() {
let content = "<!-- rumdl-disable MD001 -->\nSome content";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert!(
warnings.is_empty(),
"MD001 is a valid rule, should not produce warnings"
);
}
#[test]
fn test_validate_inline_config_rules_alias() {
let content = "<!-- rumdl-disable heading-increment -->\nSome content";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
}
#[test]
fn test_validate_inline_config_rules_multiple_unknown() {
let content = r#"<!-- rumdl-disable abc xyz -->
<!-- rumdl-disable-line foo -->
<!-- markdownlint-disable-next-line bar -->
"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 4);
assert_eq!(warnings[0].rule_name, "abc");
assert_eq!(warnings[1].rule_name, "xyz");
assert_eq!(warnings[2].rule_name, "foo");
assert_eq!(warnings[3].rule_name, "bar");
}
#[test]
fn test_validate_inline_config_rules_suggestion() {
let content = "<!-- rumdl-disable MD00 -->\n";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].suggestion.is_some());
}
#[test]
fn test_validate_inline_config_rules_file_comments() {
let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].comment_type, "disable-file");
assert_eq!(warnings[1].comment_type, "enable-file");
}
#[test]
fn test_validate_inline_config_rules_global_disable() {
let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
}
#[test]
fn test_validate_inline_config_rules_mixed_valid_invalid() {
let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 2);
assert_eq!(warnings[0].rule_name, "abc");
assert_eq!(warnings[1].rule_name, "xyz");
}
#[test]
fn test_validate_inline_config_rules_configure_file() {
let content =
r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].rule_name, "nonexistent");
assert_eq!(warnings[0].comment_type, "configure-file");
}
#[test]
fn test_validate_inline_config_rules_markdownlint_variants() {
let content = r#"<!-- markdownlint-disable unknown_rule -->
<!-- markdownlint-enable another_fake -->
<!-- markdownlint-disable-line bad_rule -->
<!-- markdownlint-disable-next-line fake_rule -->
<!-- markdownlint-disable-file missing_rule -->
<!-- markdownlint-enable-file nonexistent -->
"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 6);
assert_eq!(warnings[0].rule_name, "unknown_rule");
assert_eq!(warnings[1].rule_name, "another_fake");
assert_eq!(warnings[2].rule_name, "bad_rule");
assert_eq!(warnings[3].rule_name, "fake_rule");
assert_eq!(warnings[4].rule_name, "missing_rule");
assert_eq!(warnings[5].rule_name, "nonexistent");
}
#[test]
fn test_validate_inline_config_rules_ignores_code_blocks() {
let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
assert!(
validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
"a directive inside a fence is documentation, not configuration"
);
let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
}
#[test]
fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-disable made_up_rule -->\n";
assert_eq!(
validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
1,
"an admonition holds its content at an indent that is not code"
);
assert!(
validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
"without admonitions the same lines are an indented code block"
);
let tab = "# Doc\n\n=== \"Tab\"\n\n <!-- rumdl-disable made_up_rule -->\n";
assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
}
#[test]
fn test_validate_inline_enables_reports_inside_an_indented_container() {
let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-enable MD013 -->\n";
let active = active_set(&["MD009"]);
assert_eq!(
validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
.len(),
1,
"the enable is live in an admonition body, and configuration never enabled MD013"
);
assert!(
validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
.is_empty(),
"without admonitions the same lines are an indented code block"
);
}
#[test]
fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
}
#[test]
fn test_validate_inline_config_rules_markdownlint_configure_file() {
let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].rule_name, "fake_rule");
assert_eq!(warnings[0].comment_type, "configure-file");
}
#[test]
fn test_get_rule_config_from_configure_file() {
let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
This is a test line."#;
let inline_config = InlineConfig::from_content(content);
let config_override = inline_config.get_rule_config("MD013");
assert!(config_override.is_some(), "MD013 config should be found");
let json = config_override.unwrap();
assert!(json.is_object(), "Config should be an object");
let obj = json.as_object().unwrap();
assert!(obj.contains_key("line_length"), "Should have line_length key");
assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
}
#[test]
fn test_get_rule_config_tables_false() {
let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
let inline_config = InlineConfig::from_content(content);
let config_override = inline_config.get_rule_config("MD013");
assert!(config_override.is_some(), "MD013 config should be found");
let json = config_override.unwrap();
let obj = json.as_object().unwrap();
assert!(obj.contains_key("tables"), "Should have tables key");
assert!(!obj.get("tables").unwrap().as_bool().unwrap());
}
fn active_set(names: &[&str]) -> HashSet<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
fn no_ignores() -> HashSet<String> {
HashSet::new()
}
#[test]
fn test_inline_enable_of_inactive_rule_warns() {
let active = active_set(&["MD013", "MD022"]);
let content = "<!-- rumdl-enable MD012 -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
assert_eq!(warnings[0].rule_name, "MD012");
assert_eq!(warnings[0].comment_type, "enable");
assert_eq!(
warnings[0].problem,
InlineConfigProblem::EnableHasNoEffect {
reason: EnableNoEffectReason::NotEnabled
}
);
assert_eq!(warnings[0].line_number, 1);
}
#[test]
fn test_inline_enable_of_active_rule_does_not_warn() {
let active = active_set(&["MD012", "MD013"]);
let content = "<!-- rumdl-enable MD012 -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
}
#[test]
fn test_inline_enable_of_per_file_ignored_rule_warns() {
let active = active_set(&["MD012", "MD013"]);
let ignored = active_set(&["MD012"]);
let content = "<!-- rumdl-enable MD012 -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
assert_eq!(warnings[0].rule_name, "MD012");
assert_eq!(
warnings[0].problem,
InlineConfigProblem::EnableHasNoEffect {
reason: EnableNoEffectReason::IgnoredForFile
}
);
assert!(
warnings[0].format_message().contains("per-file-ignores"),
"the message must name the setting in play: {}",
warnings[0].format_message()
);
}
#[test]
fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
let active = active_set(&["MD013"]);
let ignored = active_set(&["MD012"]);
let content = "<!-- rumdl-enable MD012 -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(
warnings[0].problem,
InlineConfigProblem::EnableHasNoEffect {
reason: EnableNoEffectReason::NotEnabled
}
);
}
#[test]
fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
let active = active_set(&["MD012", "MD013"]);
let ignored = active_set(&["MD013"]);
let content = "<!-- rumdl-enable MD012 -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
}
#[test]
fn test_enable_inside_code_block_does_not_warn() {
let active = active_set(&["MD012", "MD013"]);
let ignored = active_set(&["MD012"]);
for content in [
"# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
"# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
"# Doc\n\n <!-- rumdl-enable MD012 -->\n",
] {
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
}
for content in [
"# Doc\n\n<!-- rumdl-enable MD012 -->\n",
"# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
] {
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
}
}
#[test]
fn test_bare_enable_all_does_not_warn() {
let active = active_set(&["MD013"]);
for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
let warnings = validate_inline_enables_against_active_rules(
content,
MarkdownFlavor::Standard,
&active,
&active_set(&["MD013"]),
);
assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
}
}
#[test]
fn test_enable_file_and_alias_of_inactive_rule_warn() {
let active = active_set(&["MD013"]);
let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
assert_eq!(warnings[0].comment_type, "enable-file");
}
#[test]
fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
let active = active_set(&["MD013"]);
let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
let warn_true =
validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
let warn_false =
validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
assert_eq!(warn_true[0].rule_name, "MD012");
assert!(
warn_false.is_empty(),
"configure-file false is a disable, not an ignored enable: {warn_false:?}"
);
}
#[test]
fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
let active = active_set(&["MD013"]);
let content = "<!-- rumdl-enable NotARule -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
}
#[test]
fn test_disable_directive_never_warns_as_no_effect() {
let active = active_set(&["MD012", "MD013"]);
let ignored = active_set(&["MD012"]);
let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert!(warnings.is_empty(), "disable warned: {warnings:?}");
}
#[test]
fn test_unknown_option_key_in_configure_file_warns() {
let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
assert_eq!(warnings[0].rule_name, "MD013");
assert_eq!(
warnings[0].problem,
InlineConfigProblem::UnknownOption {
key: "line_lenght".to_string()
}
);
assert!(
warnings[0].suggestion.is_some(),
"a near-miss key should suggest the real one"
);
assert!(
warnings[0].format_message().contains("Unknown option for rule MD013"),
"message was: {}",
warnings[0].format_message()
);
}
#[test]
fn test_valid_option_keys_do_not_warn_in_either_case_style() {
for content in [
r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
] {
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
}
}
#[test]
fn test_unknown_rule_does_not_also_warn_about_its_options() {
let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(
warnings.len(),
1,
"expected only the unknown-rule warning: {warnings:?}"
);
assert_eq!(warnings[0].rule_name, "nonexistent");
assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
}
#[test]
fn test_boolean_rule_value_has_no_option_warnings() {
let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
}
#[test]
fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
}
#[test]
fn test_configure_file_spanning_multiple_lines_applies() {
let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
let inline_config = InlineConfig::from_content(content);
let config_override = inline_config.get_rule_config("MD013");
assert!(config_override.is_some(), "a multi-line configure-file must apply");
let obj = config_override.unwrap().as_object().unwrap();
assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
}
#[test]
fn test_every_configure_file_comment_applies_not_just_the_first() {
let content = "<!-- markdownlint-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n\n<!-- markdownlint-configure-file\n{\n \"MD007\": { \"indent\": 4 }\n}\n-->\n\n# Head\n";
let inline_config = InlineConfig::from_content(content);
assert!(
inline_config.get_rule_config("MD013").is_some(),
"first (single-line) configure-file dropped"
);
assert!(
inline_config.get_rule_config("MD007").is_some(),
"second (multi-line) configure-file dropped"
);
}
#[test]
fn test_multiline_configure_file_in_code_block_is_ignored() {
let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
let inline_config = InlineConfig::from_content(content);
assert!(
inline_config.get_rule_config("MD013").is_none(),
"configure-file inside a fenced code block must not apply"
);
}
#[test]
fn test_multiline_configure_file_bool_and_alias_still_honored() {
let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
let inline_config = InlineConfig::from_content(content);
assert!(
inline_config.is_rule_disabled("MD012", 1),
"boolean alias key must disable the rule"
);
let obj = inline_config
.get_rule_config("MD013")
.expect("alias-keyed config should resolve to MD013")
.as_object()
.unwrap();
assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
}
#[test]
fn test_multiline_configure_file_warning_reports_start_line() {
let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
assert_eq!(warnings[0].rule_name, "nonexistent");
assert_eq!(warnings[0].comment_type, "configure-file");
assert_eq!(
warnings[0].line_number, 3,
"warning must point at the line the comment starts on"
);
}
#[test]
fn test_configure_file_bool_false_disables_rule() {
let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
let inline_config = InlineConfig::from_content(content);
assert!(inline_config.is_rule_disabled("MD012", 1));
assert!(
inline_config.get_rule_config("MD012").is_none(),
"a boolean should not be stored as rule options"
);
}
#[test]
fn test_configure_file_bool_false_disables_rule_by_alias() {
let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
let inline_config = InlineConfig::from_content(content);
assert!(inline_config.is_rule_disabled("MD012", 1));
}
#[test]
fn test_configure_file_bool_true_leaves_rule_enabled() {
let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
let inline_config = InlineConfig::from_content(content);
assert!(!inline_config.is_rule_disabled("MD012", 1));
}
#[test]
fn test_get_rule_config_from_configure_file_alias_key() {
let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
let inline_config = InlineConfig::from_content(content);
let config_override = inline_config.get_rule_config("MD013");
assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
let obj = config_override.unwrap().as_object().unwrap();
assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
}
#[test]
fn test_parse_disable_does_not_match_disable_line() {
assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
}
#[test]
fn test_parse_enable_does_not_match_enable_file() {
assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
}
#[test]
fn test_parse_disable_comment_edge_cases() {
assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
assert_eq!(
parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
Some(vec!["MD001", "MD002"])
);
assert_eq!(
parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
Some(vec!["MD001"])
);
assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
assert_eq!(
parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
Some(vec!["MD001", "MD001", "MD002"])
);
assert_eq!(
parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
Some(vec!["MD001"])
);
let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
let comment = format!("<!-- rumdl-disable {many_rules} -->");
let parsed = parse_disable_comment(&comment);
assert!(parsed.is_some());
assert_eq!(parsed.unwrap().len(), 100);
assert_eq!(
parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
Some(vec!["MD001-test"])
);
assert_eq!(
parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
Some(vec!["custom_rule"])
);
}
#[test]
fn test_parse_enable_comment_edge_cases() {
assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
}
#[test]
fn test_disable_inside_fenced_code_block_ignored() {
let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 6));
}
#[test]
fn test_disable_inside_tilde_fence_ignored() {
let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 6));
}
#[test]
fn test_disable_before_code_block_persists_after() {
let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_enable_inside_code_block_ignored() {
let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_disable_inside_indented_code_block_ignored() {
let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 5));
let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
let config = InlineConfig::from_content(unindented);
assert!(config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_configure_file_inside_indented_code_block_ignored() {
let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
let config = InlineConfig::from_content(content);
assert!(config.get_rule_config("MD013").is_none());
let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
let config = InlineConfig::from_content(unindented);
assert!(config.get_rule_config("MD013").is_some());
}
#[test]
fn test_disable_inside_code_span_ignored() {
let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 5));
let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
let config = InlineConfig::from_content(bare);
assert!(config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_directive_beside_one_in_a_code_span_still_applies() {
let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
}
#[test]
fn test_disable_file_inside_code_span_ignored() {
let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD013", 3));
let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
let config = InlineConfig::from_content(bare);
assert!(config.is_rule_disabled("MD013", 3));
}
#[test]
fn test_configure_file_inside_code_span_ignored() {
let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
let config = InlineConfig::from_content(content);
assert!(config.get_rule_config("MD013").is_none());
let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
let config = InlineConfig::from_content(disabling);
assert!(!config.is_rule_disabled("MD013", 3));
}
#[test]
fn test_disable_inside_multi_line_code_span_ignored() {
let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_comment_opening_a_line_inside_backticks_still_applies() {
let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_disable_inside_double_backtick_code_span_ignored() {
let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 3));
}
#[test]
fn test_code_ranges_line_up_on_a_crlf_document() {
let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
let config = InlineConfig::from_content(fenced);
assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
let config = InlineConfig::from_content(spanned);
assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
let config = InlineConfig::from_content(bare);
assert!(config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_unmatched_backtick_does_not_open_a_code_span() {
let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 3));
}
#[test]
fn test_validate_inline_config_rules_ignores_code_spans() {
let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
assert!(
validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
"a directive shown in a code span warned"
);
let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
assert_eq!(
validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
1,
"the same name outside a span is still validated"
);
}
#[test]
fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
}
#[test]
fn test_enable_inside_code_span_does_not_warn() {
let active = active_set(&["MD012", "MD013"]);
let ignored = active_set(&["MD012"]);
for content in [
"# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
"# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
] {
let warnings =
validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
}
}
#[test]
fn test_markdownlint_disable_rumdl_enable_interop() {
let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 2));
assert!(!config.is_rule_disabled("MD001", 4));
}
#[test]
fn test_rumdl_disable_markdownlint_enable_interop() {
let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD013", 2));
assert!(!config.is_rule_disabled("MD013", 4));
}
#[test]
fn test_global_disable_then_specific_enable() {
let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 4));
assert!(config.is_rule_disabled("MD002", 4));
assert!(config.is_rule_disabled("MD013", 4));
}
#[test]
fn test_specific_disable_then_global_enable() {
let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
let config = InlineConfig::from_content(content);
assert!(config.is_rule_disabled("MD001", 2));
assert!(config.is_rule_disabled("MD002", 2));
assert!(!config.is_rule_disabled("MD001", 4));
assert!(!config.is_rule_disabled("MD002", 4));
}
#[test]
fn test_multiple_rules_disable_enable_independently() {
let content = "\
Line 1\n\
<!-- rumdl-disable MD001 MD002 -->\n\
Line 3\n\
<!-- rumdl-enable MD001 -->\n\
Line 5\n\
<!-- rumdl-disable -->\n\
Line 7\n\
<!-- rumdl-enable MD002 -->\n\
Line 9\n";
let config = InlineConfig::from_content(content);
assert!(!config.is_rule_disabled("MD001", 1));
assert!(!config.is_rule_disabled("MD002", 1));
assert!(config.is_rule_disabled("MD001", 3));
assert!(config.is_rule_disabled("MD002", 3));
assert!(!config.is_rule_disabled("MD001", 5));
assert!(config.is_rule_disabled("MD002", 5));
assert!(config.is_rule_disabled("MD001", 7));
assert!(config.is_rule_disabled("MD002", 7));
assert!(config.is_rule_disabled("MD001", 9));
assert!(!config.is_rule_disabled("MD002", 9));
}
#[test]
fn test_empty_content() {
let config = InlineConfig::from_content("");
assert!(!config.is_rule_disabled("MD001", 1));
}
#[test]
fn test_single_disable_comment_only() {
let config = InlineConfig::from_content("<!-- rumdl-disable -->");
assert!(!config.is_rule_disabled("MD001", 1));
assert!(config.is_rule_disabled("MD001", 2));
assert!(config.is_rule_disabled("MD999", 2));
let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
assert!(!config.is_rule_disabled("MD001", 1));
assert!(config.is_rule_disabled("MD001", 2));
assert!(config.is_rule_disabled("MD001", 3));
}
#[test]
fn test_no_inline_markers() {
let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
assert!(!config.is_rule_disabled("MD001", 1));
assert!(!config.is_rule_disabled("MD001", 5));
}
#[test]
fn test_export_for_file_index_persistent_transitions() {
let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
let config = InlineConfig::from_content(content);
let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
assert!(file_disabled.is_empty());
assert!(
persistent.len() >= 2,
"Expected at least 2 transitions, got {}",
persistent.len()
);
}
#[test]
fn test_export_for_file_index_disable_file() {
let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
let config = InlineConfig::from_content(content);
let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
assert!(file_disabled.contains("MD001"));
}
#[test]
fn test_export_for_file_index_disable_line() {
let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
let config = InlineConfig::from_content(content);
let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
assert!(line_disabled[&2].contains("MD001"));
assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
}
fn disabled_lines(content: &str, rule: &str) -> Vec<usize> {
let config = InlineConfig::from_content(content);
let lines = content.split('\n').count();
(1..=lines)
.filter(|&line| config.is_rule_disabled(rule, line))
.collect()
}
#[test]
fn multi_line_disable_takes_effect_after_the_closing_line() {
let content = "<!--\nrumdl-disable MD013\n-->\nlong\nlong\n";
assert_eq!(disabled_lines(content, "MD013"), [4, 5, 6]);
}
#[test]
fn multi_line_markdownlint_forms_and_rule_lists_split_over_lines() {
let content = "<!--\nmarkdownlint-disable MD013\nMD001\n MD009\n-->\nx\n";
for rule in ["MD013", "MD001", "MD009"] {
assert_eq!(disabled_lines(content, rule), [6, 7], "{rule}");
}
let all = "<!-- rumdl-disable\n-->\nx\n";
assert_eq!(disabled_lines(all, "MD013"), [3, 4]);
}
#[test]
fn multi_line_enable_restores_and_capture_restore_nest() {
let content = "<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-enable MD013\n-->\nb\n";
assert_eq!(disabled_lines(content, "MD013"), [2, 3, 4, 5]);
let content = "<!--\nrumdl-capture\n-->\n<!-- rumdl-disable MD013 -->\na\n<!--\nrumdl-restore\n-->\nb\n";
assert_eq!(disabled_lines(content, "MD013"), [5, 6, 7, 8]);
}
#[test]
fn multi_line_line_and_next_line_forms_use_the_closing_line() {
let content = "<!--\nrumdl-disable-line MD013\n-->\nx\n";
assert_eq!(disabled_lines(content, "MD013"), [3]);
let content = "<!--\nrumdl-disable-next-line MD013\n-->\nx\ny\n";
assert_eq!(disabled_lines(content, "MD013"), [4]);
}
#[test]
fn multi_line_file_forms_apply_to_the_whole_document() {
let content = "x\n<!--\nrumdl-disable-file MD013\n-->\ny\n";
assert_eq!(disabled_lines(content, "MD013"), [1, 2, 3, 4, 5, 6]);
}
#[test]
fn single_line_comment_after_a_multi_line_one_on_its_closing_line_applies_in_order() {
let content = "<!--\nrumdl-disable MD013\n--> <!-- rumdl-enable MD013 -->\nx\n";
assert!(disabled_lines(content, "MD013").is_empty());
let content = "<!--\nrumdl-enable MD013\n--> <!-- rumdl-disable MD013 -->\nx\n";
assert_eq!(disabled_lines(content, "MD013"), [4, 5]);
}
#[test]
fn multi_line_directive_inside_a_code_block_or_span_configures_nothing() {
let fenced = "```\n<!--\nrumdl-disable MD013\n-->\n```\nx\n";
assert!(disabled_lines(fenced, "MD013").is_empty());
let span = "`<!--\nrumdl-disable MD013\n-->`\nx\n";
assert!(disabled_lines(span, "MD013").is_empty());
}
#[test]
fn multi_line_comment_with_other_leading_text_is_not_a_directive() {
let content = "<!--\nnote\nrumdl-disable MD013\n-->\nx\n";
assert!(disabled_lines(content, "MD013").is_empty());
}
#[test]
fn single_line_directives_are_not_counted_twice() {
let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-disable-next-line MD001 -->\nx\n";
let config = InlineConfig::from_content(content);
let (_, transitions, line_disabled) = config.export_for_file_index();
assert_eq!(transitions.len(), 2, "{transitions:?}");
assert_eq!(line_disabled.len(), 1, "{line_disabled:?}");
}
#[test]
fn multi_line_disable_site_reports_the_opening_line_and_the_closing_line_scope() {
let content = "x\n<!--\nrumdl-disable-next-line MD013\n-->\ny\n";
let sites = collect_disable_sites(content, &[], &[]);
assert_eq!(sites.len(), 1, "{sites:?}");
assert_eq!(sites[0].line, 2);
assert_eq!(sites[0].kind, "disable-next-line");
assert_eq!(sites[0].scope, DisableScope::Line(5));
assert_eq!(sites[0].span.start, 0);
assert_eq!(sites[0].span.end, 4, "clipped to the opening line");
}
#[test]
fn multi_line_directive_unknown_rule_is_reported_on_the_opening_line() {
let content = "x\n<!--\nrumdl-disable MD9999\n-->\n";
let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(warnings[0].line_number, 2);
assert_eq!(warnings[0].rule_name, "MD9999");
}
#[test]
fn comment_whitespace_is_unicode_whitespace_plus_bom() {
for ch in [
'\u{20}', '\t', '\n', '\u{a0}', '\u{3000}', '\u{85}', '\u{2028}', '\u{feff}',
] {
assert!(is_comment_space(ch), "{ch:?}");
}
for ch in ['\u{200b}', 'a', '-'] {
assert!(!is_comment_space(ch), "{ch:?}");
}
}
#[test]
fn bom_counts_as_space_in_every_part_of_a_directive() {
let content = "<!--\u{feff}rumdl-disable\u{feff}MD013\u{feff}MD001\u{feff}-->\nx\n";
assert_eq!(disabled_lines(content, "MD013"), [2, 3]);
assert_eq!(disabled_lines(content, "MD001"), [2, 3]);
let zero_width = "<!--\u{200b}rumdl-disable MD013 -->\nx\n";
assert!(disabled_lines(zero_width, "MD013").is_empty());
}
#[test]
fn prettier_ignore_accepts_the_same_whitespace() {
assert!(prettier_ignore_len("prettier-ignore\u{feff}-->").is_some());
assert!(prettier_ignore_len("prettier-ignore\u{200b}-->").is_none());
}
fn is_tool_comment(text: &str) -> bool {
!config_comment_ranges(text, |_| false).is_empty()
}
#[test]
fn tool_comment_recognises_every_tool_in_any_spelling() {
for text in [
"<!-- rumdl-disable MD013 -->",
"<!--markdownlint-disable-->",
"<!-- vale off -->",
"<!--vale off-->",
"<!-- vale on -->",
"<!-- vale Style.Rule = NO -->",
"<!-- vale style = X -->",
"<!-- lint disable no-javascript -->",
"<!--lint disable x-->",
"<!-- lint enable x -->",
"<!--\tlint ignore x\t-->",
"<!--\u{feff}lint disable x-->",
"<!--\nlint disable x\n-->",
] {
assert!(is_tool_comment(text), "{text:?}");
}
for text in [
"<!-- vale offset javascript -->",
"<!-- vale onboarding javascript -->",
"<!-- vale style guide javascript -->",
"<!-- lint disabled x -->",
"<!-- linting disable x -->",
"<!-- lint -->",
"<!-- valet off -->",
"<!-- vale -->",
"<!-- plain comment -->",
] {
assert!(!is_tool_comment(text), "{text:?}");
}
}
#[test]
fn config_comment_ranges_cover_tool_comments_only() {
let content = "a\n<!--\nlint disable x\n-->\nb\n<!-- vale off -->\n<!--\nplain\n-->\n";
let ranges = config_comment_ranges(content, |_| false);
let texts: Vec<&str> = ranges.iter().map(|range| &content[range.clone()]).collect();
assert_eq!(texts, ["<!--\nlint disable x\n-->", "<!-- vale off -->"]);
}
#[test]
fn config_comment_ranges_stop_at_the_comment_edges() {
let content = "prose <!--vale off--> tail\n";
let ranges = config_comment_ranges(content, |_| false);
assert_eq!(&content[ranges[0].clone()], "<!--vale off-->");
}
#[test]
fn config_comment_ranges_skip_code_openers_and_end_an_unclosed_one_at_its_line() {
let content = "<!-- vale off\nprose\n";
assert_eq!(config_comment_ranges(content, |_| false), vec![0..13]);
assert!(config_comment_ranges(content, |_| true).is_empty());
}
#[test]
fn blockquote_tracker_classifies_each_line_once_in_order() {
let text = "<!-- a --> <!-- b -->\n> q <!-- c --> <!-- d -->\n- > l <!-- e -->\nx > y <!-- f -->\n1 > 0 <!-- g -->\n\n> <!-- h -->";
let mut tracker = BlockquoteTracker::new();
let got: Vec<usize> = text
.match_indices(COMMENT_OPEN)
.map(|(open, _)| tracker.quote_depth_at(text, open))
.collect();
assert_eq!(got, [0, 0, 1, 1, 1, 0, 0, 1]);
}
#[test]
fn quote_prefix_needs_real_container_syntax() {
for (line, depth) in [
("> x", 1),
(">> x", 2),
(" > x", 1),
("- > x", 1),
("1. > x", 1),
("10) - > x", 1),
("> - > x", 2),
(">x", 1),
("x", 0),
("1 > 0", 0),
("a > b", 0),
("-> x", 0),
("1.> x", 0),
("-x > y", 0),
("- x > y", 0),
("1234567890. > x", 0),
("", 0),
("- x", 0),
] {
assert_eq!(line_quote_depth(line), depth, "{line:?}");
}
}
#[test]
fn config_comment_ranges_unterminated_openers_linear_complexity() {
let cost = |count: usize| {
let content = "<!-- vale off\n".repeat(count);
let mut best = std::time::Duration::MAX;
for _ in 0..5 {
let start = std::time::Instant::now();
let ranges = config_comment_ranges(std::hint::black_box(&content), |_| false);
best = best.min(start.elapsed());
assert_eq!(ranges.len(), count);
}
best.as_secs_f64()
};
let (small, large) = (cost(20_000), cost(80_000));
assert!(
large / small < 8.0,
"4x the openers took {:.1}x the time (linear is about 4x, quadratic about 16x)",
large / small
);
}
#[test]
fn offset_in_ranges_matches_a_linear_scan() {
let ranges = vec![2..5, 5..6, 9..12, 20..21];
for offset in 0..25 {
assert_eq!(
offset_in_ranges(&ranges, offset),
ranges.iter().any(|range| range.contains(&offset)),
"offset {offset}"
);
}
assert!(!offset_in_ranges(&[], 0));
}
#[test]
fn a_code_opener_does_not_swallow_the_directive_after_it() {
let content = "```\n<!-- rumdl-disable\n```\n\n<!--\nrumdl-disable MD013\n-->\n";
let kept = scan_multiline_directives(content, |open| open < 20);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].start_line, 5);
}
#[test]
fn comment_body_skips_blockquote_markers_on_later_lines_only() {
assert_eq!(comment_body("\n> rumdl-disable", 1), "rumdl-disable");
assert_eq!(comment_body("\n>> \t> rumdl-disable", 3), "rumdl-disable");
assert_eq!(
comment_body("> rumdl-disable", 1),
"> rumdl-disable",
"same line: not a marker"
);
assert_eq!(
comment_body("\n> rumdl-disable", 0),
"> rumdl-disable",
"outside a blockquote the `>` is comment text"
);
assert_eq!(
comment_body("\n> > rumdl-disable", 1),
"> rumdl-disable",
"a marker beyond the enclosing depth is text"
);
assert_eq!(comment_body(" \n ", 1), "");
}
#[test]
fn multi_line_directive_in_a_blockquote_splits_rules_across_marked_lines() {
let content = "> <!-- rumdl-disable MD013\n> MD009\n> -->\n> text\n";
let found = scan_multiline_directives(content, |_| false);
assert_eq!(found.len(), 1);
assert_eq!(found[0].rules, ["MD013", "MD009"]);
assert_eq!((found[0].start_line, found[0].end_line), (1, 3));
}
#[test]
fn an_unclosed_configure_file_example_in_code_does_not_swallow_the_directive_after_it() {
let content = "```\n<!-- rumdl-configure-file\n```\n\n<!--\nrumdl-disable MD013\n-->\ntext\n";
let code_end = content.find("\n\n<!--\n").unwrap();
let found = scan_multiline_directives(content, |open| open < code_end);
assert_eq!(found.len(), 1);
assert_eq!(found[0].rules, ["MD013"]);
}
}