rumdl_lib/
inline_config.rs1use crate::markdownlint_config::markdownlint_to_rumdl_rule_key;
22use crate::utils::code_block_utils::CodeBlockUtils;
23use serde_json::Value as JsonValue;
24use std::collections::{HashMap, HashSet};
25
26fn normalize_rule_name(rule: &str) -> String {
29 markdownlint_to_rumdl_rule_key(rule)
30 .map(|s| s.to_string())
31 .unwrap_or_else(|| rule.to_uppercase())
32}
33
34#[derive(Debug, Clone)]
35pub struct InlineConfig {
36 disabled_at_line: HashMap<usize, HashSet<String>>,
38 enabled_at_line: HashMap<usize, HashSet<String>>,
41 line_disabled_rules: HashMap<usize, HashSet<String>>,
43 file_disabled_rules: HashSet<String>,
45 file_enabled_rules: HashSet<String>,
47 file_rule_config: HashMap<String, JsonValue>,
50}
51
52impl Default for InlineConfig {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl InlineConfig {
59 pub fn new() -> Self {
60 Self {
61 disabled_at_line: HashMap::new(),
62 enabled_at_line: HashMap::new(),
63 line_disabled_rules: HashMap::new(),
64 file_disabled_rules: HashSet::new(),
65 file_enabled_rules: HashSet::new(),
66 file_rule_config: HashMap::new(),
67 }
68 }
69
70 pub fn from_content(content: &str) -> Self {
72 let mut config = Self::new();
73 let lines: Vec<&str> = content.lines().collect();
74
75 let code_blocks = CodeBlockUtils::detect_code_blocks(content);
77
78 let mut line_positions = Vec::with_capacity(lines.len());
80 let mut pos = 0;
81 for line in &lines {
82 line_positions.push(pos);
83 pos += line.len() + 1; }
85
86 let mut currently_disabled = HashSet::new();
88 let mut currently_enabled = HashSet::new(); let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
90
91 for (idx, line) in lines.iter().enumerate() {
92 let line_num = idx + 1; config.disabled_at_line.insert(line_num, currently_disabled.clone());
97 config.enabled_at_line.insert(line_num, currently_enabled.clone());
98
99 let line_start = line_positions[idx];
101 let line_end = line_start + line.len();
102 let in_code_block = code_blocks
103 .iter()
104 .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
105
106 if in_code_block {
107 continue;
108 }
109
110 if let Some(rules) = parse_disable_file_comment(line) {
113 if rules.is_empty() {
114 config.file_disabled_rules.clear();
116 config.file_disabled_rules.insert("*".to_string());
117 } else {
118 if config.file_disabled_rules.contains("*") {
120 for rule in rules {
122 config.file_enabled_rules.remove(&normalize_rule_name(rule));
123 }
124 } else {
125 for rule in rules {
127 config.file_disabled_rules.insert(normalize_rule_name(rule));
128 }
129 }
130 }
131 }
132
133 if let Some(rules) = parse_enable_file_comment(line) {
135 if rules.is_empty() {
136 config.file_disabled_rules.clear();
138 config.file_enabled_rules.clear();
139 } else {
140 if config.file_disabled_rules.contains("*") {
142 for rule in rules {
144 config.file_enabled_rules.insert(normalize_rule_name(rule));
145 }
146 } else {
147 for rule in rules {
149 config.file_disabled_rules.remove(&normalize_rule_name(rule));
150 }
151 }
152 }
153 }
154
155 if let Some(json_config) = parse_configure_file_comment(line) {
157 if let Some(obj) = json_config.as_object() {
159 for (rule_name, rule_config) in obj {
160 config.file_rule_config.insert(rule_name.clone(), rule_config.clone());
161 }
162 }
163 }
164
165 if let Some(rules) = parse_disable_next_line_comment(line) {
170 let next_line = line_num + 1;
171 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
172 if rules.is_empty() {
173 line_rules.insert("*".to_string());
175 } else {
176 for rule in rules {
177 line_rules.insert(normalize_rule_name(rule));
178 }
179 }
180 }
181
182 if line.contains("<!-- prettier-ignore -->") {
184 let next_line = line_num + 1;
185 let line_rules = config.line_disabled_rules.entry(next_line).or_default();
186 line_rules.insert("*".to_string());
187 }
188
189 if let Some(rules) = parse_disable_line_comment(line) {
191 let line_rules = config.line_disabled_rules.entry(line_num).or_default();
192 if rules.is_empty() {
193 line_rules.insert("*".to_string());
195 } else {
196 for rule in rules {
197 line_rules.insert(normalize_rule_name(rule));
198 }
199 }
200 }
201
202 let mut processed_capture = false;
205 let mut processed_restore = false;
206
207 let mut comment_positions = Vec::new();
209
210 if let Some(pos) = line.find("<!-- markdownlint-disable")
211 && !line[pos..].contains("<!-- markdownlint-disable-line")
212 && !line[pos..].contains("<!-- markdownlint-disable-next-line")
213 {
214 comment_positions.push((pos, "disable"));
215 }
216 if let Some(pos) = line.find("<!-- rumdl-disable")
217 && !line[pos..].contains("<!-- rumdl-disable-line")
218 && !line[pos..].contains("<!-- rumdl-disable-next-line")
219 {
220 comment_positions.push((pos, "disable"));
221 }
222
223 if let Some(pos) = line.find("<!-- markdownlint-enable") {
224 comment_positions.push((pos, "enable"));
225 }
226 if let Some(pos) = line.find("<!-- rumdl-enable") {
227 comment_positions.push((pos, "enable"));
228 }
229
230 if let Some(pos) = line.find("<!-- markdownlint-capture") {
231 comment_positions.push((pos, "capture"));
232 }
233 if let Some(pos) = line.find("<!-- rumdl-capture") {
234 comment_positions.push((pos, "capture"));
235 }
236
237 if let Some(pos) = line.find("<!-- markdownlint-restore") {
238 comment_positions.push((pos, "restore"));
239 }
240 if let Some(pos) = line.find("<!-- rumdl-restore") {
241 comment_positions.push((pos, "restore"));
242 }
243
244 comment_positions.sort_by_key(|&(pos, _)| pos);
246
247 for (_, comment_type) in comment_positions {
249 match comment_type {
250 "disable" => {
251 if let Some(rules) = parse_disable_comment(line) {
252 if rules.is_empty() {
253 currently_disabled.clear();
255 currently_disabled.insert("*".to_string());
256 currently_enabled.clear(); } else {
258 if currently_disabled.contains("*") {
260 for rule in rules {
262 currently_enabled.remove(&normalize_rule_name(rule));
263 }
264 } else {
265 for rule in rules {
267 currently_disabled.insert(normalize_rule_name(rule));
268 }
269 }
270 }
271 }
272 }
273 "enable" => {
274 if let Some(rules) = parse_enable_comment(line) {
275 if rules.is_empty() {
276 currently_disabled.clear();
278 currently_enabled.clear();
279 } else {
280 if currently_disabled.contains("*") {
282 for rule in rules {
284 currently_enabled.insert(normalize_rule_name(rule));
285 }
286 } else {
287 for rule in rules {
289 currently_disabled.remove(&normalize_rule_name(rule));
290 }
291 }
292 }
293 }
294 }
295 "capture" => {
296 if !processed_capture && is_capture_comment(line) {
297 capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
298 processed_capture = true;
299 }
300 }
301 "restore" => {
302 if !processed_restore && is_restore_comment(line) {
303 if let Some((disabled, enabled)) = capture_stack.pop() {
304 currently_disabled = disabled;
305 currently_enabled = enabled;
306 }
307 processed_restore = true;
308 }
309 }
310 _ => {}
311 }
312 }
313 }
314
315 config
316 }
317
318 pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
320 if self.file_disabled_rules.contains("*") {
322 return !self.file_enabled_rules.contains(rule_name);
324 } else if self.file_disabled_rules.contains(rule_name) {
325 return true;
326 }
327
328 if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
330 && (line_rules.contains("*") || line_rules.contains(rule_name))
331 {
332 return true;
333 }
334
335 if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
337 if disabled_set.contains("*") {
338 if let Some(enabled_set) = self.enabled_at_line.get(&line_number) {
340 return !enabled_set.contains(rule_name);
341 }
342 return true; } else {
344 return disabled_set.contains(rule_name);
345 }
346 }
347
348 false
349 }
350
351 pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
353 let mut disabled = HashSet::new();
354
355 if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
357 if disabled_set.contains("*") {
358 disabled.insert("*".to_string());
360 } else {
363 for rule in disabled_set {
364 disabled.insert(rule.clone());
365 }
366 }
367 }
368
369 if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
371 for rule in line_rules {
372 disabled.insert(rule.clone());
373 }
374 }
375
376 disabled
377 }
378
379 pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
381 self.file_rule_config.get(rule_name)
382 }
383
384 pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
386 &self.file_rule_config
387 }
388
389 pub fn export_for_file_index(&self) -> (HashSet<String>, HashMap<usize, HashSet<String>>) {
394 let file_disabled = self.file_disabled_rules.clone();
395
396 let mut line_disabled: HashMap<usize, HashSet<String>> = HashMap::new();
398
399 for (line, rules) in &self.disabled_at_line {
400 line_disabled.entry(*line).or_default().extend(rules.clone());
401 }
402 for (line, rules) in &self.line_disabled_rules {
403 line_disabled.entry(*line).or_default().extend(rules.clone());
404 }
405
406 (file_disabled, line_disabled)
407 }
408}
409
410pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
412 for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
414 if let Some(start) = line.find(prefix) {
415 let after_prefix = &line[start + prefix.len()..];
416
417 if after_prefix.trim_start().starts_with("-->") {
419 return Some(Vec::new()); }
421
422 if let Some(end) = after_prefix.find("-->") {
424 let rules_str = after_prefix[..end].trim();
425 if !rules_str.is_empty() {
426 let rules: Vec<&str> = rules_str.split_whitespace().collect();
427 return Some(rules);
428 }
429 }
430 }
431 }
432
433 None
434}
435
436pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
438 for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
440 if let Some(start) = line.find(prefix) {
441 let after_prefix = &line[start + prefix.len()..];
442
443 if after_prefix.trim_start().starts_with("-->") {
445 return Some(Vec::new()); }
447
448 if let Some(end) = after_prefix.find("-->") {
450 let rules_str = after_prefix[..end].trim();
451 if !rules_str.is_empty() {
452 let rules: Vec<&str> = rules_str.split_whitespace().collect();
453 return Some(rules);
454 }
455 }
456 }
457 }
458
459 None
460}
461
462pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
464 for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
466 if let Some(start) = line.find(prefix) {
467 let after_prefix = &line[start + prefix.len()..];
468
469 if after_prefix.trim_start().starts_with("-->") {
471 return Some(Vec::new()); }
473
474 if let Some(end) = after_prefix.find("-->") {
476 let rules_str = after_prefix[..end].trim();
477 if !rules_str.is_empty() {
478 let rules: Vec<&str> = rules_str.split_whitespace().collect();
479 return Some(rules);
480 }
481 }
482 }
483 }
484
485 None
486}
487
488pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
490 for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
492 if let Some(start) = line.find(prefix) {
493 let after_prefix = &line[start + prefix.len()..];
494
495 if after_prefix.trim_start().starts_with("-->") {
497 return Some(Vec::new()); }
499
500 if let Some(end) = after_prefix.find("-->") {
502 let rules_str = after_prefix[..end].trim();
503 if !rules_str.is_empty() {
504 let rules: Vec<&str> = rules_str.split_whitespace().collect();
505 return Some(rules);
506 }
507 }
508 }
509 }
510
511 None
512}
513
514pub fn is_capture_comment(line: &str) -> bool {
516 line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
517}
518
519pub fn is_restore_comment(line: &str) -> bool {
521 line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
522}
523
524pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
526 for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
528 if let Some(start) = line.find(prefix) {
529 let after_prefix = &line[start + prefix.len()..];
530
531 if after_prefix.trim_start().starts_with("-->") {
533 return Some(Vec::new()); }
535
536 if let Some(end) = after_prefix.find("-->") {
538 let rules_str = after_prefix[..end].trim();
539 if !rules_str.is_empty() {
540 let rules: Vec<&str> = rules_str.split_whitespace().collect();
541 return Some(rules);
542 }
543 }
544 }
545 }
546
547 None
548}
549
550pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
552 for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
554 if let Some(start) = line.find(prefix) {
555 let after_prefix = &line[start + prefix.len()..];
556
557 if after_prefix.trim_start().starts_with("-->") {
559 return Some(Vec::new()); }
561
562 if let Some(end) = after_prefix.find("-->") {
564 let rules_str = after_prefix[..end].trim();
565 if !rules_str.is_empty() {
566 let rules: Vec<&str> = rules_str.split_whitespace().collect();
567 return Some(rules);
568 }
569 }
570 }
571 }
572
573 None
574}
575
576pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
578 for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
580 if let Some(start) = line.find(prefix) {
581 let after_prefix = &line[start + prefix.len()..];
582
583 if let Some(end) = after_prefix.find("-->") {
585 let json_str = after_prefix[..end].trim();
586 if !json_str.is_empty() {
587 if let Ok(value) = serde_json::from_str(json_str) {
589 return Some(value);
590 }
591 }
592 }
593 }
594 }
595
596 None
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[test]
604 fn test_parse_disable_comment() {
605 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
607 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
608
609 assert_eq!(
611 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
612 Some(vec!["MD001", "MD002"])
613 );
614
615 assert_eq!(parse_disable_comment("Some regular text"), None);
617 }
618
619 #[test]
620 fn test_parse_disable_line_comment() {
621 assert_eq!(
623 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
624 Some(vec![])
625 );
626
627 assert_eq!(
629 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
630 Some(vec!["MD013"])
631 );
632
633 assert_eq!(parse_disable_line_comment("Some regular text"), None);
635 }
636
637 #[test]
638 fn test_inline_config_from_content() {
639 let content = r#"# Test Document
640
641<!-- markdownlint-disable MD013 -->
642This is a very long line that would normally trigger MD013 but it's disabled
643
644<!-- markdownlint-enable MD013 -->
645This line will be checked again
646
647<!-- markdownlint-disable-next-line MD001 -->
648# This heading will not be checked for MD001
649## But this one will
650
651Some text <!-- markdownlint-disable-line MD013 -->
652
653<!-- markdownlint-capture -->
654<!-- markdownlint-disable MD001 MD002 -->
655# Heading with MD001 disabled
656<!-- markdownlint-restore -->
657# Heading with MD001 enabled again
658"#;
659
660 let config = InlineConfig::from_content(content);
661
662 assert!(config.is_rule_disabled("MD013", 4));
664
665 assert!(!config.is_rule_disabled("MD013", 7));
667
668 assert!(config.is_rule_disabled("MD001", 10));
670
671 assert!(!config.is_rule_disabled("MD001", 11));
673
674 assert!(config.is_rule_disabled("MD013", 13));
676
677 assert!(!config.is_rule_disabled("MD001", 19));
679 }
680
681 #[test]
682 fn test_capture_restore() {
683 let content = r#"<!-- markdownlint-disable MD001 -->
684<!-- markdownlint-capture -->
685<!-- markdownlint-disable MD002 MD003 -->
686<!-- markdownlint-restore -->
687Some content after restore
688"#;
689
690 let config = InlineConfig::from_content(content);
691
692 assert!(config.is_rule_disabled("MD001", 5));
694 assert!(!config.is_rule_disabled("MD002", 5));
695 assert!(!config.is_rule_disabled("MD003", 5));
696 }
697}