rumdl_lib/rule.rs
1//!
2//! This module defines the Rule trait and related types for implementing linting rules in rumdl.
3
4use dyn_clone::DynClone;
5use serde::{Deserialize, Serialize};
6use std::ops::Range;
7use thiserror::Error;
8
9use crate::lint_context::LintContext;
10
11// Macro to implement box_clone for Rule implementors
12#[macro_export]
13macro_rules! impl_rule_clone {
14 ($ty:ty) => {
15 impl $ty {
16 fn box_clone(&self) -> Box<dyn Rule> {
17 Box::new(self.clone())
18 }
19 }
20 };
21}
22
23#[derive(Debug, Error)]
24pub enum LintError {
25 #[error("Invalid input: {0}")]
26 InvalidInput(String),
27 #[error("Fix failed: {0}")]
28 FixFailed(String),
29 #[error("IO error: {0}")]
30 IoError(#[from] std::io::Error),
31 #[error("Parsing error: {0}")]
32 ParsingError(String),
33}
34
35pub type LintResult = Result<Vec<LintWarning>, LintError>;
36
37#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
38pub struct LintWarning {
39 pub message: String,
40 pub line: usize, // 1-indexed start line
41 /// 1-indexed start column, measured in **characters** (not bytes).
42 /// When deriving a column from a byte offset (regex match, `str::find`,
43 /// parser byte offset), convert with `range_utils::byte_to_char_count` or a
44 /// character-based range helper. A raw byte offset mis-positions the
45 /// highlight on lines containing multi-byte UTF-8.
46 pub column: usize,
47 pub end_line: usize, // 1-indexed end line
48 /// 1-indexed end column, measured in **characters** (see `column`). Use
49 /// `str::chars().count()`, not `str::len()`, when computing a span width.
50 pub end_column: usize,
51 pub severity: Severity,
52 pub fix: Option<Fix>,
53 pub rule_name: Option<String>,
54}
55
56/// One atomic fix attached to a `LintWarning`.
57///
58/// `range`/`replacement` describe the primary edit. `additional_edits`
59/// carries any *paired* edits that must apply together with the primary one
60/// for the result to be a valid document — for example, MD054's conversion
61/// of an inline link to a reference style produces the in-place link rewrite
62/// **and** a reference-definition append at end-of-file; applying only one
63/// half would leave a dangling reference.
64///
65/// All fix consumers (the LSP code-action layer, CLI counters, the
66/// `apply_warning_fixes` helper) treat the primary edit and the
67/// `additional_edits` as a single unit. The field is empty by default so
68/// rules that only need a single-location fix can keep using
69/// `Fix::new(range, replacement)`; only rules that need multi-location
70/// atomicity populate it via `Fix::with_additional_edits(...)`.
71///
72/// `additional_edits` is intentionally a flat `Vec<Fix>` — nesting beyond
73/// one level isn't needed today and would complicate the apply contract.
74/// Apply order is "primary first, then additional in their declared order"
75/// when offsets are non-overlapping; consumers that batch multiple fixes
76/// across warnings still sort by `range.start` descending so earlier offsets
77/// remain valid as later edits mutate the buffer.
78#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
79pub struct Fix {
80 pub range: Range<usize>,
81 pub replacement: String,
82 /// Edits applied atomically with the primary `range`/`replacement` pair.
83 /// Empty for the common single-edit case. See struct docs for semantics.
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub additional_edits: Vec<Fix>,
86}
87
88impl Fix {
89 /// Construct a single-edit fix. Use this for the overwhelming common case
90 /// where a fix is one in-place replacement.
91 pub fn new(range: Range<usize>, replacement: String) -> Self {
92 Self {
93 range,
94 replacement,
95 additional_edits: Vec::new(),
96 }
97 }
98
99 /// Construct a multi-edit fix bundle. The primary edit is applied first,
100 /// followed by every entry in `additional_edits` as part of the same
101 /// atomic operation.
102 pub fn with_additional_edits(range: Range<usize>, replacement: String, additional_edits: Vec<Fix>) -> Self {
103 Self {
104 range,
105 replacement,
106 additional_edits,
107 }
108 }
109}
110
111#[derive(Debug, PartialEq, Clone, Copy, Serialize, schemars::JsonSchema)]
112#[serde(rename_all = "lowercase")]
113pub enum Severity {
114 Error,
115 Warning,
116 Info,
117}
118
119impl<'de> serde::Deserialize<'de> for Severity {
120 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121 where
122 D: serde::Deserializer<'de>,
123 {
124 let s = String::deserialize(deserializer)?;
125 match s.to_lowercase().as_str() {
126 "error" => Ok(Severity::Error),
127 "warning" => Ok(Severity::Warning),
128 "info" => Ok(Severity::Info),
129 _ => Err(serde::de::Error::custom(format!(
130 "Invalid severity: '{s}'. Valid values: error, warning, info"
131 ))),
132 }
133 }
134}
135
136/// Type of rule for selective processing
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum RuleCategory {
139 Heading,
140 List,
141 CodeBlock,
142 Link,
143 Image,
144 Html,
145 Emphasis,
146 Whitespace,
147 Blockquote,
148 Table,
149 FrontMatter,
150 Other,
151}
152
153/// Capability of a rule to fix issues
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum FixCapability {
156 /// Rule can automatically fix all violations it detects
157 FullyFixable,
158 /// Rule can fix some violations based on context
159 ConditionallyFixable,
160 /// Rule cannot fix violations (by design)
161 Unfixable,
162}
163
164/// Declares what cross-file data a rule needs
165///
166/// Most rules only need single-file context and should use `None` (the default).
167/// Rules that need to validate references across files (like MD051) should use `Workspace`.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub enum CrossFileScope {
170 /// Single-file only - no cross-file analysis needed (default for 99% of rules)
171 #[default]
172 None,
173 /// Needs workspace-wide index for cross-file validation
174 Workspace,
175}
176
177/// Remove marker /// TRAIT_MARKER_V1
178pub trait Rule: DynClone + Send + Sync {
179 fn name(&self) -> &'static str;
180 fn description(&self) -> &'static str;
181 fn check(&self, ctx: &LintContext) -> LintResult;
182 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
183
184 /// Check if this rule should quickly skip processing based on content
185 fn should_skip(&self, _ctx: &LintContext) -> bool {
186 false
187 }
188
189 /// Get the category of this rule for selective processing
190 fn category(&self) -> RuleCategory {
191 RuleCategory::Other // Default implementation returns Other
192 }
193
194 fn as_any(&self) -> &dyn std::any::Any;
195
196 // DocumentStructure has been merged into LintContext - this method is no longer used
197 // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
198 // None
199 // }
200
201 /// Returns the rule name and default config table if the rule has config.
202 /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
203 /// not just the inherent impl.
204 fn default_config_section(&self) -> Option<(String, toml::Value)> {
205 None
206 }
207
208 /// Returns config key aliases for this rule
209 /// This allows rules to accept alternative config key names for backwards compatibility
210 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
211 None
212 }
213
214 /// Returns the list of config keys whose deserializer accepts more than one TOML
215 /// type (e.g. either a scalar or a list). The schema is built from a serialized
216 /// default that can only encode one variant, so the validator would reject the
217 /// alternative form. The registry replaces the schema entry for each listed key
218 /// with a polymorphic sentinel so type checking is skipped while the key name
219 /// is still validated. Keep `default_config_section()` returning clean defaults
220 /// — the sentinel is a schema concern and must not leak into user-facing output
221 /// like `rumdl config --defaults`.
222 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
223 &[]
224 }
225
226 /// Declares the fix capability of this rule
227 fn fix_capability(&self) -> FixCapability {
228 FixCapability::FullyFixable // Safe default for backward compatibility
229 }
230
231 /// Declares cross-file analysis requirements for this rule
232 ///
233 /// Returns `CrossFileScope::None` by default, meaning the rule only needs
234 /// single-file context. Rules that need workspace-wide data should override
235 /// this to return `CrossFileScope::Workspace`.
236 fn cross_file_scope(&self) -> CrossFileScope {
237 CrossFileScope::None
238 }
239
240 /// Contribute data to the workspace index during linting
241 ///
242 /// Called during the single-file linting phase for rules that return
243 /// `CrossFileScope::Workspace`. Rules should extract headings, links,
244 /// and other data needed for cross-file validation.
245 ///
246 /// This is called as a side effect of linting, so LintContext is already
247 /// created - no duplicate parsing required.
248 fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
249 // Default: no contribution
250 }
251
252 /// Perform cross-file validation after all files have been linted
253 ///
254 /// Called once per file after the entire workspace has been indexed.
255 /// Rules receive the file_index (from contribute_to_index) and the full
256 /// workspace_index for cross-file lookups.
257 ///
258 /// Note: This receives the FileIndex instead of LintContext to avoid re-parsing
259 /// each file. The FileIndex was already populated during contribute_to_index.
260 ///
261 /// Rules can use workspace_index methods for cross-file validation:
262 /// - `get_file(path)` - to look up headings in target files (for MD051)
263 /// - `files()` - to iterate all indexed files
264 ///
265 /// Returns additional warnings for cross-file issues. These are appended
266 /// to the single-file warnings.
267 fn cross_file_check(
268 &self,
269 _file_path: &std::path::Path,
270 _file_index: &crate::workspace_index::FileIndex,
271 _workspace_index: &crate::workspace_index::WorkspaceIndex,
272 ) -> LintResult {
273 Ok(Vec::new()) // Default: no cross-file warnings
274 }
275
276 /// Factory: create a rule from config (if present), or use defaults.
277 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
278 where
279 Self: Sized,
280 {
281 panic!(
282 "from_config not implemented for rule: {}",
283 std::any::type_name::<Self>()
284 );
285 }
286}
287
288// Implement the cloning logic for the Rule trait object
289dyn_clone::clone_trait_object!(Rule);
290
291/// Extension trait to add downcasting capabilities to Rule
292pub trait RuleExt {
293 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
294}
295
296impl<R: Rule + 'static> RuleExt for Box<R> {
297 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
298 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
299 unsafe { Some(&*std::ptr::from_ref(self.as_ref()).cast::<T>()) }
300 } else {
301 None
302 }
303 }
304}
305
306// Inline config parsing functions are in inline_config.rs.
307// Use InlineConfig::from_content() for the full inline configuration system,
308// or inline_config::parse_disable_comment/parse_enable_comment for low-level parsing.
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_severity_serialization() {
316 let warning = LintWarning {
317 message: "Test warning".to_string(),
318 line: 1,
319 column: 1,
320 end_line: 1,
321 end_column: 10,
322 severity: Severity::Warning,
323 fix: None,
324 rule_name: Some("MD001".to_string()),
325 };
326
327 let serialized = serde_json::to_string(&warning).unwrap();
328 assert!(serialized.contains("\"severity\":\"warning\""));
329
330 let error = LintWarning {
331 severity: Severity::Error,
332 ..warning
333 };
334
335 let serialized = serde_json::to_string(&error).unwrap();
336 assert!(serialized.contains("\"severity\":\"error\""));
337 }
338
339 #[test]
340 fn test_fix_serialization() {
341 let fix = Fix::new(0..10, "fixed text".to_string());
342
343 let warning = LintWarning {
344 message: "Test warning".to_string(),
345 line: 1,
346 column: 1,
347 end_line: 1,
348 end_column: 10,
349 severity: Severity::Warning,
350 fix: Some(fix),
351 rule_name: Some("MD001".to_string()),
352 };
353
354 let serialized = serde_json::to_string(&warning).unwrap();
355 assert!(serialized.contains("\"fix\""));
356 assert!(serialized.contains("\"replacement\":\"fixed text\""));
357 }
358
359 #[test]
360 fn test_rule_category_equality() {
361 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
362 assert_ne!(RuleCategory::Heading, RuleCategory::List);
363
364 // Test all categories are distinct
365 let categories = [
366 RuleCategory::Heading,
367 RuleCategory::List,
368 RuleCategory::CodeBlock,
369 RuleCategory::Link,
370 RuleCategory::Image,
371 RuleCategory::Html,
372 RuleCategory::Emphasis,
373 RuleCategory::Whitespace,
374 RuleCategory::Blockquote,
375 RuleCategory::Table,
376 RuleCategory::FrontMatter,
377 RuleCategory::Other,
378 ];
379
380 for (i, cat1) in categories.iter().enumerate() {
381 for (j, cat2) in categories.iter().enumerate() {
382 if i == j {
383 assert_eq!(cat1, cat2);
384 } else {
385 assert_ne!(cat1, cat2);
386 }
387 }
388 }
389 }
390
391 #[test]
392 fn test_lint_error_conversions() {
393 use std::io;
394
395 // Test From<io::Error>
396 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
397 let lint_error: LintError = io_error.into();
398 match lint_error {
399 LintError::IoError(_) => {}
400 _ => panic!("Expected IoError variant"),
401 }
402
403 // Test Display trait
404 let invalid_input = LintError::InvalidInput("bad input".to_string());
405 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
406
407 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
408 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
409
410 let parsing_error = LintError::ParsingError("parse error".to_string());
411 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
412 }
413}