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)
}
fn has_inline_config_markers(content: &str) -> bool {
if !content.contains("<!--") {
return false;
}
content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
}
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 code_blocks = CodeBlockUtils::detect_code_blocks(content);
Self::from_content_with_code_blocks_internal(content, &code_blocks)
}
pub fn from_content_with_code_blocks(content: &str, code_blocks: &[(usize, usize)]) -> Self {
if !has_inline_config_markers(content) {
return Self::new();
}
Self::from_content_with_code_blocks_internal(content, code_blocks)
}
fn from_content_with_code_blocks_internal(content: &str, code_blocks: &[(usize, usize)]) -> Self {
let mut config = Self::new();
let lines: Vec<&str> = content.lines().collect();
for (offset, json_config) in scan_configure_file_comments(content) {
if offset_in_code_block(offset, code_blocks) {
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 line_positions = Vec::with_capacity(lines.len());
let mut pos = 0;
for line in &lines {
line_positions.push(pos);
pos += line.len() + 1; }
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(),
});
for (idx, line) in lines.iter().enumerate() {
let line_num = idx + 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);
}
if line_in_code_block(line_positions[idx], line, code_blocks) {
continue;
}
let directives = parse_inline_directives(line);
let has_prettier_ignore = line.contains("<!-- prettier-ignore -->");
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: lines.len() + 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) -> 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(char::is_whitespace) && !after_kw.starts_with("-->") {
continue;
}
let close_offset = after_kw.find("-->")?;
let rules_str = after_kw[..close_offset].trim();
let rules = if rules_str.is_empty() {
Vec::new()
} else {
rules_str.split_whitespace().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("<!-- ") else {
break;
};
let comment_start = pos + open_offset;
let after_open = &line[comment_start + 5..];
if let Some((kind, rules, consumed)) = try_parse_directive(after_open) {
let comment_end = comment_start + 5 + consumed;
results.push(InlineDirective {
kind,
rules,
span: comment_start..comment_end,
});
pos = comment_end;
} else {
pos = comment_start + 5;
}
}
results
}
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_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
code_blocks.iter().any(|&(start, end)| offset >= start && offset < end)
}
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
}
fn drop_warnings_inside_code_blocks(content: &str, flavor: MarkdownFlavor, warnings: &mut Vec<InlineConfigWarning>) {
if warnings.is_empty() {
return;
}
let code_blocks = crate::lint_context::code_block_ranges(content, flavor);
if code_blocks.is_empty() {
return;
}
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)
});
}
fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
let mut found = Vec::new();
let mut pos = 0;
while let Some(open_offset) = text[pos..].find("<!-- ") {
let comment_start = pos + open_offset;
let after_open = &text[comment_start + 5..]; pos = comment_start + 5;
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(char::is_whitespace) && !after_kw.starts_with("-->") {
break;
}
let Some(close_offset) = after_kw.find("-->") else {
break;
};
let json_str = after_kw[..close_offset].trim();
if !json_str.is_empty()
&& let Ok(value) = serde_json::from_str(json_str)
{
found.push((comment_start, value));
}
pos = comment_start + 5 + 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)]) -> 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 parse_inline_directives(line) {
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,
});
}
}
for (offset, json_config) in scan_configure_file_comments(content) {
if offset_in_code_block(offset, code_blocks) {
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!(
"\x1b[33m[inline config warning]\x1b[0m {}:{}: {}",
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> {
use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
let mut warnings = Vec::new();
let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
let suggest = |rule_name: &str| {
suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
};
let registry = crate::config::default_registry();
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 !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,
});
continue;
}
let Some(options) = rule_config.as_object() else {
continue;
};
let canonical = normalize_rule_name(rule_name);
let Some(valid_keys) = registry.config_keys_for(&canonical) else {
continue;
};
let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
for key in options.keys() {
if !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() },
});
}
}
}
}
for (idx, line) in content.lines().enumerate() {
let line_num = idx + 1;
let directives = parse_inline_directives(line);
let mut rule_entries: Vec<(&str, &str)> = Vec::new();
for directive in &directives {
let comment_type = match directive.kind {
DirectiveKind::Disable => "disable",
DirectiveKind::Enable => "enable",
DirectiveKind::DisableLine => "disable-line",
DirectiveKind::DisableNextLine => "disable-next-line",
DirectiveKind::DisableFile => "disable-file",
DirectiveKind::EnableFile => "enable-file",
DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
};
for rule in &directive.rules {
rule_entries.push((rule, comment_type));
}
}
for (rule_name, comment_type) in rule_entries {
if !is_valid_rule_name(rule_name) {
warnings.push(InlineConfigWarning {
line_number: line_num,
rule_name: rule_name.to_string(),
comment_type: comment_type.to_string(),
suggestion: suggest(rule_name),
problem: InlineConfigProblem::UnknownRule,
});
}
}
}
warnings.sort_by_key(|w| w.line_number);
drop_warnings_inside_code_blocks(content, flavor, &mut warnings);
warnings
}
pub fn validate_inline_enables_against_active_rules(
content: &str,
flavor: MarkdownFlavor,
active_rules: &HashSet<String>,
ignored_for_file: &HashSet<String>,
) -> Vec<InlineConfigWarning> {
use crate::config::is_valid_rule_name;
let mut warnings = Vec::new();
let flag = |warnings: &mut Vec<InlineConfigWarning>, name: &str, comment_type: &str, line: usize| {
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;
};
warnings.push(InlineConfigWarning {
line_number: line,
rule_name: canonical,
comment_type: comment_type.to_string(),
suggestion: None,
problem: InlineConfigProblem::EnableHasNoEffect { reason },
});
};
for (offset, json_config) in scan_configure_file_comments(content) {
let Some(obj) = json_config.as_object() else {
continue;
};
let line = line_of_offset(content, offset);
for (rule_name, rule_config) in obj {
if rule_config.as_bool() == Some(true) {
flag(&mut warnings, rule_name, "configure-file", line);
}
}
}
for (idx, line) in content.lines().enumerate() {
for directive in parse_inline_directives(line) {
let comment_type = match directive.kind {
DirectiveKind::Enable => "enable",
DirectiveKind::EnableFile => "enable-file",
_ => continue,
};
for rule in &directive.rules {
flag(&mut warnings, rule, comment_type, idx + 1);
}
}
}
warnings.sort_by_key(|w| w.line_number);
drop_warnings_inside_code_blocks(content, flavor, &mut warnings);
warnings
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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");
}
}