rumdl_lib/
inline_config.rs

1//! Inline configuration comment handling for markdownlint compatibility
2//!
3//! Supports:
4//! - `<!-- markdownlint-disable -->` - Disable all rules from this point
5//! - `<!-- markdownlint-enable -->` - Re-enable all rules from this point
6//! - `<!-- markdownlint-disable MD001 MD002 -->` - Disable specific rules
7//! - `<!-- markdownlint-enable MD001 MD002 -->` - Re-enable specific rules
8//! - `<!-- markdownlint-disable-line MD001 -->` - Disable rules for current line
9//! - `<!-- markdownlint-disable-next-line MD001 -->` - Disable rules for next line
10//! - `<!-- markdownlint-capture -->` - Capture current configuration state
11//! - `<!-- markdownlint-restore -->` - Restore captured configuration state
12//! - `<!-- markdownlint-disable-file -->` - Disable all rules for entire file
13//! - `<!-- markdownlint-enable-file -->` - Re-enable all rules for entire file
14//! - `<!-- markdownlint-disable-file MD001 MD002 -->` - Disable specific rules for entire file
15//! - `<!-- markdownlint-enable-file MD001 MD002 -->` - Re-enable specific rules for entire file
16//! - `<!-- markdownlint-configure-file { "MD013": { "line_length": 120 } } -->` - Configure rules for entire file
17//! - `<!-- prettier-ignore -->` - Disable all rules for next line (compatibility with prettier)
18//!
19//! Also supports rumdl-specific syntax with same semantics.
20
21use crate::utils::code_block_utils::CodeBlockUtils;
22use serde_json::Value as JsonValue;
23use std::collections::{HashMap, HashSet};
24
25#[derive(Debug, Clone)]
26pub struct InlineConfig {
27    /// Rules that are disabled at each line (1-indexed line -> set of disabled rules)
28    disabled_at_line: HashMap<usize, HashSet<String>>,
29    /// Rules that are explicitly enabled when all rules are disabled (1-indexed line -> set of enabled rules)
30    /// Only used when "*" is in disabled_at_line
31    enabled_at_line: HashMap<usize, HashSet<String>>,
32    /// Rules disabled for specific lines via disable-line (1-indexed)
33    line_disabled_rules: HashMap<usize, HashSet<String>>,
34    /// Rules disabled for the entire file
35    file_disabled_rules: HashSet<String>,
36    /// Rules explicitly enabled for the entire file (used when all rules are disabled)
37    file_enabled_rules: HashSet<String>,
38    /// Configuration overrides for specific rules from configure-file comments
39    /// Maps rule name to configuration JSON value
40    file_rule_config: HashMap<String, JsonValue>,
41}
42
43impl Default for InlineConfig {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl InlineConfig {
50    pub fn new() -> Self {
51        Self {
52            disabled_at_line: HashMap::new(),
53            enabled_at_line: HashMap::new(),
54            line_disabled_rules: HashMap::new(),
55            file_disabled_rules: HashSet::new(),
56            file_enabled_rules: HashSet::new(),
57            file_rule_config: HashMap::new(),
58        }
59    }
60
61    /// Process all inline comments in the content and return the configuration state
62    pub fn from_content(content: &str) -> Self {
63        let mut config = Self::new();
64        let lines: Vec<&str> = content.lines().collect();
65
66        // Detect code blocks to skip comments within them
67        let code_blocks = CodeBlockUtils::detect_code_blocks(content);
68
69        // Pre-compute line positions for checking if a line is in a code block
70        let mut line_positions = Vec::with_capacity(lines.len());
71        let mut pos = 0;
72        for line in &lines {
73            line_positions.push(pos);
74            pos += line.len() + 1; // +1 for newline
75        }
76
77        // Track current state of disabled rules
78        let mut currently_disabled = HashSet::new();
79        let mut currently_enabled = HashSet::new(); // For when all rules are disabled
80        let mut capture_stack: Vec<(HashSet<String>, HashSet<String>)> = Vec::new();
81
82        for (idx, line) in lines.iter().enumerate() {
83            let line_num = idx + 1; // 1-indexed
84
85            // Store the current state for this line BEFORE processing comments
86            // This way, comments on a line don't affect that same line
87            config.disabled_at_line.insert(line_num, currently_disabled.clone());
88            config.enabled_at_line.insert(line_num, currently_enabled.clone());
89
90            // Skip processing if this line is inside a code block
91            let line_start = line_positions[idx];
92            let line_end = line_start + line.len();
93            let in_code_block = code_blocks
94                .iter()
95                .any(|&(block_start, block_end)| line_start >= block_start && line_end <= block_end);
96
97            if in_code_block {
98                continue;
99            }
100
101            // Process file-wide comments first as they affect the entire file
102            // Check for disable-file
103            if let Some(rules) = parse_disable_file_comment(line) {
104                if rules.is_empty() {
105                    // Disable all rules for entire file
106                    config.file_disabled_rules.clear();
107                    config.file_disabled_rules.insert("*".to_string());
108                } else {
109                    // Disable specific rules for entire file
110                    if config.file_disabled_rules.contains("*") {
111                        // All rules are disabled, so remove from enabled list
112                        for rule in rules {
113                            config.file_enabled_rules.remove(rule);
114                        }
115                    } else {
116                        // Normal case: add to disabled list
117                        for rule in rules {
118                            config.file_disabled_rules.insert(rule.to_string());
119                        }
120                    }
121                }
122            }
123
124            // Check for enable-file
125            if let Some(rules) = parse_enable_file_comment(line) {
126                if rules.is_empty() {
127                    // Enable all rules for entire file
128                    config.file_disabled_rules.clear();
129                    config.file_enabled_rules.clear();
130                } else {
131                    // Enable specific rules for entire file
132                    if config.file_disabled_rules.contains("*") {
133                        // All rules are disabled, so add to enabled list
134                        for rule in rules {
135                            config.file_enabled_rules.insert(rule.to_string());
136                        }
137                    } else {
138                        // Normal case: remove from disabled list
139                        for rule in rules {
140                            config.file_disabled_rules.remove(rule);
141                        }
142                    }
143                }
144            }
145
146            // Check for configure-file
147            if let Some(json_config) = parse_configure_file_comment(line) {
148                // Process the JSON configuration
149                if let Some(obj) = json_config.as_object() {
150                    for (rule_name, rule_config) in obj {
151                        config.file_rule_config.insert(rule_name.clone(), rule_config.clone());
152                    }
153                }
154            }
155
156            // Process comments - handle multiple comment types on same line
157            // Process line-specific comments first (they don't affect state)
158
159            // Check for disable-next-line
160            if let Some(rules) = parse_disable_next_line_comment(line) {
161                let next_line = line_num + 1;
162                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
163                if rules.is_empty() {
164                    // Disable all rules for next line
165                    line_rules.insert("*".to_string());
166                } else {
167                    for rule in rules {
168                        line_rules.insert(rule.to_string());
169                    }
170                }
171            }
172
173            // Check for prettier-ignore (disables all rules for next line)
174            if line.contains("<!-- prettier-ignore -->") {
175                let next_line = line_num + 1;
176                let line_rules = config.line_disabled_rules.entry(next_line).or_default();
177                line_rules.insert("*".to_string());
178            }
179
180            // Check for disable-line
181            if let Some(rules) = parse_disable_line_comment(line) {
182                let line_rules = config.line_disabled_rules.entry(line_num).or_default();
183                if rules.is_empty() {
184                    // Disable all rules for current line
185                    line_rules.insert("*".to_string());
186                } else {
187                    for rule in rules {
188                        line_rules.insert(rule.to_string());
189                    }
190                }
191            }
192
193            // Process state-changing comments in the order they appear
194            // This handles multiple comments on the same line correctly
195            let mut processed_capture = false;
196            let mut processed_restore = false;
197
198            // Find all comments on this line and process them in order
199            let mut comment_positions = Vec::new();
200
201            if let Some(pos) = line.find("<!-- markdownlint-disable")
202                && !line[pos..].contains("<!-- markdownlint-disable-line")
203                && !line[pos..].contains("<!-- markdownlint-disable-next-line")
204            {
205                comment_positions.push((pos, "disable"));
206            }
207            if let Some(pos) = line.find("<!-- rumdl-disable")
208                && !line[pos..].contains("<!-- rumdl-disable-line")
209                && !line[pos..].contains("<!-- rumdl-disable-next-line")
210            {
211                comment_positions.push((pos, "disable"));
212            }
213
214            if let Some(pos) = line.find("<!-- markdownlint-enable") {
215                comment_positions.push((pos, "enable"));
216            }
217            if let Some(pos) = line.find("<!-- rumdl-enable") {
218                comment_positions.push((pos, "enable"));
219            }
220
221            if let Some(pos) = line.find("<!-- markdownlint-capture") {
222                comment_positions.push((pos, "capture"));
223            }
224            if let Some(pos) = line.find("<!-- rumdl-capture") {
225                comment_positions.push((pos, "capture"));
226            }
227
228            if let Some(pos) = line.find("<!-- markdownlint-restore") {
229                comment_positions.push((pos, "restore"));
230            }
231            if let Some(pos) = line.find("<!-- rumdl-restore") {
232                comment_positions.push((pos, "restore"));
233            }
234
235            // Sort by position to process in order
236            comment_positions.sort_by_key(|&(pos, _)| pos);
237
238            // Process each comment in order
239            for (_, comment_type) in comment_positions {
240                match comment_type {
241                    "disable" => {
242                        if let Some(rules) = parse_disable_comment(line) {
243                            if rules.is_empty() {
244                                // Disable all rules
245                                currently_disabled.clear();
246                                currently_disabled.insert("*".to_string());
247                                currently_enabled.clear(); // Reset enabled list
248                            } else {
249                                // Disable specific rules
250                                if currently_disabled.contains("*") {
251                                    // All rules are disabled, so remove from enabled list
252                                    for rule in rules {
253                                        currently_enabled.remove(rule);
254                                    }
255                                } else {
256                                    // Normal case: add to disabled list
257                                    for rule in rules {
258                                        currently_disabled.insert(rule.to_string());
259                                    }
260                                }
261                            }
262                        }
263                    }
264                    "enable" => {
265                        if let Some(rules) = parse_enable_comment(line) {
266                            if rules.is_empty() {
267                                // Enable all rules
268                                currently_disabled.clear();
269                                currently_enabled.clear();
270                            } else {
271                                // Enable specific rules
272                                if currently_disabled.contains("*") {
273                                    // All rules are disabled, so add to enabled list
274                                    for rule in rules {
275                                        currently_enabled.insert(rule.to_string());
276                                    }
277                                } else {
278                                    // Normal case: remove from disabled list
279                                    for rule in rules {
280                                        currently_disabled.remove(rule);
281                                    }
282                                }
283                            }
284                        }
285                    }
286                    "capture" => {
287                        if !processed_capture && is_capture_comment(line) {
288                            capture_stack.push((currently_disabled.clone(), currently_enabled.clone()));
289                            processed_capture = true;
290                        }
291                    }
292                    "restore" => {
293                        if !processed_restore && is_restore_comment(line) {
294                            if let Some((disabled, enabled)) = capture_stack.pop() {
295                                currently_disabled = disabled;
296                                currently_enabled = enabled;
297                            }
298                            processed_restore = true;
299                        }
300                    }
301                    _ => {}
302                }
303            }
304        }
305
306        config
307    }
308
309    /// Check if a rule is disabled at a specific line
310    pub fn is_rule_disabled(&self, rule_name: &str, line_number: usize) -> bool {
311        // Check file-wide disables first (highest priority)
312        if self.file_disabled_rules.contains("*") {
313            // All rules are disabled for the file, check if this rule is explicitly enabled
314            return !self.file_enabled_rules.contains(rule_name);
315        } else if self.file_disabled_rules.contains(rule_name) {
316            return true;
317        }
318
319        // Check line-specific disables (disable-line, disable-next-line)
320        if let Some(line_rules) = self.line_disabled_rules.get(&line_number)
321            && (line_rules.contains("*") || line_rules.contains(rule_name))
322        {
323            return true;
324        }
325
326        // Check persistent disables at this line
327        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
328            if disabled_set.contains("*") {
329                // All rules are disabled, check if this rule is explicitly enabled
330                if let Some(enabled_set) = self.enabled_at_line.get(&line_number) {
331                    return !enabled_set.contains(rule_name);
332                }
333                return true; // All disabled and not explicitly enabled
334            } else {
335                return disabled_set.contains(rule_name);
336            }
337        }
338
339        false
340    }
341
342    /// Get all disabled rules at a specific line
343    pub fn get_disabled_rules(&self, line_number: usize) -> HashSet<String> {
344        let mut disabled = HashSet::new();
345
346        // Add persistent disables
347        if let Some(disabled_set) = self.disabled_at_line.get(&line_number) {
348            if disabled_set.contains("*") {
349                // All rules are disabled except those explicitly enabled
350                disabled.insert("*".to_string());
351                // We could subtract enabled rules here, but that would require knowing all rules
352                // For now, we'll just return "*" to indicate all rules are disabled
353            } else {
354                for rule in disabled_set {
355                    disabled.insert(rule.clone());
356                }
357            }
358        }
359
360        // Add line-specific disables
361        if let Some(line_rules) = self.line_disabled_rules.get(&line_number) {
362            for rule in line_rules {
363                disabled.insert(rule.clone());
364            }
365        }
366
367        disabled
368    }
369
370    /// Get configuration overrides for a specific rule from configure-file comments
371    pub fn get_rule_config(&self, rule_name: &str) -> Option<&JsonValue> {
372        self.file_rule_config.get(rule_name)
373    }
374
375    /// Get all configuration overrides from configure-file comments
376    pub fn get_all_rule_configs(&self) -> &HashMap<String, JsonValue> {
377        &self.file_rule_config
378    }
379}
380
381/// Parse a disable comment and return the list of rules (empty vec means all rules)
382pub fn parse_disable_comment(line: &str) -> Option<Vec<&str>> {
383    // Check for both rumdl-disable and markdownlint-disable
384    for prefix in &["<!-- rumdl-disable", "<!-- markdownlint-disable"] {
385        if let Some(start) = line.find(prefix) {
386            let after_prefix = &line[start + prefix.len()..];
387
388            // Global disable: <!-- markdownlint-disable -->
389            if after_prefix.trim_start().starts_with("-->") {
390                return Some(Vec::new()); // Empty vec means all rules
391            }
392
393            // Rule-specific disable: <!-- markdownlint-disable MD001 MD002 -->
394            if let Some(end) = after_prefix.find("-->") {
395                let rules_str = after_prefix[..end].trim();
396                if !rules_str.is_empty() {
397                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
398                    return Some(rules);
399                }
400            }
401        }
402    }
403
404    None
405}
406
407/// Parse an enable comment and return the list of rules (empty vec means all rules)
408pub fn parse_enable_comment(line: &str) -> Option<Vec<&str>> {
409    // Check for both rumdl-enable and markdownlint-enable
410    for prefix in &["<!-- rumdl-enable", "<!-- markdownlint-enable"] {
411        if let Some(start) = line.find(prefix) {
412            let after_prefix = &line[start + prefix.len()..];
413
414            // Global enable: <!-- markdownlint-enable -->
415            if after_prefix.trim_start().starts_with("-->") {
416                return Some(Vec::new()); // Empty vec means all rules
417            }
418
419            // Rule-specific enable: <!-- markdownlint-enable MD001 MD002 -->
420            if let Some(end) = after_prefix.find("-->") {
421                let rules_str = after_prefix[..end].trim();
422                if !rules_str.is_empty() {
423                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
424                    return Some(rules);
425                }
426            }
427        }
428    }
429
430    None
431}
432
433/// Parse a disable-line comment
434pub fn parse_disable_line_comment(line: &str) -> Option<Vec<&str>> {
435    // Check for both rumdl and markdownlint variants
436    for prefix in &["<!-- rumdl-disable-line", "<!-- markdownlint-disable-line"] {
437        if let Some(start) = line.find(prefix) {
438            let after_prefix = &line[start + prefix.len()..];
439
440            // Global disable-line: <!-- markdownlint-disable-line -->
441            if after_prefix.trim_start().starts_with("-->") {
442                return Some(Vec::new()); // Empty vec means all rules
443            }
444
445            // Rule-specific disable-line: <!-- markdownlint-disable-line MD001 MD002 -->
446            if let Some(end) = after_prefix.find("-->") {
447                let rules_str = after_prefix[..end].trim();
448                if !rules_str.is_empty() {
449                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
450                    return Some(rules);
451                }
452            }
453        }
454    }
455
456    None
457}
458
459/// Parse a disable-next-line comment
460pub fn parse_disable_next_line_comment(line: &str) -> Option<Vec<&str>> {
461    // Check for both rumdl and markdownlint variants
462    for prefix in &["<!-- rumdl-disable-next-line", "<!-- markdownlint-disable-next-line"] {
463        if let Some(start) = line.find(prefix) {
464            let after_prefix = &line[start + prefix.len()..];
465
466            // Global disable-next-line: <!-- markdownlint-disable-next-line -->
467            if after_prefix.trim_start().starts_with("-->") {
468                return Some(Vec::new()); // Empty vec means all rules
469            }
470
471            // Rule-specific disable-next-line: <!-- markdownlint-disable-next-line MD001 MD002 -->
472            if let Some(end) = after_prefix.find("-->") {
473                let rules_str = after_prefix[..end].trim();
474                if !rules_str.is_empty() {
475                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
476                    return Some(rules);
477                }
478            }
479        }
480    }
481
482    None
483}
484
485/// Check if line contains a capture comment
486pub fn is_capture_comment(line: &str) -> bool {
487    line.contains("<!-- markdownlint-capture -->") || line.contains("<!-- rumdl-capture -->")
488}
489
490/// Check if line contains a restore comment
491pub fn is_restore_comment(line: &str) -> bool {
492    line.contains("<!-- markdownlint-restore -->") || line.contains("<!-- rumdl-restore -->")
493}
494
495/// Parse a disable-file comment and return the list of rules (empty vec means all rules)
496pub fn parse_disable_file_comment(line: &str) -> Option<Vec<&str>> {
497    // Check for both rumdl and markdownlint variants
498    for prefix in &["<!-- rumdl-disable-file", "<!-- markdownlint-disable-file"] {
499        if let Some(start) = line.find(prefix) {
500            let after_prefix = &line[start + prefix.len()..];
501
502            // Global disable-file: <!-- markdownlint-disable-file -->
503            if after_prefix.trim_start().starts_with("-->") {
504                return Some(Vec::new()); // Empty vec means all rules
505            }
506
507            // Rule-specific disable-file: <!-- markdownlint-disable-file MD001 MD002 -->
508            if let Some(end) = after_prefix.find("-->") {
509                let rules_str = after_prefix[..end].trim();
510                if !rules_str.is_empty() {
511                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
512                    return Some(rules);
513                }
514            }
515        }
516    }
517
518    None
519}
520
521/// Parse an enable-file comment and return the list of rules (empty vec means all rules)
522pub fn parse_enable_file_comment(line: &str) -> Option<Vec<&str>> {
523    // Check for both rumdl and markdownlint variants
524    for prefix in &["<!-- rumdl-enable-file", "<!-- markdownlint-enable-file"] {
525        if let Some(start) = line.find(prefix) {
526            let after_prefix = &line[start + prefix.len()..];
527
528            // Global enable-file: <!-- markdownlint-enable-file -->
529            if after_prefix.trim_start().starts_with("-->") {
530                return Some(Vec::new()); // Empty vec means all rules
531            }
532
533            // Rule-specific enable-file: <!-- markdownlint-enable-file MD001 MD002 -->
534            if let Some(end) = after_prefix.find("-->") {
535                let rules_str = after_prefix[..end].trim();
536                if !rules_str.is_empty() {
537                    let rules: Vec<&str> = rules_str.split_whitespace().collect();
538                    return Some(rules);
539                }
540            }
541        }
542    }
543
544    None
545}
546
547/// Parse a configure-file comment and return the JSON configuration
548pub fn parse_configure_file_comment(line: &str) -> Option<JsonValue> {
549    // Check for both rumdl and markdownlint variants
550    for prefix in &["<!-- rumdl-configure-file", "<!-- markdownlint-configure-file"] {
551        if let Some(start) = line.find(prefix) {
552            let after_prefix = &line[start + prefix.len()..];
553
554            // Find the JSON content between the prefix and -->
555            if let Some(end) = after_prefix.find("-->") {
556                let json_str = after_prefix[..end].trim();
557                if !json_str.is_empty() {
558                    // Try to parse as JSON
559                    if let Ok(value) = serde_json::from_str(json_str) {
560                        return Some(value);
561                    }
562                }
563            }
564        }
565    }
566
567    None
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_parse_disable_comment() {
576        // Global disable
577        assert_eq!(parse_disable_comment("<!-- markdownlint-disable -->"), Some(vec![]));
578        assert_eq!(parse_disable_comment("<!-- rumdl-disable -->"), Some(vec![]));
579
580        // Specific rules
581        assert_eq!(
582            parse_disable_comment("<!-- markdownlint-disable MD001 MD002 -->"),
583            Some(vec!["MD001", "MD002"])
584        );
585
586        // No comment
587        assert_eq!(parse_disable_comment("Some regular text"), None);
588    }
589
590    #[test]
591    fn test_parse_disable_line_comment() {
592        // Global disable-line
593        assert_eq!(
594            parse_disable_line_comment("<!-- markdownlint-disable-line -->"),
595            Some(vec![])
596        );
597
598        // Specific rules
599        assert_eq!(
600            parse_disable_line_comment("<!-- markdownlint-disable-line MD013 -->"),
601            Some(vec!["MD013"])
602        );
603
604        // No comment
605        assert_eq!(parse_disable_line_comment("Some regular text"), None);
606    }
607
608    #[test]
609    fn test_inline_config_from_content() {
610        let content = r#"# Test Document
611
612<!-- markdownlint-disable MD013 -->
613This is a very long line that would normally trigger MD013 but it's disabled
614
615<!-- markdownlint-enable MD013 -->
616This line will be checked again
617
618<!-- markdownlint-disable-next-line MD001 -->
619# This heading will not be checked for MD001
620## But this one will
621
622Some text <!-- markdownlint-disable-line MD013 -->
623
624<!-- markdownlint-capture -->
625<!-- markdownlint-disable MD001 MD002 -->
626# Heading with MD001 disabled
627<!-- markdownlint-restore -->
628# Heading with MD001 enabled again
629"#;
630
631        let config = InlineConfig::from_content(content);
632
633        // Line 4 should have MD013 disabled (line after disable comment on line 3)
634        assert!(config.is_rule_disabled("MD013", 4));
635
636        // Line 7 should have MD013 enabled (line after enable comment on line 6)
637        assert!(!config.is_rule_disabled("MD013", 7));
638
639        // Line 10 should have MD001 disabled (from disable-next-line on line 9)
640        assert!(config.is_rule_disabled("MD001", 10));
641
642        // Line 11 should not have MD001 disabled
643        assert!(!config.is_rule_disabled("MD001", 11));
644
645        // Line 13 should have MD013 disabled (from disable-line)
646        assert!(config.is_rule_disabled("MD013", 13));
647
648        // After restore (line 18), MD001 should be enabled again on line 19
649        assert!(!config.is_rule_disabled("MD001", 19));
650    }
651
652    #[test]
653    fn test_capture_restore() {
654        let content = r#"<!-- markdownlint-disable MD001 -->
655<!-- markdownlint-capture -->
656<!-- markdownlint-disable MD002 MD003 -->
657<!-- markdownlint-restore -->
658Some content after restore
659"#;
660
661        let config = InlineConfig::from_content(content);
662
663        // After restore (line 4), line 5 should only have MD001 disabled
664        assert!(config.is_rule_disabled("MD001", 5));
665        assert!(!config.is_rule_disabled("MD002", 5));
666        assert!(!config.is_rule_disabled("MD003", 5));
667    }
668}