1use crate::config::MarkdownFlavor;
27use crate::markdownlint_config::markdownlint_to_rumdl_rule_key;
28use crate::utils::code_block_utils::CodeBlockUtils;
29use serde_json::Value as JsonValue;
30use std::collections::{HashMap, HashSet};
31
32pub(crate) fn normalize_rule_name(rule: &str) -> String {
35 markdownlint_to_rumdl_rule_key(rule).map_or_else(|| rule.to_uppercase(), std::string::ToString::to_string)
36}
37
38fn has_inline_config_markers(content: &str) -> bool {
39 if !content.contains("<!--") {
40 return false;
41 }
42 content.contains("markdownlint") || content.contains("rumdl") || content.contains("prettier-ignore")
43}
44
45pub type FileIndexExport = (
48 HashSet<String>,
49 Vec<(usize, HashSet<String>, HashSet<String>)>,
50 HashMap<usize, HashSet<String>>,
51);
52
53#[derive(Debug, Clone)]
57struct StateTransition {
58 line: usize,
60 disabled: HashSet<String>,
62 enabled: HashSet<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DisableLayer {
72 File,
74 Block,
76 Line,
78}
79
80#[derive(Debug, Clone)]
81pub struct InlineConfig {
82 transitions: Vec<StateTransition>,
85 line_disabled_rules: HashMap<usize, HashSet<String>>,
87 file_disabled_rules: HashSet<String>,
89 file_enabled_rules: HashSet<String>,
91 file_rule_config: HashMap<String, JsonValue>,
94}
95
96impl Default for InlineConfig {
97 fn default() -> Self {
98 Self::new()
99 }
100}
101
102impl InlineConfig {
103 pub fn new() -> Self {
104 Self {
105 transitions: Vec::new(),
106 line_disabled_rules: HashMap::new(),
107 file_disabled_rules: HashSet::new(),
108 file_enabled_rules: HashSet::new(),
109 file_rule_config: HashMap::new(),
110 }
111 }
112
113 fn find_transition(&self, line_number: usize) -> Option<&StateTransition> {
116 if self.transitions.is_empty() {
117 return None;
118 }
119 match self.transitions.binary_search_by_key(&line_number, |t| t.line) {
121 Ok(idx) => Some(&self.transitions[idx]),
122 Err(idx) => {
123 if idx > 0 {
124 Some(&self.transitions[idx - 1])
125 } else {
126 None
127 }
128 }
129 }
130 }
131
132 pub fn from_content(content: &str) -> Self {
134 if !has_inline_config_markers(content) {
135 return Self::new();
136 }
137
138 let parsed = CodeBlockUtils::detect_code_blocks_and_spans(content);
141 Self::from_content_with_code_blocks_internal(content, &parsed.code_blocks, &parsed.code_spans)
142 }
143
144 pub fn from_content_with_code_blocks(
146 content: &str,
147 code_blocks: &[(usize, usize)],
148 code_spans: &[(usize, usize)],
149 ) -> Self {
150 if !has_inline_config_markers(content) {
151 return Self::new();
152 }
153
154 Self::from_content_with_code_blocks_internal(content, code_blocks, code_spans)
155 }
156
157 fn from_content_with_code_blocks_internal(
158 content: &str,
159 code_blocks: &[(usize, usize)],
160 code_spans: &[(usize, usize)],
161 ) -> Self {
162 let mut config = Self::new();
163 let lines: Vec<&str> = content.split('\n').collect();
166
167 for (offset, json_config) in scan_configure_file_comments(content) {
172 if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
173 continue;
174 }
175 let Some(obj) = json_config.as_object() else {
176 continue;
177 };
178 for (rule_name, rule_config) in obj {
179 let normalized = normalize_rule_name(rule_name);
183 if let Some(enabled) = rule_config.as_bool() {
184 if enabled {
185 config.file_disabled_rules.remove(&normalized);
186 } else {
187 config.file_disabled_rules.insert(normalized);
188 }
189 continue;
190 }
191 config.file_rule_config.insert(normalized, rule_config.clone());
195 }
196 }
197
198 let mut line_positions = Vec::with_capacity(lines.len());
200 let mut pos = 0;
201 for line in &lines {
202 line_positions.push(pos);
203 pos += line.len() + 1; }
205
206 let mut currently_disabled: HashSet<String> = HashSet::new();
208 let mut currently_enabled: HashSet<String> = HashSet::new();
209 let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
210
211 let mut prev_disabled: HashSet<String> = HashSet::new();
213 let mut prev_enabled: HashSet<String> = HashSet::new();
214
215 config.transitions.push(StateTransition {
217 line: 1,
218 disabled: HashSet::new(),
219 enabled: HashSet::new(),
220 });
221
222 for (idx, line) in lines.iter().enumerate() {
223 let line_num = idx + 1; if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
228 config.transitions.push(StateTransition {
229 line: line_num,
230 disabled: currently_disabled.clone(),
231 enabled: currently_enabled.clone(),
232 });
233 prev_disabled.clone_from(¤tly_disabled);
234 prev_enabled.clone_from(¤tly_enabled);
235 }
236
237 if line_in_code_block(line_positions[idx], line, code_blocks) {
239 continue;
240 }
241
242 let directives = directives_outside_code_spans(line, line_positions[idx], code_spans);
245
246 let has_prettier_ignore = line
248 .match_indices("<!-- prettier-ignore -->")
249 .any(|(offset, _)| !offset_in_code_span(line_positions[idx] + offset, code_spans));
250
251 for directive in &directives {
253 match directive.kind {
254 DirectiveKind::DisableFile => {
255 if directive.rules.is_empty() {
256 config.file_disabled_rules.clear();
257 config.file_disabled_rules.insert("*".to_string());
258 } else if config.file_disabled_rules.contains("*") {
259 for rule in &directive.rules {
260 config.file_enabled_rules.remove(&normalize_rule_name(rule));
261 }
262 } else {
263 for rule in &directive.rules {
264 config.file_disabled_rules.insert(normalize_rule_name(rule));
265 }
266 }
267 }
268 DirectiveKind::EnableFile => {
269 if directive.rules.is_empty() {
270 config.file_disabled_rules.clear();
271 config.file_enabled_rules.clear();
272 } else if config.file_disabled_rules.contains("*") {
273 for rule in &directive.rules {
274 config.file_enabled_rules.insert(normalize_rule_name(rule));
275 }
276 } else {
277 for rule in &directive.rules {
278 config.file_disabled_rules.remove(&normalize_rule_name(rule));
279 }
280 }
281 }
282 _ => {}
284 }
285 }
286
287 for directive in &directives {
289 match directive.kind {
290 DirectiveKind::DisableNextLine => {
291 let next_line = line_num + 1;
292 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
293 if directive.rules.is_empty() {
294 line_rules.insert("*".to_string());
295 } else {
296 for rule in &directive.rules {
297 line_rules.insert(normalize_rule_name(rule));
298 }
299 }
300 }
301 DirectiveKind::DisableLine => {
302 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
303 if directive.rules.is_empty() {
304 line_rules.insert("*".to_string());
305 } else {
306 for rule in &directive.rules {
307 line_rules.insert(normalize_rule_name(rule));
308 }
309 }
310 }
311 DirectiveKind::Disable => {
312 if directive.rules.is_empty() {
313 currently_disabled.clear();
314 currently_disabled.insert("*".to_string());
315 currently_enabled.clear();
316 } else if currently_disabled.contains("*") {
317 for rule in &directive.rules {
318 currently_enabled.remove(&normalize_rule_name(rule));
319 }
320 } else {
321 for rule in &directive.rules {
322 currently_disabled.insert(normalize_rule_name(rule));
323 }
324 }
325 }
326 DirectiveKind::Enable => {
327 if directive.rules.is_empty() {
328 currently_disabled.clear();
329 currently_enabled.clear();
330 } else if currently_disabled.contains("*") {
331 for rule in &directive.rules {
332 currently_enabled.insert(normalize_rule_name(rule));
333 }
334 } else {
335 for rule in &directive.rules {
336 currently_disabled.remove(&normalize_rule_name(rule));
337 }
338 }
339 }
340 DirectiveKind::Capture => {
341 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
342 }
343 DirectiveKind::Restore => {
344 if let Some((disabled, enabled)) = capture_stack.pop() {
345 currently_disabled = disabled;
346 currently_enabled = enabled;
347 }
348 }
349 DirectiveKind::DisableFile | DirectiveKind::EnableFile | DirectiveKind::ConfigureFile => {}
351 }
352 }
353
354 if has_prettier_ignore {
356 let next_line = line_num + 1;
357 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
358 line_rules.insert("*".to_string());
359 }
360 }
361
362 if currently_disabled != prev_disabled || currently_enabled != prev_enabled {
364 config.transitions.push(StateTransition {
365 line: lines.len() + 1,
366 disabled: currently_disabled,
367 enabled: currently_enabled,
368 });
369 }
370
371 config
372 }
373
374 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
376 self.disabling_layer(rule_name, line_number).is_some()
377 }
378
379 pub fn disabling_layer(&self, rule_name: &str, line_number: usize) -> Option<DisableLayer> {
386 if self.file_disabled_rules.contains("*") {
387 return (!self.file_enabled_rules.contains(rule_name)).then_some(DisableLayer::File);
389 } else if self.file_disabled_rules.contains(rule_name) {
390 return Some(DisableLayer::File);
391 }
392
393 if let Some(transition) = self.find_transition(line_number) {
395 let disabled = if transition.disabled.contains("*") {
396 !transition.enabled.contains(rule_name)
397 } else {
398 transition.disabled.contains(rule_name)
399 };
400 if disabled {
401 return Some(DisableLayer::Block);
402 }
403 }
404
405 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
407 && (line_rules.contains("*") || line_rules.contains(rule_name))
408 {
409 return Some(DisableLayer::Line);
410 }
411
412 None
413 }
414
415 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
417 let mut disabled = HashSet::new();
418
419 if let Some(transition) = self.find_transition(line_number) {
421 if transition.disabled.contains("*") {
422 disabled.insert("*".to_string());
423 } else {
424 for rule in &transition.disabled {
425 disabled.insert(rule.clone());
426 }
427 }
428 }
429
430 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
432 for rule in line_rules {
433 disabled.insert(rule.clone());
434 }
435 }
436
437 disabled
438 }
439
440 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
442 self.file_rule_config.get(rule_name)
443 }
444
445 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
447 &self.file_rule_config
448 }
449
450 pub fn export_for_file_index(&self) -> FileIndexExport {
454 let file_disabled = self.file_disabled_rules.clone();
455
456 let persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)> = self
457 .transitions
458 .iter()
459 .map(|t| (t.line, t.disabled.clone(), t.enabled.clone()))
460 .collect();
461
462 let line_disabled = self.line_disabled_rules.clone();
463
464 (file_disabled, persistent_transitions, line_disabled)
465 }
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum DirectiveKind {
479 Disable,
480 DisableLine,
481 DisableNextLine,
482 DisableFile,
483 Enable,
484 EnableFile,
485 Capture,
486 Restore,
487 ConfigureFile,
488}
489
490#[derive(Debug, Clone, PartialEq)]
492pub struct InlineDirective<'a> {
493 pub kind: DirectiveKind,
494 pub rules: Vec<&'a str>,
495 pub span: std::ops::Range<usize>,
497}
498
499const TOOL_PREFIXES: &[&str] = &["rumdl-", "markdownlint-"];
501
502const DIRECTIVE_KEYWORDS: &[(DirectiveKind, &str)] = &[
506 (DirectiveKind::DisableNextLine, "disable-next-line"),
507 (DirectiveKind::DisableLine, "disable-line"),
508 (DirectiveKind::DisableFile, "disable-file"),
509 (DirectiveKind::Disable, "disable"),
510 (DirectiveKind::EnableFile, "enable-file"),
511 (DirectiveKind::Enable, "enable"),
512 (DirectiveKind::ConfigureFile, "configure-file"),
513 (DirectiveKind::Capture, "capture"),
514 (DirectiveKind::Restore, "restore"),
515];
516
517fn try_parse_directive(s: &str) -> Option<(DirectiveKind, Vec<&str>, usize)> {
521 for tool in TOOL_PREFIXES {
522 if !s.starts_with(tool) {
523 continue;
524 }
525 let after_tool = &s[tool.len()..];
526
527 for &(kind, keyword) in DIRECTIVE_KEYWORDS {
528 if !after_tool.starts_with(keyword) {
529 continue;
530 }
531 let after_kw = &after_tool[keyword.len()..];
532
533 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
536 continue;
537 }
538
539 let close_offset = after_kw.find("-->")?;
541
542 let rules_str = after_kw[..close_offset].trim();
543 let rules = if rules_str.is_empty() {
544 Vec::new()
545 } else {
546 rules_str.split_whitespace().collect()
547 };
548
549 let consumed = tool.len() + keyword.len() + close_offset + 3; return Some((kind, rules, consumed));
551 }
552
553 return None;
555 }
556 None
557}
558
559pub fn parse_inline_directives(line: &str) -> Vec<InlineDirective<'_>> {
565 let mut results = Vec::new();
566 let mut pos = 0;
567
568 while pos < line.len() {
569 let remaining = &line[pos..];
570 let Some(open_offset) = remaining.find("<!-- ") else {
571 break;
572 };
573 let comment_start = pos + open_offset;
574 let after_open = &line[comment_start + 5..]; if let Some((kind, rules, consumed)) = try_parse_directive(after_open) {
577 let comment_end = comment_start + 5 + consumed;
578 results.push(InlineDirective {
579 kind,
580 rules,
581 span: comment_start..comment_end,
582 });
583 pos = comment_end;
584 } else {
585 pos = comment_start + 5;
586 }
587 }
588
589 results
590}
591
592fn find_directive_rules(line: &str, kind: DirectiveKind) -> Option<Vec<&str>> {
598 parse_inline_directives(line)
599 .into_iter()
600 .find(|d| d.kind == kind)
601 .map(|d| d.rules)
602}
603
604pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
606 find_directive_rules(line, DirectiveKind::Disable)
607}
608
609pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
611 find_directive_rules(line, DirectiveKind::Enable)
612}
613
614pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
616 find_directive_rules(line, DirectiveKind::DisableLine)
617}
618
619pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
621 find_directive_rules(line, DirectiveKind::DisableNextLine)
622}
623
624pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
626 find_directive_rules(line, DirectiveKind::DisableFile)
627}
628
629pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
631 find_directive_rules(line, DirectiveKind::EnableFile)
632}
633
634pub fn is_capture_comment(line: &str) -> bool {
636 parse_inline_directives(line)
637 .iter()
638 .any(|d| d.kind == DirectiveKind::Capture)
639}
640
641pub fn is_restore_comment(line: &str) -> bool {
643 parse_inline_directives(line)
644 .iter()
645 .any(|d| d.kind == DirectiveKind::Restore)
646}
647
648const CONFIGURE_FILE_KEYWORD: &str = "configure-file";
649
650fn offset_within(offset: usize, ranges: &[(usize, usize)]) -> bool {
652 ranges.iter().any(|&(start, end)| offset >= start && offset < end)
653}
654
655fn offset_in_code_block(offset: usize, code_blocks: &[(usize, usize)]) -> bool {
657 offset_within(offset, code_blocks)
658}
659
660fn offset_in_code_span(offset: usize, code_spans: &[(usize, usize)]) -> bool {
667 offset_within(offset, code_spans)
668}
669
670fn directives_outside_code_spans<'a>(
675 line: &'a str,
676 line_start: usize,
677 code_spans: &[(usize, usize)],
678) -> Vec<InlineDirective<'a>> {
679 let mut directives = parse_inline_directives(line);
680 if !code_spans.is_empty() {
681 directives.retain(|directive| !offset_in_code_span(line_start + directive.span.start, code_spans));
682 }
683 directives
684}
685
686fn line_in_code_block(line_start: usize, line: &str, code_blocks: &[(usize, usize)]) -> bool {
694 let probe = line
695 .find(|c: char| !c.is_whitespace())
696 .map_or(line_start, |indent| line_start + indent);
697 offset_in_code_block(probe, code_blocks)
698}
699
700fn line_of_offset(text: &str, offset: usize) -> usize {
702 text[..offset].bytes().filter(|&b| b == b'\n').count() + 1
703}
704
705type LocatedWarning = (InlineConfigWarning, usize);
710
711fn drop_warnings_inside_code(
720 content: &str,
721 flavor: MarkdownFlavor,
722 mut warnings: Vec<LocatedWarning>,
723) -> Vec<InlineConfigWarning> {
724 if warnings.is_empty() {
725 return Vec::new();
726 }
727 let code = crate::lint_context::code_ranges(content, flavor);
728 if !code.blocks.is_empty() {
729 let line_spans: Vec<(usize, &str)> = {
734 let mut spans = Vec::new();
735 let mut start = 0;
736 for line in content.split('\n') {
737 spans.push((start, line));
738 start += line.len() + 1;
739 }
740 spans
741 };
742
743 warnings.retain(|(warning, _)| {
744 let Some(&(line_start, line)) = line_spans.get(warning.line_number.saturating_sub(1)) else {
745 return true;
746 };
747 !line_in_code_block(line_start, line, &code.blocks)
748 });
749 }
750 if !code.spans.is_empty() {
751 warnings.retain(|&(_, offset)| !offset_in_code_span(offset, &code.spans));
752 }
753
754 warnings.into_iter().map(|(warning, _)| warning).collect()
755}
756
757fn scan_configure_file_comments(text: &str) -> Vec<(usize, JsonValue)> {
768 let mut found = Vec::new();
769 let mut pos = 0;
770
771 while let Some(open_offset) = text[pos..].find("<!-- ") {
772 let comment_start = pos + open_offset;
773 let after_open = &text[comment_start + 5..]; pos = comment_start + 5;
777
778 for tool in TOOL_PREFIXES {
779 let Some(after_tool) = after_open.strip_prefix(tool) else {
780 continue;
781 };
782 let Some(after_kw) = after_tool.strip_prefix(CONFIGURE_FILE_KEYWORD) else {
783 break;
784 };
785 if !after_kw.is_empty() && !after_kw.starts_with(char::is_whitespace) && !after_kw.starts_with("-->") {
788 break;
789 }
790 let Some(close_offset) = after_kw.find("-->") else {
791 break;
792 };
793
794 let json_str = after_kw[..close_offset].trim();
795 if !json_str.is_empty()
796 && let Ok(value) = serde_json::from_str(json_str)
797 {
798 found.push((comment_start, value));
799 }
800 pos = comment_start + 5 + tool.len() + CONFIGURE_FILE_KEYWORD.len() + close_offset + 3;
801 break;
802 }
803 }
804
805 found
806}
807
808pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
812 scan_configure_file_comments(line).into_iter().next().map(|(_, v)| v)
813}
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub enum DisableScope {
820 Line(usize),
822 Block(usize),
824 File,
826}
827
828impl DisableScope {
829 pub fn carries(self, layer: DisableLayer, line: usize) -> bool {
841 match (self, layer) {
842 (Self::Line(target), DisableLayer::Line) => target == line,
843 (Self::Block(start), DisableLayer::Block) => start < line,
845 (Self::File, DisableLayer::File) => true,
846 _ => false,
847 }
848 }
849}
850
851#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct DisableSite {
854 pub line: usize,
856 pub span: std::ops::Range<usize>,
858 pub kind: &'static str,
860 pub rules: Vec<String>,
862 pub scope: DisableScope,
864}
865
866pub fn collect_disable_sites(
878 content: &str,
879 code_blocks: &[(usize, usize)],
880 code_spans: &[(usize, usize)],
881) -> Vec<DisableSite> {
882 if !has_inline_config_markers(content) {
883 return Vec::new();
884 }
885
886 let line_spans: Vec<(usize, &str)> = {
889 let mut spans = Vec::new();
890 let mut start = 0;
891 for line in content.split('\n') {
892 spans.push((start, line));
893 start += line.len() + 1;
894 }
895 spans
896 };
897
898 let mut sites = Vec::new();
899
900 for (idx, &(line_start, line)) in line_spans.iter().enumerate() {
901 if line_in_code_block(line_start, line, code_blocks) {
902 continue;
903 }
904 let line_num = idx + 1;
905 for directive in directives_outside_code_spans(line, line_start, code_spans) {
906 let (kind, scope) = match directive.kind {
907 DirectiveKind::Disable => ("disable", DisableScope::Block(line_num)),
908 DirectiveKind::DisableLine => ("disable-line", DisableScope::Line(line_num)),
909 DirectiveKind::DisableNextLine => ("disable-next-line", DisableScope::Line(line_num + 1)),
910 DirectiveKind::DisableFile => ("disable-file", DisableScope::File),
911 DirectiveKind::Enable
912 | DirectiveKind::EnableFile
913 | DirectiveKind::Capture
914 | DirectiveKind::Restore
915 | DirectiveKind::ConfigureFile => continue,
916 };
917 sites.push(DisableSite {
918 line: line_num,
919 span: directive.span,
920 kind,
921 rules: directive.rules.iter().map(|rule| (*rule).to_string()).collect(),
922 scope,
923 });
924 }
925 }
926
927 for (offset, json_config) in scan_configure_file_comments(content) {
931 if offset_in_code_block(offset, code_blocks) || offset_in_code_span(offset, code_spans) {
932 continue;
933 }
934 let Some(obj) = json_config.as_object() else {
935 continue;
936 };
937 let rules: Vec<String> = obj
938 .iter()
939 .filter(|(_, value)| value.as_bool() == Some(false))
940 .map(|(name, _)| name.clone())
941 .collect();
942 if rules.is_empty() {
943 continue;
944 }
945 let line_num = line_of_offset(content, offset);
946 let Some(&(line_start, line)) = line_spans.get(line_num - 1) else {
947 continue;
948 };
949 let comment_end = content[offset..]
950 .find("-->")
951 .map_or(content.len(), |close| offset + close + 3);
952 sites.push(DisableSite {
953 line: line_num,
954 span: (offset - line_start)..(comment_end - line_start).min(line.len()),
955 kind: "configure-file",
956 rules,
957 scope: DisableScope::File,
958 });
959 }
960
961 sites.sort_by_key(|site| (site.line, site.span.start));
962 sites
963}
964
965#[derive(Debug, Clone, PartialEq, Eq)]
967pub enum InlineConfigProblem {
968 UnknownRule,
970 UnknownOption { key: String },
973 EnableHasNoEffect { reason: EnableNoEffectReason },
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub enum EnableNoEffectReason {
985 NotEnabled,
987 IgnoredForFile,
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct InlineConfigWarning {
995 pub line_number: usize,
997 pub rule_name: String,
999 pub comment_type: String,
1001 pub suggestion: Option<String>,
1003 pub problem: InlineConfigProblem,
1005}
1006
1007impl InlineConfigWarning {
1008 pub fn format_message(&self) -> String {
1010 match &self.problem {
1013 InlineConfigProblem::UnknownOption { key } => match self.suggestion {
1014 Some(ref suggestion) => format!(
1015 "Unknown option for rule {}: {} (did you mean: {}?)",
1016 self.rule_name, key, suggestion
1017 ),
1018 None => format!("Unknown option for rule {}: {}", self.rule_name, key),
1019 },
1020 InlineConfigProblem::UnknownRule => match self.suggestion {
1021 Some(ref suggestion) => format!(
1022 "Unknown rule in inline {} comment: {} (did you mean: {}?)",
1023 self.comment_type, self.rule_name, suggestion
1024 ),
1025 None => format!(
1026 "Unknown rule in inline {} comment: {}",
1027 self.comment_type, self.rule_name
1028 ),
1029 },
1030 InlineConfigProblem::EnableHasNoEffect {
1031 reason: EnableNoEffectReason::NotEnabled,
1032 } => format!(
1033 "Rule {} is not enabled in configuration, so the inline {} comment enabling it has no effect",
1034 self.rule_name, self.comment_type
1035 ),
1036 InlineConfigProblem::EnableHasNoEffect {
1037 reason: EnableNoEffectReason::IgnoredForFile,
1038 } => format!(
1039 "Rule {} is ignored for this file by per-file-ignores, so the inline {} comment enabling it has no effect",
1040 self.rule_name, self.comment_type
1041 ),
1042 }
1043 }
1044
1045 pub fn print_warning(&self, file_path: &str) {
1047 eprintln!(
1048 "{} {}:{}: {}",
1049 crate::utils::warning_label("inline config warning"),
1050 file_path,
1051 self.line_number,
1052 self.format_message()
1053 );
1054 }
1055
1056 pub fn to_lint_warning(&self, content: &str) -> crate::rule::LintWarning {
1064 let line_width = content
1065 .lines()
1066 .nth(self.line_number.saturating_sub(1))
1067 .map_or(0, |line| line.chars().count());
1068 crate::rule::LintWarning {
1069 message: self.format_message(),
1070 line: self.line_number,
1071 column: 1,
1072 end_line: self.line_number,
1073 end_column: line_width + 1,
1074 severity: crate::rule::Severity::Warning,
1075 fix: None,
1076 rule_name: Some(INLINE_CONFIG_DIAGNOSTIC_NAME.to_string()),
1077 }
1078 }
1079}
1080
1081pub const INLINE_CONFIG_DIAGNOSTIC_NAME: &str = "inline-config";
1086
1087pub fn validate_inline_config_rules(content: &str, flavor: MarkdownFlavor) -> Vec<InlineConfigWarning> {
1096 use crate::config::{RULE_ALIAS_MAP, is_valid_rule_name, suggest_similar_key};
1097
1098 let mut warnings: Vec<LocatedWarning> = Vec::new();
1099 let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
1100
1101 let suggest = |rule_name: &str| {
1102 suggest_similar_key(rule_name, &all_rule_names).map(|s| if s.starts_with("MD") { s } else { s.to_lowercase() })
1103 };
1104
1105 let registry = crate::config::default_registry();
1109 for (offset, json_config) in scan_configure_file_comments(content) {
1110 let Some(obj) = json_config.as_object() else {
1111 continue;
1112 };
1113 let line_number = line_of_offset(content, offset);
1114 for (rule_name, rule_config) in obj {
1115 if !is_valid_rule_name(rule_name) {
1116 warnings.push((
1117 InlineConfigWarning {
1118 line_number,
1119 rule_name: rule_name.clone(),
1120 comment_type: "configure-file".to_string(),
1121 suggestion: suggest(rule_name),
1122 problem: InlineConfigProblem::UnknownRule,
1123 },
1124 offset,
1125 ));
1126 continue;
1129 }
1130 let Some(options) = rule_config.as_object() else {
1132 continue;
1133 };
1134 let canonical = normalize_rule_name(rule_name);
1135 let Some(valid_keys) = registry.config_keys_for(&canonical) else {
1136 continue;
1137 };
1138 let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
1139 for key in options.keys() {
1140 if !valid_keys.contains(key) {
1141 warnings.push((
1142 InlineConfigWarning {
1143 line_number,
1144 rule_name: canonical.clone(),
1145 comment_type: "configure-file".to_string(),
1146 suggestion: suggest_similar_key(key, &valid_keys_vec),
1147 problem: InlineConfigProblem::UnknownOption { key: key.clone() },
1148 },
1149 offset,
1150 ));
1151 }
1152 }
1153 }
1154 }
1155
1156 let mut line_start = 0;
1159 for (idx, line) in content.split('\n').enumerate() {
1160 let line_num = idx + 1;
1161
1162 for directive in parse_inline_directives(line) {
1164 let comment_type = match directive.kind {
1165 DirectiveKind::Disable => "disable",
1166 DirectiveKind::Enable => "enable",
1167 DirectiveKind::DisableLine => "disable-line",
1168 DirectiveKind::DisableNextLine => "disable-next-line",
1169 DirectiveKind::DisableFile => "disable-file",
1170 DirectiveKind::EnableFile => "enable-file",
1171 DirectiveKind::ConfigureFile | DirectiveKind::Capture | DirectiveKind::Restore => continue,
1173 };
1174 let offset = line_start + directive.span.start;
1175 for rule_name in &directive.rules {
1176 if !is_valid_rule_name(rule_name) {
1177 warnings.push((
1178 InlineConfigWarning {
1179 line_number: line_num,
1180 rule_name: (*rule_name).to_string(),
1181 comment_type: comment_type.to_string(),
1182 suggestion: suggest(rule_name),
1183 problem: InlineConfigProblem::UnknownRule,
1184 },
1185 offset,
1186 ));
1187 }
1188 }
1189 }
1190 line_start += line.len() + 1;
1191 }
1192
1193 warnings.sort_by_key(|(warning, _)| warning.line_number);
1196 drop_warnings_inside_code(content, flavor, warnings)
1197}
1198
1199pub fn validate_inline_enables_against_active_rules(
1216 content: &str,
1217 flavor: MarkdownFlavor,
1218 active_rules: &HashSet<String>,
1219 ignored_for_file: &HashSet<String>,
1220) -> Vec<InlineConfigWarning> {
1221 use crate::config::is_valid_rule_name;
1222
1223 let mut warnings: Vec<LocatedWarning> = Vec::new();
1224
1225 let flag = |warnings: &mut Vec<LocatedWarning>, name: &str, comment_type: &str, line: usize, offset: usize| {
1226 if !is_valid_rule_name(name) {
1228 return;
1229 }
1230 let canonical = normalize_rule_name(name);
1231 let reason = if !active_rules.contains(&canonical) {
1235 EnableNoEffectReason::NotEnabled
1236 } else if ignored_for_file.contains(&canonical) {
1237 EnableNoEffectReason::IgnoredForFile
1238 } else {
1239 return;
1240 };
1241 warnings.push((
1242 InlineConfigWarning {
1243 line_number: line,
1244 rule_name: canonical,
1245 comment_type: comment_type.to_string(),
1246 suggestion: None,
1247 problem: InlineConfigProblem::EnableHasNoEffect { reason },
1248 },
1249 offset,
1250 ));
1251 };
1252
1253 for (offset, json_config) in scan_configure_file_comments(content) {
1255 let Some(obj) = json_config.as_object() else {
1256 continue;
1257 };
1258 let line = line_of_offset(content, offset);
1259 for (rule_name, rule_config) in obj {
1260 if rule_config.as_bool() == Some(true) {
1263 flag(&mut warnings, rule_name, "configure-file", line, offset);
1264 }
1265 }
1266 }
1267
1268 let mut line_start = 0;
1272 for (idx, line) in content.split('\n').enumerate() {
1273 for directive in parse_inline_directives(line) {
1274 let comment_type = match directive.kind {
1275 DirectiveKind::Enable => "enable",
1276 DirectiveKind::EnableFile => "enable-file",
1277 _ => continue,
1278 };
1279 let offset = line_start + directive.span.start;
1280 for rule in &directive.rules {
1281 flag(&mut warnings, rule, comment_type, idx + 1, offset);
1282 }
1283 }
1284 line_start += line.len() + 1;
1285 }
1286
1287 warnings.sort_by_key(|(warning, _)| warning.line_number);
1288 drop_warnings_inside_code(content, flavor, warnings)
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294
1295 #[test]
1298 fn test_parse_inline_directives_all_kinds() {
1299 let cases: &[(&str, DirectiveKind)] = &[
1301 ("<!-- rumdl-disable -->", DirectiveKind::Disable),
1302 ("<!-- rumdl-disable-line -->", DirectiveKind::DisableLine),
1303 ("<!-- rumdl-disable-next-line -->", DirectiveKind::DisableNextLine),
1304 ("<!-- rumdl-disable-file -->", DirectiveKind::DisableFile),
1305 ("<!-- rumdl-enable -->", DirectiveKind::Enable),
1306 ("<!-- rumdl-enable-file -->", DirectiveKind::EnableFile),
1307 ("<!-- rumdl-capture -->", DirectiveKind::Capture),
1308 ("<!-- rumdl-restore -->", DirectiveKind::Restore),
1309 ("<!-- rumdl-configure-file {} -->", DirectiveKind::ConfigureFile),
1310 ("<!-- markdownlint-disable -->", DirectiveKind::Disable),
1312 ("<!-- markdownlint-disable-line -->", DirectiveKind::DisableLine),
1313 (
1314 "<!-- markdownlint-disable-next-line -->",
1315 DirectiveKind::DisableNextLine,
1316 ),
1317 ("<!-- markdownlint-enable -->", DirectiveKind::Enable),
1318 ("<!-- markdownlint-capture -->", DirectiveKind::Capture),
1319 ("<!-- markdownlint-restore -->", DirectiveKind::Restore),
1320 ];
1321 for (input, expected_kind) in cases {
1322 let directives = parse_inline_directives(input);
1323 assert_eq!(
1324 directives.len(),
1325 1,
1326 "Expected 1 directive for {input:?}, got {directives:?}"
1327 );
1328 assert_eq!(directives[0].kind, *expected_kind, "Wrong kind for {input:?}");
1329 }
1330 }
1331
1332 #[test]
1333 fn test_parse_inline_directives_disambiguation() {
1334 let line = "<!-- rumdl-disable-line MD001 -->";
1336 let directives = parse_inline_directives(line);
1337 assert_eq!(directives.len(), 1);
1338 assert_eq!(directives[0].kind, DirectiveKind::DisableLine);
1339
1340 let line = "<!-- rumdl-disable-next-line -->";
1341 let directives = parse_inline_directives(line);
1342 assert_eq!(directives.len(), 1);
1343 assert_eq!(directives[0].kind, DirectiveKind::DisableNextLine);
1344
1345 let line = "<!-- rumdl-disable-file MD001 -->";
1346 let directives = parse_inline_directives(line);
1347 assert_eq!(directives.len(), 1);
1348 assert_eq!(directives[0].kind, DirectiveKind::DisableFile);
1349
1350 let line = "<!-- rumdl-enable-file -->";
1351 let directives = parse_inline_directives(line);
1352 assert_eq!(directives.len(), 1);
1353 assert_eq!(directives[0].kind, DirectiveKind::EnableFile);
1354 }
1355
1356 #[test]
1357 fn test_parse_inline_directives_no_space_before_close() {
1358 let directives = parse_inline_directives("<!-- rumdl-disable-->");
1360 assert_eq!(directives.len(), 1);
1361 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1362 assert!(directives[0].rules.is_empty());
1363
1364 let directives = parse_inline_directives("<!-- rumdl-enable-->");
1365 assert_eq!(directives.len(), 1);
1366 assert_eq!(directives[0].kind, DirectiveKind::Enable);
1367 }
1368
1369 #[test]
1370 fn test_parse_inline_directives_multiple_on_one_line() {
1371 let line = "<!-- rumdl-disable MD001 --> text <!-- rumdl-enable MD001 -->";
1372 let directives = parse_inline_directives(line);
1373 assert_eq!(directives.len(), 2);
1374 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1375 assert_eq!(directives[0].rules, vec!["MD001"]);
1376 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1377 assert_eq!(directives[1].rules, vec!["MD001"]);
1378 }
1379
1380 #[test]
1381 fn test_parse_inline_directives_global_disable_then_specific_enable() {
1382 let line = "<!-- rumdl-disable --> <!-- rumdl-enable MD001 -->";
1383 let directives = parse_inline_directives(line);
1384 assert_eq!(directives.len(), 2);
1385 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1386 assert!(directives[0].rules.is_empty());
1387 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1388 assert_eq!(directives[1].rules, vec!["MD001"]);
1389 }
1390
1391 #[test]
1392 fn test_parse_inline_directives_word_boundary() {
1393 assert!(parse_inline_directives("<!-- rumdl-disablefoo -->").is_empty());
1395 assert!(parse_inline_directives("<!-- rumdl-enablebar -->").is_empty());
1397 assert!(parse_inline_directives("<!-- rumdl-captures -->").is_empty());
1399 }
1400
1401 #[test]
1402 fn test_parse_inline_directives_no_closing_tag() {
1403 assert!(parse_inline_directives("<!-- rumdl-disable MD001").is_empty());
1405 assert!(parse_inline_directives("<!-- rumdl-enable").is_empty());
1406 }
1407
1408 #[test]
1409 fn test_parse_inline_directives_not_a_comment() {
1410 assert!(parse_inline_directives("rumdl-disable MD001 -->").is_empty());
1411 assert!(parse_inline_directives("Some regular text").is_empty());
1412 assert!(parse_inline_directives("").is_empty());
1413 }
1414
1415 #[test]
1416 fn test_parse_inline_directives_case_sensitive() {
1417 assert!(parse_inline_directives("<!-- RUMDL-DISABLE -->").is_empty());
1418 assert!(parse_inline_directives("<!-- Markdownlint-Disable -->").is_empty());
1419 }
1420
1421 #[test]
1422 fn test_parse_inline_directives_rules_extraction() {
1423 let directives = parse_inline_directives("<!-- rumdl-disable MD001 MD002 MD013 -->");
1424 assert_eq!(directives[0].rules, vec!["MD001", "MD002", "MD013"]);
1425
1426 let directives = parse_inline_directives("<!-- rumdl-disable\tMD001\tMD002 -->");
1428 assert_eq!(directives[0].rules, vec!["MD001", "MD002"]);
1429
1430 let directives = parse_inline_directives("<!-- rumdl-disable MD001 -->");
1432 assert_eq!(directives[0].rules, vec!["MD001"]);
1433 }
1434
1435 #[test]
1436 fn test_parse_inline_directives_embedded_in_text() {
1437 let line = "Some text <!-- rumdl-disable MD001 --> more text";
1438 let directives = parse_inline_directives(line);
1439 assert_eq!(directives.len(), 1);
1440 assert_eq!(directives[0].rules, vec!["MD001"]);
1441
1442 let line = "🚀 <!-- rumdl-disable MD001 --> 🎉";
1443 let directives = parse_inline_directives(line);
1444 assert_eq!(directives.len(), 1);
1445 assert_eq!(directives[0].rules, vec!["MD001"]);
1446 }
1447
1448 #[test]
1449 fn test_parse_inline_directives_mixed_tools_same_line() {
1450 let line = "<!-- rumdl-disable MD001 --> <!-- markdownlint-enable MD002 -->";
1451 let directives = parse_inline_directives(line);
1452 assert_eq!(directives.len(), 2);
1453 assert_eq!(directives[0].kind, DirectiveKind::Disable);
1454 assert_eq!(directives[0].rules, vec!["MD001"]);
1455 assert_eq!(directives[1].kind, DirectiveKind::Enable);
1456 assert_eq!(directives[1].rules, vec!["MD002"]);
1457 }
1458
1459 #[test]
1462 fn test_parse_disable_comment() {
1463 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
1465 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
1466
1467 assert_eq!(
1469 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
1470 Some(vec!["MD001", "MD002"])
1471 );
1472
1473 assert_eq!(parse_disable_comment("Some regular text"), None);
1475 }
1476
1477 #[test]
1478 fn test_parse_disable_line_comment() {
1479 assert_eq!(
1481 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
1482 Some(vec![])
1483 );
1484
1485 assert_eq!(
1487 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
1488 Some(vec!["MD013"])
1489 );
1490
1491 assert_eq!(parse_disable_line_comment("Some regular text"), None);
1493 }
1494
1495 #[test]
1496 fn test_inline_config_from_content() {
1497 let content = r#"# Test Document
1498
1499<!-- markdownlint-disable MD013 -->
1500This is a very long line that would normally trigger MD013 but it's disabled
1501
1502<!-- markdownlint-enable MD013 -->
1503This line will be checked again
1504
1505<!-- markdownlint-disable-next-line MD001 -->
1506# This heading will not be checked for MD001
1507## But this one will
1508
1509Some text <!-- markdownlint-disable-line MD013 -->
1510
1511<!-- markdownlint-capture -->
1512<!-- markdownlint-disable MD001 MD002 -->
1513# Heading with MD001 disabled
1514<!-- markdownlint-restore -->
1515# Heading with MD001 enabled again
1516"#;
1517
1518 let config = InlineConfig::from_content(content);
1519
1520 assert!(config.is_rule_disabled("MD013", 4));
1522
1523 assert!(!config.is_rule_disabled("MD013", 7));
1525
1526 assert!(config.is_rule_disabled("MD001", 10));
1528
1529 assert!(!config.is_rule_disabled("MD001", 11));
1531
1532 assert!(config.is_rule_disabled("MD013", 13));
1534
1535 assert!(!config.is_rule_disabled("MD001", 19));
1537 }
1538
1539 #[test]
1540 fn test_capture_restore() {
1541 let content = r#"<!-- markdownlint-disable MD001 -->
1542<!-- markdownlint-capture -->
1543<!-- markdownlint-disable MD002 MD003 -->
1544<!-- markdownlint-restore -->
1545Some content after restore
1546"#;
1547
1548 let config = InlineConfig::from_content(content);
1549
1550 assert!(config.is_rule_disabled("MD001", 5));
1552 assert!(!config.is_rule_disabled("MD002", 5));
1553 assert!(!config.is_rule_disabled("MD003", 5));
1554 }
1555
1556 #[test]
1557 fn test_validate_inline_config_rules_unknown_rule() {
1558 let content = "<!-- rumdl-disable abc -->\nSome content";
1559 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1560 assert_eq!(warnings.len(), 1);
1561 assert_eq!(warnings[0].line_number, 1);
1562 assert_eq!(warnings[0].rule_name, "abc");
1563 assert_eq!(warnings[0].comment_type, "disable");
1564 }
1565
1566 #[test]
1567 fn test_validate_inline_config_rules_valid_rule() {
1568 let content = "<!-- rumdl-disable MD001 -->\nSome content";
1569 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1570 assert!(
1571 warnings.is_empty(),
1572 "MD001 is a valid rule, should not produce warnings"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_validate_inline_config_rules_alias() {
1578 let content = "<!-- rumdl-disable heading-increment -->\nSome content";
1579 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1580 assert!(warnings.is_empty(), "heading-increment is a valid alias for MD001");
1581 }
1582
1583 #[test]
1584 fn test_validate_inline_config_rules_multiple_unknown() {
1585 let content = r#"<!-- rumdl-disable abc xyz -->
1586<!-- rumdl-disable-line foo -->
1587<!-- markdownlint-disable-next-line bar -->
1588"#;
1589 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1590 assert_eq!(warnings.len(), 4);
1591 assert_eq!(warnings[0].rule_name, "abc");
1592 assert_eq!(warnings[1].rule_name, "xyz");
1593 assert_eq!(warnings[2].rule_name, "foo");
1594 assert_eq!(warnings[3].rule_name, "bar");
1595 }
1596
1597 #[test]
1598 fn test_validate_inline_config_rules_suggestion() {
1599 let content = "<!-- rumdl-disable MD00 -->\n";
1601 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1602 assert_eq!(warnings.len(), 1);
1603 assert!(warnings[0].suggestion.is_some());
1605 }
1606
1607 #[test]
1608 fn test_validate_inline_config_rules_file_comments() {
1609 let content = "<!-- rumdl-disable-file nonexistent -->\n<!-- markdownlint-enable-file another_fake -->";
1610 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1611 assert_eq!(warnings.len(), 2);
1612 assert_eq!(warnings[0].comment_type, "disable-file");
1613 assert_eq!(warnings[1].comment_type, "enable-file");
1614 }
1615
1616 #[test]
1617 fn test_validate_inline_config_rules_global_disable() {
1618 let content = "<!-- rumdl-disable -->\n<!-- markdownlint-enable -->";
1620 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1621 assert!(warnings.is_empty(), "Global disable/enable should not produce warnings");
1622 }
1623
1624 #[test]
1625 fn test_validate_inline_config_rules_mixed_valid_invalid() {
1626 let content = "<!-- rumdl-disable MD001 abc MD003 xyz -->";
1628 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1629 assert_eq!(warnings.len(), 2);
1630 assert_eq!(warnings[0].rule_name, "abc");
1631 assert_eq!(warnings[1].rule_name, "xyz");
1632 }
1633
1634 #[test]
1635 fn test_validate_inline_config_rules_configure_file() {
1636 let content =
1638 r#"<!-- rumdl-configure-file { "MD013": { "line_length": 120 }, "nonexistent": { "foo": true } } -->"#;
1639 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1640 assert_eq!(warnings.len(), 1);
1641 assert_eq!(warnings[0].rule_name, "nonexistent");
1642 assert_eq!(warnings[0].comment_type, "configure-file");
1643 }
1644
1645 #[test]
1646 fn test_validate_inline_config_rules_markdownlint_variants() {
1647 let content = r#"<!-- markdownlint-disable unknown_rule -->
1649<!-- markdownlint-enable another_fake -->
1650<!-- markdownlint-disable-line bad_rule -->
1651<!-- markdownlint-disable-next-line fake_rule -->
1652<!-- markdownlint-disable-file missing_rule -->
1653<!-- markdownlint-enable-file nonexistent -->
1654"#;
1655 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1656 assert_eq!(warnings.len(), 6);
1657 assert_eq!(warnings[0].rule_name, "unknown_rule");
1658 assert_eq!(warnings[1].rule_name, "another_fake");
1659 assert_eq!(warnings[2].rule_name, "bad_rule");
1660 assert_eq!(warnings[3].rule_name, "fake_rule");
1661 assert_eq!(warnings[4].rule_name, "missing_rule");
1662 assert_eq!(warnings[5].rule_name, "nonexistent");
1663 }
1664
1665 #[test]
1669 fn test_validate_inline_config_rules_ignores_code_blocks() {
1670 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-disable made_up_rule -->\n```\n";
1671 assert!(
1672 validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty(),
1673 "a directive inside a fence is documentation, not configuration"
1674 );
1675
1676 let tilde = "# Doc\n\n~~~markdown\n<!-- rumdl-disable made_up_rule -->\n~~~\n";
1677 assert!(validate_inline_config_rules(tilde, MarkdownFlavor::Standard).is_empty());
1678
1679 let indented = "# Doc\n\n <!-- rumdl-disable made_up_rule -->\n";
1680 assert!(validate_inline_config_rules(indented, MarkdownFlavor::Standard).is_empty());
1681
1682 let outside = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
1683 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
1684 }
1685
1686 #[test]
1691 fn test_validate_inline_config_rules_reports_inside_an_indented_container() {
1692 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-disable made_up_rule -->\n";
1693 assert_eq!(
1694 validate_inline_config_rules(admonition, MarkdownFlavor::MkDocs).len(),
1695 1,
1696 "an admonition holds its content at an indent that is not code"
1697 );
1698 assert!(
1699 validate_inline_config_rules(admonition, MarkdownFlavor::Standard).is_empty(),
1700 "without admonitions the same lines are an indented code block"
1701 );
1702
1703 let tab = "# Doc\n\n=== \"Tab\"\n\n <!-- rumdl-disable made_up_rule -->\n";
1704 assert_eq!(validate_inline_config_rules(tab, MarkdownFlavor::MkDocs).len(), 1);
1705 assert!(validate_inline_config_rules(tab, MarkdownFlavor::Standard).is_empty());
1706 }
1707
1708 #[test]
1711 fn test_validate_inline_enables_reports_inside_an_indented_container() {
1712 let admonition = "# Doc\n\n!!! example\n\n <!-- rumdl-enable MD013 -->\n";
1713 let active = active_set(&["MD009"]);
1714 assert_eq!(
1715 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::MkDocs, &active, &no_ignores())
1716 .len(),
1717 1,
1718 "the enable is live in an admonition body, and configuration never enabled MD013"
1719 );
1720 assert!(
1721 validate_inline_enables_against_active_rules(admonition, MarkdownFlavor::Standard, &active, &no_ignores())
1722 .is_empty(),
1723 "without admonitions the same lines are an indented code block"
1724 );
1725 }
1726
1727 #[test]
1730 fn test_validate_inline_config_rules_ignores_configure_file_in_code_block() {
1731 let fenced = "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n```\n";
1732 assert!(validate_inline_config_rules(fenced, MarkdownFlavor::Standard).is_empty());
1733
1734 let multiline = "# Doc\n\n```markdown\n<!-- rumdl-configure-file\n{ \"MD013\": { \"bogus\": 1 } }\n-->\n```\n";
1735 assert!(validate_inline_config_rules(multiline, MarkdownFlavor::Standard).is_empty());
1736
1737 let outside = "# Doc\n\n<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->\n";
1738 assert_eq!(validate_inline_config_rules(outside, MarkdownFlavor::Standard).len(), 1);
1739 }
1740
1741 #[test]
1742 fn test_validate_inline_config_rules_markdownlint_configure_file() {
1743 let content = r#"<!-- markdownlint-configure-file { "fake_rule": {} } -->"#;
1744 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
1745 assert_eq!(warnings.len(), 1);
1746 assert_eq!(warnings[0].rule_name, "fake_rule");
1747 assert_eq!(warnings[0].comment_type, "configure-file");
1748 }
1749
1750 #[test]
1751 fn test_get_rule_config_from_configure_file() {
1752 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 50}} -->
1753
1754This is a test line."#;
1755
1756 let inline_config = InlineConfig::from_content(content);
1757 let config_override = inline_config.get_rule_config("MD013");
1758
1759 assert!(config_override.is_some(), "MD013 config should be found");
1760 let json = config_override.unwrap();
1761 assert!(json.is_object(), "Config should be an object");
1762 let obj = json.as_object().unwrap();
1763 assert!(obj.contains_key("line_length"), "Should have line_length key");
1764 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
1765 }
1766
1767 #[test]
1768 fn test_get_rule_config_tables_false() {
1769 let content = r#"<!-- markdownlint-configure-file {"MD013": {"tables": false}} -->"#;
1771
1772 let inline_config = InlineConfig::from_content(content);
1773 let config_override = inline_config.get_rule_config("MD013");
1774
1775 assert!(config_override.is_some(), "MD013 config should be found");
1776 let json = config_override.unwrap();
1777 let obj = json.as_object().unwrap();
1778 assert!(obj.contains_key("tables"), "Should have tables key");
1779 assert!(!obj.get("tables").unwrap().as_bool().unwrap());
1780 }
1781
1782 fn active_set(names: &[&str]) -> HashSet<String> {
1796 names.iter().map(|s| (*s).to_string()).collect()
1797 }
1798
1799 fn no_ignores() -> HashSet<String> {
1800 HashSet::new()
1801 }
1802
1803 #[test]
1804 fn test_inline_enable_of_inactive_rule_warns() {
1805 let active = active_set(&["MD013", "MD022"]);
1806 let content = "<!-- rumdl-enable MD012 -->\n";
1807
1808 let warnings =
1809 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1810
1811 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1812 assert_eq!(warnings[0].rule_name, "MD012");
1813 assert_eq!(warnings[0].comment_type, "enable");
1814 assert_eq!(
1815 warnings[0].problem,
1816 InlineConfigProblem::EnableHasNoEffect {
1817 reason: EnableNoEffectReason::NotEnabled
1818 }
1819 );
1820 assert_eq!(warnings[0].line_number, 1);
1821 }
1822
1823 #[test]
1824 fn test_inline_enable_of_active_rule_does_not_warn() {
1825 let active = active_set(&["MD012", "MD013"]);
1828 let content = "<!-- rumdl-enable MD012 -->\n";
1829
1830 let warnings =
1831 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1832
1833 assert!(warnings.is_empty(), "active rule warned: {warnings:?}");
1834 }
1835
1836 #[test]
1837 fn test_inline_enable_of_per_file_ignored_rule_warns() {
1838 let active = active_set(&["MD012", "MD013"]);
1841 let ignored = active_set(&["MD012"]);
1842 let content = "<!-- rumdl-enable MD012 -->\n";
1843
1844 let warnings =
1845 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1846
1847 assert_eq!(warnings.len(), 1, "expected one no-effect warning: {warnings:?}");
1848 assert_eq!(warnings[0].rule_name, "MD012");
1849 assert_eq!(
1850 warnings[0].problem,
1851 InlineConfigProblem::EnableHasNoEffect {
1852 reason: EnableNoEffectReason::IgnoredForFile
1853 }
1854 );
1855 assert!(
1856 warnings[0].format_message().contains("per-file-ignores"),
1857 "the message must name the setting in play: {}",
1858 warnings[0].format_message()
1859 );
1860 }
1861
1862 #[test]
1863 fn test_enable_of_rule_both_disabled_and_ignored_blames_configuration() {
1864 let active = active_set(&["MD013"]);
1867 let ignored = active_set(&["MD012"]);
1868 let content = "<!-- rumdl-enable MD012 -->\n";
1869
1870 let warnings =
1871 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1872
1873 assert_eq!(warnings.len(), 1, "{warnings:?}");
1874 assert_eq!(
1875 warnings[0].problem,
1876 InlineConfigProblem::EnableHasNoEffect {
1877 reason: EnableNoEffectReason::NotEnabled
1878 }
1879 );
1880 }
1881
1882 #[test]
1883 fn test_per_file_ignores_of_an_unrelated_rule_does_not_warn() {
1884 let active = active_set(&["MD012", "MD013"]);
1887 let ignored = active_set(&["MD013"]);
1888 let content = "<!-- rumdl-enable MD012 -->\n";
1889
1890 let warnings =
1891 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1892
1893 assert!(warnings.is_empty(), "unrelated ignore warned: {warnings:?}");
1894 }
1895
1896 #[test]
1897 fn test_enable_inside_code_block_does_not_warn() {
1898 let active = active_set(&["MD012", "MD013"]);
1901 let ignored = active_set(&["MD012"]);
1902
1903 for content in [
1904 "# Doc\n\n```markdown\n<!-- rumdl-enable MD012 -->\n```\n",
1905 "# Doc\n\n```markdown\n<!-- rumdl-configure-file { \"MD012\": true } -->\n```\n",
1906 "# Doc\n\n <!-- rumdl-enable MD012 -->\n",
1907 ] {
1908 let warnings =
1909 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1910 assert!(warnings.is_empty(), "code block warned: {content} -> {warnings:?}");
1911 }
1912
1913 for content in [
1914 "# Doc\n\n<!-- rumdl-enable MD012 -->\n",
1915 "# Doc\n\n<!-- rumdl-configure-file { \"MD012\": true } -->\n",
1916 ] {
1917 let warnings =
1918 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1919 assert_eq!(warnings.len(), 1, "control did not warn: {content} -> {warnings:?}");
1920 }
1921 }
1922
1923 #[test]
1924 fn test_bare_enable_all_does_not_warn() {
1925 let active = active_set(&["MD013"]);
1927 for content in ["<!-- rumdl-enable -->\n", "<!-- rumdl-enable-file -->\n"] {
1928 let warnings = validate_inline_enables_against_active_rules(
1929 content,
1930 MarkdownFlavor::Standard,
1931 &active,
1932 &active_set(&["MD013"]),
1933 );
1934 assert!(warnings.is_empty(), "bare enable warned: {content} -> {warnings:?}");
1935 }
1936 }
1937
1938 #[test]
1939 fn test_enable_file_and_alias_of_inactive_rule_warn() {
1940 let active = active_set(&["MD013"]);
1941 let content = "<!-- rumdl-enable-file no-multiple-blanks -->\n";
1943
1944 let warnings =
1945 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1946
1947 assert_eq!(warnings.len(), 1, "{warnings:?}");
1948 assert_eq!(warnings[0].rule_name, "MD012", "alias must normalize to the id");
1949 assert_eq!(warnings[0].comment_type, "enable-file");
1950 }
1951
1952 #[test]
1953 fn test_configure_file_true_for_inactive_rule_warns_but_false_does_not() {
1954 let active = active_set(&["MD013"]);
1955 let enable = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
1956 let disable = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
1957
1958 let warn_true =
1959 validate_inline_enables_against_active_rules(enable, MarkdownFlavor::Standard, &active, &no_ignores());
1960 let warn_false =
1961 validate_inline_enables_against_active_rules(disable, MarkdownFlavor::Standard, &active, &no_ignores());
1962
1963 assert_eq!(warn_true.len(), 1, "configure-file true should warn: {warn_true:?}");
1964 assert_eq!(warn_true[0].rule_name, "MD012");
1965 assert!(
1966 warn_false.is_empty(),
1967 "configure-file false is a disable, not an ignored enable: {warn_false:?}"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_unknown_rule_in_enable_does_not_warn_as_no_effect() {
1973 let active = active_set(&["MD013"]);
1976 let content = "<!-- rumdl-enable NotARule -->\n";
1977
1978 let warnings =
1979 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &no_ignores());
1980
1981 assert!(warnings.is_empty(), "unknown rule double-warned: {warnings:?}");
1982 }
1983
1984 #[test]
1985 fn test_disable_directive_never_warns_as_no_effect() {
1986 let active = active_set(&["MD012", "MD013"]);
1989 let ignored = active_set(&["MD012"]);
1990 let content = "<!-- rumdl-disable MD012 -->\n<!-- rumdl-disable-file MD012 -->\n";
1991
1992 let warnings =
1993 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
1994
1995 assert!(warnings.is_empty(), "disable warned: {warnings:?}");
1996 }
1997
1998 #[test]
2004 fn test_unknown_option_key_in_configure_file_warns() {
2005 let content = r#"<!-- markdownlint-configure-file {"MD013": {"line_lenght": 20}} -->"#;
2006
2007 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2008
2009 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2010 assert_eq!(warnings[0].rule_name, "MD013");
2011 assert_eq!(
2012 warnings[0].problem,
2013 InlineConfigProblem::UnknownOption {
2014 key: "line_lenght".to_string()
2015 }
2016 );
2017 assert!(
2018 warnings[0].suggestion.is_some(),
2019 "a near-miss key should suggest the real one"
2020 );
2021 assert!(
2022 warnings[0].format_message().contains("Unknown option for rule MD013"),
2023 "message was: {}",
2024 warnings[0].format_message()
2025 );
2026 }
2027
2028 #[test]
2029 fn test_valid_option_keys_do_not_warn_in_either_case_style() {
2030 for content in [
2033 r#"<!-- markdownlint-configure-file {"MD013": {"line_length": 20}} -->"#,
2034 r#"<!-- markdownlint-configure-file {"MD013": {"line-length": 20}} -->"#,
2035 ] {
2036 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2037 assert!(warnings.is_empty(), "valid key warned: {content} -> {warnings:?}");
2038 }
2039 }
2040
2041 #[test]
2042 fn test_unknown_rule_does_not_also_warn_about_its_options() {
2043 let content = r#"<!-- markdownlint-configure-file {"nonexistent": {"whatever": 1}} -->"#;
2046
2047 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2048
2049 assert_eq!(
2050 warnings.len(),
2051 1,
2052 "expected only the unknown-rule warning: {warnings:?}"
2053 );
2054 assert_eq!(warnings[0].rule_name, "nonexistent");
2055 assert_eq!(warnings[0].problem, InlineConfigProblem::UnknownRule);
2056 }
2057
2058 #[test]
2059 fn test_boolean_rule_value_has_no_option_warnings() {
2060 let content = r#"<!-- markdownlint-configure-file {"MD013": false} -->"#;
2061
2062 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2063
2064 assert!(warnings.is_empty(), "a boolean has no option keys: {warnings:?}");
2065 }
2066
2067 #[test]
2068 fn test_unknown_option_key_in_multiline_comment_reports_start_line() {
2069 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_lenght\": 20 }\n}\n-->\n";
2070
2071 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2072
2073 assert_eq!(warnings.len(), 1, "expected one option warning: {warnings:?}");
2074 assert_eq!(warnings[0].line_number, 3, "must point at the comment's opening line");
2075 }
2076
2077 #[test]
2078 fn test_configure_file_spanning_multiple_lines_applies() {
2079 let content = "<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n\n# Head\n";
2080
2081 let inline_config = InlineConfig::from_content(content);
2082 let config_override = inline_config.get_rule_config("MD013");
2083
2084 assert!(config_override.is_some(), "a multi-line configure-file must apply");
2085 let obj = config_override.unwrap().as_object().unwrap();
2086 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 20);
2087 }
2088
2089 #[test]
2090 fn test_every_configure_file_comment_applies_not_just_the_first() {
2091 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";
2094
2095 let inline_config = InlineConfig::from_content(content);
2096
2097 assert!(
2098 inline_config.get_rule_config("MD013").is_some(),
2099 "first (single-line) configure-file dropped"
2100 );
2101 assert!(
2102 inline_config.get_rule_config("MD007").is_some(),
2103 "second (multi-line) configure-file dropped"
2104 );
2105 }
2106
2107 #[test]
2108 fn test_multiline_configure_file_in_code_block_is_ignored() {
2109 let content = "# Head\n\n```markdown\n<!-- markdownlint-configure-file\n{\n \"MD013\": { \"line_length\": 20 }\n}\n-->\n```\n";
2112
2113 let inline_config = InlineConfig::from_content(content);
2114
2115 assert!(
2116 inline_config.get_rule_config("MD013").is_none(),
2117 "configure-file inside a fenced code block must not apply"
2118 );
2119 }
2120
2121 #[test]
2122 fn test_multiline_configure_file_bool_and_alias_still_honored() {
2123 let content = "<!-- markdownlint-configure-file\n{\n \"no-multiple-blanks\": false,\n \"line-length\": { \"line_length\": 70 }\n}\n-->\n";
2126
2127 let inline_config = InlineConfig::from_content(content);
2128
2129 assert!(
2130 inline_config.is_rule_disabled("MD012", 1),
2131 "boolean alias key must disable the rule"
2132 );
2133 let obj = inline_config
2134 .get_rule_config("MD013")
2135 .expect("alias-keyed config should resolve to MD013")
2136 .as_object()
2137 .unwrap();
2138 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 70);
2139 }
2140
2141 #[test]
2142 fn test_multiline_configure_file_warning_reports_start_line() {
2143 let content = "# Head\n\n<!-- markdownlint-configure-file\n{\n \"nonexistent\": { \"foo\": true }\n}\n-->\n";
2146
2147 let warnings = validate_inline_config_rules(content, MarkdownFlavor::Standard);
2148
2149 assert_eq!(warnings.len(), 1, "expected one unknown-rule warning: {warnings:?}");
2150 assert_eq!(warnings[0].rule_name, "nonexistent");
2151 assert_eq!(warnings[0].comment_type, "configure-file");
2152 assert_eq!(
2153 warnings[0].line_number, 3,
2154 "warning must point at the line the comment starts on"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_configure_file_bool_false_disables_rule() {
2160 let content = r#"<!-- markdownlint-configure-file {"MD012": false} -->"#;
2163
2164 let inline_config = InlineConfig::from_content(content);
2165
2166 assert!(inline_config.is_rule_disabled("MD012", 1));
2167 assert!(
2168 inline_config.get_rule_config("MD012").is_none(),
2169 "a boolean should not be stored as rule options"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_configure_file_bool_false_disables_rule_by_alias() {
2175 let content = r#"<!-- markdownlint-configure-file {"no-multiple-blanks": false} -->"#;
2176
2177 let inline_config = InlineConfig::from_content(content);
2178
2179 assert!(inline_config.is_rule_disabled("MD012", 1));
2180 }
2181
2182 #[test]
2183 fn test_configure_file_bool_true_leaves_rule_enabled() {
2184 let content = r#"<!-- markdownlint-configure-file {"MD012": true} -->"#;
2185
2186 let inline_config = InlineConfig::from_content(content);
2187
2188 assert!(!inline_config.is_rule_disabled("MD012", 1));
2189 }
2190
2191 #[test]
2192 fn test_get_rule_config_from_configure_file_alias_key() {
2193 let content = r#"<!-- markdownlint-configure-file {"line-length": {"line_length": 50}} -->"#;
2195
2196 let inline_config = InlineConfig::from_content(content);
2197 let config_override = inline_config.get_rule_config("MD013");
2198
2199 assert!(config_override.is_some(), "alias-keyed config should resolve to MD013");
2200 let obj = config_override.unwrap().as_object().unwrap();
2201 assert_eq!(obj.get("line_length").unwrap().as_u64().unwrap(), 50);
2202 }
2203
2204 #[test]
2207 fn test_parse_disable_does_not_match_disable_line() {
2208 assert_eq!(parse_disable_comment("<!-- rumdl-disable-line MD001 -->"), None);
2210 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-line MD001 -->"), None);
2211 assert_eq!(parse_disable_comment("<!-- rumdl-disable-next-line MD001 -->"), None);
2212 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-next-line -->"), None);
2213 assert_eq!(parse_disable_comment("<!-- rumdl-disable-file MD001 -->"), None);
2214 assert_eq!(parse_disable_comment("<!-- markdownlint-disable-file -->"), None);
2215 }
2216
2217 #[test]
2218 fn test_parse_enable_does_not_match_enable_file() {
2219 assert_eq!(parse_enable_comment("<!-- rumdl-enable-file MD001 -->"), None);
2220 assert_eq!(parse_enable_comment("<!-- markdownlint-enable-file -->"), None);
2221 }
2222
2223 #[test]
2224 fn test_parse_disable_comment_edge_cases() {
2225 assert_eq!(parse_disable_comment("<!-- rumdl-disable-->"), Some(vec![]));
2227
2228 assert_eq!(
2230 parse_disable_comment("<!-- rumdl-disable\tMD001\tMD002 -->"),
2231 Some(vec!["MD001", "MD002"])
2232 );
2233
2234 assert_eq!(
2236 parse_disable_comment("Some text <!-- rumdl-disable MD001 --> more text"),
2237 Some(vec!["MD001"])
2238 );
2239
2240 assert_eq!(parse_disable_comment("<!-- rumdl-disable MD001"), None);
2242
2243 assert_eq!(parse_disable_comment("rumdl-disable MD001 -->"), None);
2245
2246 assert_eq!(parse_disable_comment("<!-- RUMDL-DISABLE -->"), None);
2248
2249 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
2251
2252 assert_eq!(
2254 parse_disable_comment("<!-- rumdl-disable MD001 MD001 MD002 -->"),
2255 Some(vec!["MD001", "MD001", "MD002"])
2256 );
2257
2258 assert_eq!(
2260 parse_disable_comment("🚀 <!-- rumdl-disable MD001 --> 🎉"),
2261 Some(vec!["MD001"])
2262 );
2263
2264 let many_rules = (1..=100).map(|i| format!("MD{i:03}")).collect::<Vec<_>>().join(" ");
2266 let comment = format!("<!-- rumdl-disable {many_rules} -->");
2267 let parsed = parse_disable_comment(&comment);
2268 assert!(parsed.is_some());
2269 assert_eq!(parsed.unwrap().len(), 100);
2270
2271 assert_eq!(
2273 parse_disable_comment("<!-- rumdl-disable MD001-test -->"),
2274 Some(vec!["MD001-test"])
2275 );
2276 assert_eq!(
2277 parse_disable_comment("<!-- rumdl-disable custom_rule -->"),
2278 Some(vec!["custom_rule"])
2279 );
2280 }
2281
2282 #[test]
2283 fn test_parse_enable_comment_edge_cases() {
2284 assert_eq!(parse_enable_comment("<!-- rumdl-enable-->"), Some(vec![]));
2285 assert_eq!(parse_enable_comment("<!-- RUMDL-ENABLE -->"), None);
2286 assert_eq!(parse_enable_comment("<!-- rumdl-enable MD001"), None);
2287 assert_eq!(parse_enable_comment("<!-- rumdl-enable -->"), Some(vec![]));
2288 }
2289
2290 #[test]
2293 fn test_disable_inside_fenced_code_block_ignored() {
2294 let content = "# Document\n```markdown\n<!-- rumdl-disable MD001 -->\nContent\n```\nAfter code block\n";
2295 let config = InlineConfig::from_content(content);
2296 assert!(!config.is_rule_disabled("MD001", 6));
2298 }
2299
2300 #[test]
2301 fn test_disable_inside_tilde_fence_ignored() {
2302 let content = "# Document\n~~~\n<!-- rumdl-disable -->\nContent\n~~~\nAfter code block\n";
2303 let config = InlineConfig::from_content(content);
2304 assert!(!config.is_rule_disabled("MD001", 6));
2305 }
2306
2307 #[test]
2308 fn test_disable_before_code_block_persists_after() {
2309 let content = "<!-- rumdl-disable MD001 -->\n```\ncode\n```\nStill disabled\n";
2311 let config = InlineConfig::from_content(content);
2312 assert!(config.is_rule_disabled("MD001", 5));
2313 }
2314
2315 #[test]
2316 fn test_enable_inside_code_block_ignored() {
2317 let content = "<!-- rumdl-disable MD001 -->\n```\n<!-- rumdl-enable MD001 -->\n```\nShould still be disabled\n";
2319 let config = InlineConfig::from_content(content);
2320 assert!(config.is_rule_disabled("MD001", 5));
2321 }
2322
2323 #[test]
2324 fn test_disable_inside_indented_code_block_ignored() {
2325 let content = "# Document\n\n <!-- rumdl-disable MD001 -->\n\nAfter code block\n";
2329 let config = InlineConfig::from_content(content);
2330 assert!(!config.is_rule_disabled("MD001", 5));
2331
2332 let unindented = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter comment\n";
2334 let config = InlineConfig::from_content(unindented);
2335 assert!(config.is_rule_disabled("MD001", 5));
2336 }
2337
2338 #[test]
2339 fn test_configure_file_inside_indented_code_block_ignored() {
2340 let content = "# Document\n\n <!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2343 let config = InlineConfig::from_content(content);
2344 assert!(config.get_rule_config("MD013").is_none());
2345
2346 let unindented = "# Document\n\n<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->\n";
2347 let config = InlineConfig::from_content(unindented);
2348 assert!(config.get_rule_config("MD013").is_some());
2349 }
2350
2351 #[test]
2354 fn test_disable_inside_code_span_ignored() {
2355 let content = "# Document\n\n`<!-- rumdl-disable MD001 -->`\n\nAfter the span\n";
2358 let config = InlineConfig::from_content(content);
2359 assert!(!config.is_rule_disabled("MD001", 5));
2360
2361 let bare = "# Document\n\n<!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
2363 let config = InlineConfig::from_content(bare);
2364 assert!(config.is_rule_disabled("MD001", 5));
2365 }
2366
2367 #[test]
2368 fn test_directive_beside_one_in_a_code_span_still_applies() {
2369 let content = "`<!-- rumdl-disable MD001 -->` <!-- rumdl-disable MD013 -->\n\ntext\n";
2372 let config = InlineConfig::from_content(content);
2373 assert!(!config.is_rule_disabled("MD001", 3), "the span's directive applied");
2374 assert!(config.is_rule_disabled("MD013", 3), "the live directive was dropped");
2375 }
2376
2377 #[test]
2378 fn test_disable_file_inside_code_span_ignored() {
2379 let content = "`<!-- rumdl-disable-file MD013 -->`\n\ntext\n";
2380 let config = InlineConfig::from_content(content);
2381 assert!(!config.is_rule_disabled("MD013", 3));
2382
2383 let bare = "<!-- rumdl-disable-file MD013 -->\n\ntext\n";
2384 let config = InlineConfig::from_content(bare);
2385 assert!(config.is_rule_disabled("MD013", 3));
2386 }
2387
2388 #[test]
2389 fn test_configure_file_inside_code_span_ignored() {
2390 let content = "`<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 20 } } -->`\n";
2393 let config = InlineConfig::from_content(content);
2394 assert!(config.get_rule_config("MD013").is_none());
2395
2396 let disabling = "`<!-- rumdl-configure-file { \"MD013\": false } -->`\n\ntext\n";
2397 let config = InlineConfig::from_content(disabling);
2398 assert!(!config.is_rule_disabled("MD013", 3));
2399 }
2400
2401 #[test]
2402 fn test_disable_inside_multi_line_code_span_ignored() {
2403 let content = "`a\ntext <!-- rumdl-disable MD001 -->\nb`\n\nAfter the span\n";
2405 let config = InlineConfig::from_content(content);
2406 assert!(!config.is_rule_disabled("MD001", 5));
2407 }
2408
2409 #[test]
2410 fn test_comment_opening_a_line_inside_backticks_still_applies() {
2411 let content = "`a\n<!-- rumdl-disable MD001 -->\nb`\n\nAfter the comment\n";
2415 let config = InlineConfig::from_content(content);
2416 assert!(config.is_rule_disabled("MD001", 5));
2417 }
2418
2419 #[test]
2420 fn test_disable_inside_double_backtick_code_span_ignored() {
2421 let content = "``<!-- rumdl-disable MD001 -->``\n\nAfter the span\n";
2422 let config = InlineConfig::from_content(content);
2423 assert!(!config.is_rule_disabled("MD001", 3));
2424 }
2425
2426 #[test]
2427 fn test_code_ranges_line_up_on_a_crlf_document() {
2428 let fenced = "# Doc\r\n\r\n```markdown\r\n<!-- rumdl-disable MD001 -->\r\n```\r\n\r\nAfter\r\n";
2433 let config = InlineConfig::from_content(fenced);
2434 assert!(!config.is_rule_disabled("MD001", 7), "a fenced directive applied");
2435
2436 let spanned = "# Doc\r\n\r\nSee `<!-- rumdl-disable MD001 -->` here.\r\n\r\nAfter\r\n";
2437 let config = InlineConfig::from_content(spanned);
2438 assert!(!config.is_rule_disabled("MD001", 5), "a directive in a span applied");
2439
2440 let bare = "# Doc\r\n\r\n<!-- rumdl-disable MD001 -->\r\n\r\nAfter\r\n";
2442 let config = InlineConfig::from_content(bare);
2443 assert!(config.is_rule_disabled("MD001", 5));
2444 }
2445
2446 #[test]
2447 fn test_unmatched_backtick_does_not_open_a_code_span() {
2448 let content = "` <!-- rumdl-disable MD001 -->\n\nAfter the comment\n";
2450 let config = InlineConfig::from_content(content);
2451 assert!(config.is_rule_disabled("MD001", 3));
2452 }
2453
2454 #[test]
2455 fn test_validate_inline_config_rules_ignores_code_spans() {
2456 let spanned = "# Doc\n\nSee `<!-- rumdl-disable made_up_rule -->` here.\n";
2459 assert!(
2460 validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty(),
2461 "a directive shown in a code span warned"
2462 );
2463
2464 let bare = "# Doc\n\n<!-- rumdl-disable made_up_rule -->\n";
2465 assert_eq!(
2466 validate_inline_config_rules(bare, MarkdownFlavor::Standard).len(),
2467 1,
2468 "the same name outside a span is still validated"
2469 );
2470 }
2471
2472 #[test]
2473 fn test_validate_inline_config_rules_ignores_configure_file_in_code_span() {
2474 let spanned = "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD013\": { \"bogus\": 1 } } -->` here.\n";
2475 assert!(validate_inline_config_rules(spanned, MarkdownFlavor::Standard).is_empty());
2476 }
2477
2478 #[test]
2479 fn test_enable_inside_code_span_does_not_warn() {
2480 let active = active_set(&["MD012", "MD013"]);
2482 let ignored = active_set(&["MD012"]);
2483
2484 for content in [
2485 "# Doc\n\nSee `<!-- rumdl-enable MD012 -->` here.\n",
2486 "# Doc\n\nSee `<!-- rumdl-configure-file { \"MD012\": true } -->` here.\n",
2487 ] {
2488 let warnings =
2489 validate_inline_enables_against_active_rules(content, MarkdownFlavor::Standard, &active, &ignored);
2490 assert!(warnings.is_empty(), "code span warned: {content} -> {warnings:?}");
2491 }
2492 }
2493
2494 #[test]
2497 fn test_markdownlint_disable_rumdl_enable_interop() {
2498 let content = "<!-- markdownlint-disable MD001 -->\nDisabled\n<!-- rumdl-enable MD001 -->\nEnabled\n";
2499 let config = InlineConfig::from_content(content);
2500 assert!(config.is_rule_disabled("MD001", 2));
2501 assert!(!config.is_rule_disabled("MD001", 4));
2502 }
2503
2504 #[test]
2505 fn test_rumdl_disable_markdownlint_enable_interop() {
2506 let content = "<!-- rumdl-disable MD013 -->\nDisabled\n<!-- markdownlint-enable MD013 -->\nEnabled\n";
2507 let config = InlineConfig::from_content(content);
2508 assert!(config.is_rule_disabled("MD013", 2));
2509 assert!(!config.is_rule_disabled("MD013", 4));
2510 }
2511
2512 #[test]
2515 fn test_global_disable_then_specific_enable() {
2516 let content = "<!-- rumdl-disable -->\nAll off\n<!-- rumdl-enable MD001 -->\nMD001 on, rest off\n";
2517 let config = InlineConfig::from_content(content);
2518 assert!(!config.is_rule_disabled("MD001", 4));
2519 assert!(config.is_rule_disabled("MD002", 4));
2520 assert!(config.is_rule_disabled("MD013", 4));
2521 }
2522
2523 #[test]
2524 fn test_specific_disable_then_global_enable() {
2525 let content = "<!-- rumdl-disable MD001 MD002 -->\nBoth off\n<!-- rumdl-enable -->\nAll on\n";
2526 let config = InlineConfig::from_content(content);
2527 assert!(config.is_rule_disabled("MD001", 2));
2528 assert!(config.is_rule_disabled("MD002", 2));
2529 assert!(!config.is_rule_disabled("MD001", 4));
2530 assert!(!config.is_rule_disabled("MD002", 4));
2531 }
2532
2533 #[test]
2534 fn test_multiple_rules_disable_enable_independently() {
2535 let content = "\
2536Line 1\n\
2537<!-- rumdl-disable MD001 MD002 -->\n\
2538Line 3\n\
2539<!-- rumdl-enable MD001 -->\n\
2540Line 5\n\
2541<!-- rumdl-disable -->\n\
2542Line 7\n\
2543<!-- rumdl-enable MD002 -->\n\
2544Line 9\n";
2545 let config = InlineConfig::from_content(content);
2546
2547 assert!(!config.is_rule_disabled("MD001", 1));
2549 assert!(!config.is_rule_disabled("MD002", 1));
2550
2551 assert!(config.is_rule_disabled("MD001", 3));
2553 assert!(config.is_rule_disabled("MD002", 3));
2554
2555 assert!(!config.is_rule_disabled("MD001", 5));
2557 assert!(config.is_rule_disabled("MD002", 5));
2558
2559 assert!(config.is_rule_disabled("MD001", 7));
2561 assert!(config.is_rule_disabled("MD002", 7));
2562
2563 assert!(config.is_rule_disabled("MD001", 9));
2565 assert!(!config.is_rule_disabled("MD002", 9));
2566 }
2567
2568 #[test]
2571 fn test_empty_content() {
2572 let config = InlineConfig::from_content("");
2573 assert!(!config.is_rule_disabled("MD001", 1));
2574 }
2575
2576 #[test]
2577 fn test_single_disable_comment_only() {
2578 let config = InlineConfig::from_content("<!-- rumdl-disable -->");
2581 assert!(!config.is_rule_disabled("MD001", 1));
2582 assert!(config.is_rule_disabled("MD001", 2));
2583 assert!(config.is_rule_disabled("MD999", 2));
2584
2585 let config = InlineConfig::from_content("<!-- rumdl-disable -->\n# Heading\nSome text");
2587 assert!(!config.is_rule_disabled("MD001", 1));
2588 assert!(config.is_rule_disabled("MD001", 2));
2589 assert!(config.is_rule_disabled("MD001", 3));
2590 }
2591
2592 #[test]
2593 fn test_no_inline_markers() {
2594 let config = InlineConfig::from_content("# Heading\n\nSome text\n\n- list item\n");
2595 assert!(!config.is_rule_disabled("MD001", 1));
2596 assert!(!config.is_rule_disabled("MD001", 5));
2597 }
2598
2599 #[test]
2602 fn test_export_for_file_index_persistent_transitions() {
2603 let content = "Line 1\n<!-- rumdl-disable MD001 -->\nLine 3\n<!-- rumdl-enable MD001 -->\nLine 5\n";
2604 let config = InlineConfig::from_content(content);
2605 let (file_disabled, persistent, _line_disabled) = config.export_for_file_index();
2606
2607 assert!(file_disabled.is_empty());
2608 assert!(
2610 persistent.len() >= 2,
2611 "Expected at least 2 transitions, got {}",
2612 persistent.len()
2613 );
2614 }
2615
2616 #[test]
2617 fn test_export_for_file_index_disable_file() {
2618 let content = "<!-- rumdl-disable-file MD001 -->\n# Heading\n";
2619 let config = InlineConfig::from_content(content);
2620 let (file_disabled, _persistent, _line_disabled) = config.export_for_file_index();
2621
2622 assert!(file_disabled.contains("MD001"));
2623 }
2624
2625 #[test]
2626 fn test_export_for_file_index_disable_line() {
2627 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD001 -->\nLine 3\n";
2628 let config = InlineConfig::from_content(content);
2629 let (_file_disabled, _persistent, line_disabled) = config.export_for_file_index();
2630
2631 assert!(line_disabled.contains_key(&2), "Line 2 should have disabled rules");
2632 assert!(line_disabled[&2].contains("MD001"));
2633 assert!(!line_disabled.contains_key(&3), "Line 3 should not be affected");
2634 }
2635}