Skip to main content

mdlint/lint/
engine.rs

1use crate::config::{Config, RuleConfig};
2use crate::error::Result;
3use crate::lint::{Rule, RuleRegistry};
4use crate::markdown::MarkdownParser;
5use crate::types::Violation;
6use serde_json::Value;
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10pub struct LintEngine {
11    config: Config,
12    registry: RuleRegistry,
13}
14
15impl LintEngine {
16    pub fn new(config: Config) -> Self {
17        let registry = crate::lint::rules::create_default_registry();
18        Self { config, registry }
19    }
20
21    pub fn lint_content(&self, content: &str) -> Result<Vec<Violation>> {
22        let parser = MarkdownParser::new(content);
23        let mut violations: Vec<Violation> = self
24            .registry
25            .all_rules()
26            .flat_map(|rule| self.violations(&parser, rule))
27            .collect();
28
29        if !self.config.no_inline_config {
30            let suppressed = parse_inline_config(content);
31            if !suppressed.is_empty() {
32                violations.retain(|v| {
33                    let line = v.line;
34                    let all = suppressed.get("*").is_some_and(|s| s.contains(&line));
35                    let specific = suppressed
36                        .get(v.rule.as_str())
37                        .is_some_and(|s| s.contains(&line));
38                    !all && !specific
39                });
40            }
41        }
42
43        Ok(violations)
44    }
45
46    fn violations(&self, parser: &MarkdownParser, rule: &dyn Rule) -> Vec<Violation> {
47        let rule_config = self.config.config().get(rule.name());
48        let config_value = match rule_config {
49            Some(RuleConfig::Enabled(false)) => return Vec::new(),
50            Some(RuleConfig::Enabled(true)) => None,
51            Some(RuleConfig::Config(cfg)) => {
52                // Convert TOML config to JSON for rule consumption
53                let mut table = toml::map::Map::new();
54                for (k, v) in cfg.clone() {
55                    table.insert(k, v);
56                }
57                let toml_value = toml::Value::Table(table);
58                let json_value: Value = toml_to_json(toml_value);
59
60                if let Some(Value::Bool(false)) = json_value.get("enabled") {
61                    return Vec::new();
62                }
63                Some(json_value)
64            }
65            None => {
66                // If default_enabled is true and no specific config exists, enable the rule
67                if self.config.default_enabled {
68                    None
69                } else {
70                    return Vec::new();
71                }
72            }
73        };
74
75        rule.check(parser, config_value.as_ref())
76    }
77
78    pub fn lint_file(&self, path: &Path) -> Result<Vec<Violation>> {
79        let content = std::fs::read_to_string(path)?;
80        self.lint_content(&content)
81    }
82}
83
84/// Parse inline configuration comments from document content.
85///
86/// Supports:
87/// - `<!-- mdlint-disable -->` / `<!-- mdlint-disable MD001 MD003 -->`
88/// - `<!-- mdlint-enable -->` / `<!-- mdlint-enable MD001 -->`
89/// - `<!-- mdlint-disable-next-line -->` / `<!-- mdlint-disable-next-line MD001 -->`
90///
91/// Returns a map from rule name (or `"*"` for all rules) to the set of suppressed line numbers.
92fn parse_inline_config(content: &str) -> HashMap<String, HashSet<usize>> {
93    let lines: Vec<&str> = content.lines().collect();
94    let total_lines = lines.len();
95
96    // Active disable ranges awaiting a matching enable: rule -> start line
97    let mut active: HashMap<String, usize> = HashMap::new();
98    // Completed ranges: rule -> [(start, end)]
99    let mut ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
100
101    for (idx, line) in lines.iter().enumerate() {
102        let line_num = idx + 1;
103        let Some((kind, rule_names)) = extract_directive(line) else {
104            continue;
105        };
106        match kind {
107            DirectiveKind::DisableNextLine => {
108                let next = line_num + 1;
109                for rule in rules_or_all(rule_names) {
110                    ranges.entry(rule).or_default().push((next, next));
111                }
112            }
113            DirectiveKind::Disable => {
114                for rule in rules_or_all(rule_names) {
115                    active.entry(rule).or_insert(line_num);
116                }
117            }
118            DirectiveKind::Enable => {
119                let to_enable = rules_or_all(rule_names);
120                if to_enable.contains(&"*".to_string()) {
121                    for (rule, start) in active.drain() {
122                        ranges.entry(rule).or_default().push((start, line_num - 1));
123                    }
124                } else {
125                    for rule in to_enable {
126                        if let Some(start) = active.remove(&rule) {
127                            ranges.entry(rule).or_default().push((start, line_num - 1));
128                        }
129                    }
130                }
131            }
132        }
133    }
134
135    // Close any remaining open disables at end of document
136    for (rule, start) in active {
137        ranges.entry(rule).or_default().push((start, total_lines));
138    }
139
140    // Expand ranges into per-line sets
141    let mut suppressed: HashMap<String, HashSet<usize>> = HashMap::new();
142    for (rule, rule_ranges) in ranges {
143        let entry = suppressed.entry(rule).or_default();
144        for (start, end) in rule_ranges {
145            entry.extend(start..=end);
146        }
147    }
148    suppressed
149}
150
151enum DirectiveKind {
152    Disable,
153    Enable,
154    DisableNextLine,
155}
156
157/// Extract an mdlint directive from a line, returning the kind and the list of rule names
158/// (empty = apply to all rules). Returns `None` if the line contains no directive.
159fn extract_directive(line: &str) -> Option<(DirectiveKind, Vec<String>)> {
160    let start = line.find("<!--")?;
161    let end = line[start..].find("-->")?;
162    let body = line[start + 4..start + end].trim();
163
164    if let Some(rest) = body.strip_prefix("mdlint-disable-next-line") {
165        Some((DirectiveKind::DisableNextLine, parse_rule_names(rest)))
166    } else if let Some(rest) = body.strip_prefix("mdlint-disable") {
167        Some((DirectiveKind::Disable, parse_rule_names(rest)))
168    } else {
169        body.strip_prefix("mdlint-enable")
170            .map(|rest| (DirectiveKind::Enable, parse_rule_names(rest)))
171    }
172}
173
174fn parse_rule_names(s: &str) -> Vec<String> {
175    s.split_whitespace().map(str::to_string).collect()
176}
177
178fn rules_or_all(rules: Vec<String>) -> Vec<String> {
179    if rules.is_empty() {
180        vec!["*".to_string()]
181    } else {
182        rules
183    }
184}
185
186/// Convert a TOML value to a JSON value
187fn toml_to_json(toml_val: toml::Value) -> Value {
188    match toml_val {
189        toml::Value::String(s) => Value::String(s),
190        toml::Value::Integer(i) => Value::Number(i.into()),
191        toml::Value::Float(f) => {
192            Value::Number(serde_json::Number::from_f64(f).unwrap_or_else(|| 0.into()))
193        }
194        toml::Value::Boolean(b) => Value::Bool(b),
195        toml::Value::Array(arr) => Value::Array(arr.into_iter().map(toml_to_json).collect()),
196        toml::Value::Table(table) => Value::Object(
197            table
198                .into_iter()
199                .map(|(k, v)| (k, toml_to_json(v)))
200                .collect(),
201        ),
202        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::config::Config;
210
211    fn engine_all_rules() -> LintEngine {
212        LintEngine::new(Config {
213            default_enabled: true,
214            ..Config::default()
215        })
216    }
217
218    #[test]
219    fn test_disable_next_line_specific_rule() {
220        // MD018: no space after hash. Line 2 has `#Heading` — suppressed by disable-next-line on line 1.
221        let content = "<!-- mdlint-disable-next-line MD018 -->\n#Heading without space\n";
222        let engine = engine_all_rules();
223        let violations = engine.lint_content(content).unwrap();
224        assert!(
225            violations.iter().all(|v| v.rule != "MD018"),
226            "MD018 should be suppressed on line 2: {violations:?}"
227        );
228    }
229
230    #[test]
231    fn test_disable_next_line_does_not_suppress_two_lines_ahead() {
232        // The disable-next-line on line 1 suppresses line 2, NOT line 3
233        let content = "<!-- mdlint-disable-next-line MD018 -->\n# Good heading\n#Bad heading\n";
234        let engine = engine_all_rules();
235        let violations = engine.lint_content(content).unwrap();
236        // MD018 on line 3 should still fire
237        assert!(
238            violations.iter().any(|v| v.rule == "MD018" && v.line == 3),
239            "MD018 on line 3 should not be suppressed: {violations:?}"
240        );
241    }
242
243    #[test]
244    fn test_disable_enable_specific_rule() {
245        // Disable MD041, then re-enable it; violations between should be suppressed
246        let content =
247            "<!-- mdlint-disable MD041 -->\nNo heading here\n<!-- mdlint-enable MD041 -->\n";
248        let engine = engine_all_rules();
249        let violations = engine.lint_content(content).unwrap();
250        assert!(
251            violations.iter().all(|v| v.rule != "MD041"),
252            "MD041 should be suppressed in disabled range: {violations:?}"
253        );
254    }
255
256    #[test]
257    fn test_disable_all_rules() {
258        let content = "<!-- mdlint-disable -->\nNo heading here\n<!-- mdlint-enable -->\n";
259        let engine = engine_all_rules();
260        let violations = engine.lint_content(content).unwrap();
261        // All rules suppressed from line 1 to line 2 (enable on line 3)
262        let lines_12: Vec<_> = violations.iter().filter(|v| v.line <= 2).collect();
263        assert!(
264            lines_12.is_empty(),
265            "Lines 1-2 should have no violations: {violations:?}"
266        );
267    }
268
269    #[test]
270    fn test_no_inline_config_flag_disables_parsing() {
271        let content = "<!-- mdlint-disable MD041 -->\nNo heading here\n";
272        let engine = LintEngine::new(Config {
273            default_enabled: true,
274            no_inline_config: true,
275            ..Config::default()
276        });
277        let violations = engine.lint_content(content).unwrap();
278        // With no_inline_config, the directive is ignored — MD041 should still fire
279        assert!(
280            violations.iter().any(|v| v.rule == "MD041"),
281            "MD041 should NOT be suppressed when no_inline_config=true: {violations:?}"
282        );
283    }
284
285    #[test]
286    fn test_disable_without_enable_suppresses_to_end() {
287        let content = "# Heading\n\n<!-- mdlint-disable MD013 -->\nA very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on\n";
288        let engine = engine_all_rules();
289        let violations = engine.lint_content(content).unwrap();
290        assert!(
291            violations.iter().all(|v| v.rule != "MD013"),
292            "MD013 should be suppressed to end of file: {violations:?}"
293        );
294    }
295}