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
390pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
392 for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
394 if let Some(start) = line.find(prefix) {
395 let after_prefix = &line[start + prefix.len()..];
396
397 if after_prefix.trim_start().starts_with("-->") {
399 return Some(Vec::new()); }
401
402 if let Some(end) = after_prefix.find("-->") {
404 let rules_str = after_prefix[..end].trim();
405 if !rules_str.is_empty() {
406 let rules: Vec<&str> = rules_str.split_whitespace().collect();
407 return Some(rules);
408 }
409 }
410 }
411 }
412
413 None
414}
415
416pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
418 for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
420 if let Some(start) = line.find(prefix) {
421 let after_prefix = &line[start + prefix.len()..];
422
423 if after_prefix.trim_start().starts_with("-->") {
425 return Some(Vec::new()); }
427
428 if let Some(end) = after_prefix.find("-->") {
430 let rules_str = after_prefix[..end].trim();
431 if !rules_str.is_empty() {
432 let rules: Vec<&str> = rules_str.split_whitespace().collect();
433 return Some(rules);
434 }
435 }
436 }
437 }
438
439 None
440}
441
442pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
444 for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
446 if let Some(start) = line.find(prefix) {
447 let after_prefix = &line[start + prefix.len()..];
448
449 if after_prefix.trim_start().starts_with("-->") {
451 return Some(Vec::new()); }
453
454 if let Some(end) = after_prefix.find("-->") {
456 let rules_str = after_prefix[..end].trim();
457 if !rules_str.is_empty() {
458 let rules: Vec<&str> = rules_str.split_whitespace().collect();
459 return Some(rules);
460 }
461 }
462 }
463 }
464
465 None
466}
467
468pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
470 for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
472 if let Some(start) = line.find(prefix) {
473 let after_prefix = &line[start + prefix.len()..];
474
475 if after_prefix.trim_start().starts_with("-->") {
477 return Some(Vec::new()); }
479
480 if let Some(end) = after_prefix.find("-->") {
482 let rules_str = after_prefix[..end].trim();
483 if !rules_str.is_empty() {
484 let rules: Vec<&str> = rules_str.split_whitespace().collect();
485 return Some(rules);
486 }
487 }
488 }
489 }
490
491 None
492}
493
494pub fn is_capture_comment(line: &str) -> bool {
496 line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
497}
498
499pub fn is_restore_comment(line: &str) -> bool {
501 line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
502}
503
504pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
506 for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
508 if let Some(start) = line.find(prefix) {
509 let after_prefix = &line[start + prefix.len()..];
510
511 if after_prefix.trim_start().starts_with("-->") {
513 return Some(Vec::new()); }
515
516 if let Some(end) = after_prefix.find("-->") {
518 let rules_str = after_prefix[..end].trim();
519 if !rules_str.is_empty() {
520 let rules: Vec<&str> = rules_str.split_whitespace().collect();
521 return Some(rules);
522 }
523 }
524 }
525 }
526
527 None
528}
529
530pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
532 for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
534 if let Some(start) = line.find(prefix) {
535 let after_prefix = &line[start + prefix.len()..];
536
537 if after_prefix.trim_start().starts_with("-->") {
539 return Some(Vec::new()); }
541
542 if let Some(end) = after_prefix.find("-->") {
544 let rules_str = after_prefix[..end].trim();
545 if !rules_str.is_empty() {
546 let rules: Vec<&str> = rules_str.split_whitespace().collect();
547 return Some(rules);
548 }
549 }
550 }
551 }
552
553 None
554}
555
556pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
558 for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
560 if let Some(start) = line.find(prefix) {
561 let after_prefix = &line[start + prefix.len()..];
562
563 if let Some(end) = after_prefix.find("-->") {
565 let json_str = after_prefix[..end].trim();
566 if !json_str.is_empty() {
567 if let Ok(value) = serde_json::from_str(json_str) {
569 return Some(value);
570 }
571 }
572 }
573 }
574 }
575
576 None
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_parse_disable_comment() {
585 assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
587 assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
588
589 assert_eq!(
591 parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
592 Some(vec!["MD001", "MD002"])
593 );
594
595 assert_eq!(parse_disable_comment("Some regular text"), None);
597 }
598
599 #[test]
600 fn test_parse_disable_line_comment() {
601 assert_eq!(
603 parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
604 Some(vec![])
605 );
606
607 assert_eq!(
609 parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
610 Some(vec!["MD013"])
611 );
612
613 assert_eq!(parse_disable_line_comment("Some regular text"), None);
615 }
616
617 #[test]
618 fn test_inline_config_from_content() {
619 let content = r#"# Test Document
620
621<!-- markdownlint-disable MD013 -->
622This is a very long line that would normally trigger MD013 but it's disabled
623
624<!-- markdownlint-enable MD013 -->
625This line will be checked again
626
627<!-- markdownlint-disable-next-line MD001 -->
628# This heading will not be checked for MD001
629## But this one will
630
631Some text <!-- markdownlint-disable-line MD013 -->
632
633<!-- markdownlint-capture -->
634<!-- markdownlint-disable MD001 MD002 -->
635# Heading with MD001 disabled
636<!-- markdownlint-restore -->
637# Heading with MD001 enabled again
638"#;
639
640 let config = InlineConfig::from_content(content);
641
642 assert!(config.is_rule_disabled("MD013", 4));
644
645 assert!(!config.is_rule_disabled("MD013", 7));
647
648 assert!(config.is_rule_disabled("MD001", 10));
650
651 assert!(!config.is_rule_disabled("MD001", 11));
653
654 assert!(config.is_rule_disabled("MD013", 13));
656
657 assert!(!config.is_rule_disabled("MD001", 19));
659 }
660
661 #[test]
662 fn test_capture_restore() {
663 let content = r#"<!-- markdownlint-disable MD001 -->
664<!-- markdownlint-capture -->
665<!-- markdownlint-disable MD002 MD003 -->
666<!-- markdownlint-restore -->
667Some content after restore
668"#;
669
670 let config = InlineConfig::from_content(content);
671
672 assert!(config.is_rule_disabled("MD001", 5));
674 assert!(!config.is_rule_disabled("MD002", 5));
675 assert!(!config.is_rule_disabled("MD003", 5));
676 }
677}