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 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 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
84fn 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 let mut active: HashMap<String, usize> = HashMap::new();
98 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 for (rule, start) in active {
137 ranges.entry(rule).or_default().push((start, total_lines));
138 }
139
140 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
157fn 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
186fn 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 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 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 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 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 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 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}