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
56impl LintWarning {
57 /// The 1-indexed lines whose content this warning covers, used to decide
58 /// whether an inline disable comment suppresses it.
59 ///
60 /// `end_column` is exclusive, so a range ending at column 1 of a later line
61 /// stops at that line's start and covers none of it: a warning spanning
62 /// whole lines with their terminators ends there. That line is excluded,
63 /// so a `disable-line` comment on the content after a flagged run does not
64 /// suppress the run. An `end_line` before `line` falls back to `line` alone.
65 pub fn covered_lines(&self) -> std::ops::RangeInclusive<usize> {
66 let end = if self.end_line > self.line && self.end_column <= 1 {
67 self.end_line - 1
68 } else {
69 self.end_line.max(self.line)
70 };
71 self.line..=end
72 }
73}
74
75/// One atomic fix attached to a `LintWarning`.
76///
77/// `range`/`replacement` describe the primary edit. `additional_edits`
78/// carries any *paired* edits that must apply together with the primary one
79/// for the result to be a valid document — for example, MD054's conversion
80/// of an inline link to a reference style produces the in-place link rewrite
81/// **and** a reference-definition append at end-of-file; applying only one
82/// half would leave a dangling reference.
83///
84/// All fix consumers (the LSP code-action layer, CLI counters, the
85/// `apply_warning_fixes` helper) treat the primary edit and the
86/// `additional_edits` as a single unit. The field is empty by default so
87/// rules that only need a single-location fix can keep using
88/// `Fix::new(range, replacement)`; only rules that need multi-location
89/// atomicity populate it via `Fix::with_additional_edits(...)`.
90///
91/// `additional_edits` is intentionally a flat `Vec<Fix>` — nesting beyond
92/// one level isn't needed today and would complicate the apply contract.
93/// Apply order is "primary first, then additional in their declared order"
94/// when offsets are non-overlapping; consumers that batch multiple fixes
95/// across warnings still sort by `range.start` descending so earlier offsets
96/// remain valid as later edits mutate the buffer.
97#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
98pub struct Fix {
99 /// Byte range in the text supplied to the producing API. Public document
100 /// linting includes any leading BOM; raw `Rule::check` ranges address the
101 /// rule's `LintContext::content`.
102 pub range: Range<usize>,
103 pub replacement: String,
104 /// Edits applied atomically with the primary `range`/`replacement` pair.
105 /// Empty for the common single-edit case. See struct docs for semantics.
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub additional_edits: Vec<Fix>,
108}
109
110impl Fix {
111 /// Construct a single-edit fix. Use this for the overwhelming common case
112 /// where a fix is one in-place replacement.
113 pub fn new(range: Range<usize>, replacement: String) -> Self {
114 Self {
115 range,
116 replacement,
117 additional_edits: Vec::new(),
118 }
119 }
120
121 /// Construct a multi-edit fix bundle. The primary edit is applied first,
122 /// followed by every entry in `additional_edits` as part of the same
123 /// atomic operation.
124 pub fn with_additional_edits(range: Range<usize>, replacement: String, additional_edits: Vec<Fix>) -> Self {
125 Self {
126 range,
127 replacement,
128 additional_edits,
129 }
130 }
131}
132
133#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, schemars::JsonSchema)]
134#[serde(rename_all = "lowercase")]
135pub enum Severity {
136 Error,
137 Warning,
138 Info,
139}
140
141impl<'de> serde::Deserialize<'de> for Severity {
142 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
143 where
144 D: serde::Deserializer<'de>,
145 {
146 let s = String::deserialize(deserializer)?;
147 match s.to_lowercase().as_str() {
148 "error" => Ok(Severity::Error),
149 "warning" => Ok(Severity::Warning),
150 "info" => Ok(Severity::Info),
151 _ => Err(serde::de::Error::custom(format!(
152 "Invalid severity: '{s}'. Valid values: error, warning, info"
153 ))),
154 }
155 }
156}
157
158/// Type of rule for selective processing
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum RuleCategory {
161 Heading,
162 List,
163 CodeBlock,
164 Link,
165 Image,
166 Html,
167 Emphasis,
168 Whitespace,
169 Blockquote,
170 Table,
171 FrontMatter,
172 Other,
173}
174
175/// Capability of a rule to fix issues
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum FixCapability {
178 /// Rule can automatically fix all violations it detects
179 FullyFixable,
180 /// Rule can fix some violations based on context
181 ConditionallyFixable,
182 /// Rule cannot fix violations (by design)
183 Unfixable,
184}
185
186/// Declares what cross-file data a rule needs
187///
188/// Most rules only need single-file context and should use `None` (the default).
189/// Rules that need to validate references across files (like MD051) should use `Workspace`.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum CrossFileScope {
192 /// Single-file only - no cross-file analysis needed (default for 99% of rules)
193 #[default]
194 None,
195 /// Needs workspace-wide index for cross-file validation
196 Workspace,
197}
198
199/// A warning an inline disable comment kept out of a document's results.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SuppressedWarning {
202 /// Canonical id of the rule that raised the warning, e.g. `MD013`
203 pub rule_name: String,
204 /// 1-indexed line of the warning's range the rule was found disabled on
205 pub line: usize,
206 /// The kind of directive that disabled the rule there
207 pub layer: crate::inline_config::DisableLayer,
208}
209
210/// What a run's inline disable comments actually suppressed.
211///
212/// Assembled once per document, after every single-file rule has run, so a rule
213/// reading it sees the complete picture.
214#[derive(Debug, Clone, Default)]
215pub struct SuppressionReport {
216 /// Every warning an inline disable comment removed, in the order raised
217 pub suppressed: Vec<SuppressedWarning>,
218 /// Canonical ids of the rules whose findings this report accounts for.
219 ///
220 /// A rule outside this set produced nothing the report can see, so nothing
221 /// can be concluded about a comment naming it.
222 pub judged_rules: std::collections::HashSet<String>,
223}
224
225pub trait Rule: DynClone + Send + Sync {
226 fn name(&self) -> &'static str;
227 fn description(&self) -> &'static str;
228 /// Return raw diagnostics, including findings covered by inline disables.
229 /// The lint driver filters them and records which comments suppressed them
230 /// for observers such as MD087. Filtering them here loses that accounting.
231 /// Disabled regions may still be excluded from inferred settings, and `fix`
232 /// must honor them when rewriting content.
233 fn check(&self, ctx: &LintContext) -> LintResult;
234 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
235
236 /// Return the native fix when computing findings already produces it.
237 /// The result belongs only to this configured instance and exact context;
238 /// callers discard it with the findings after any source rewrite.
239 #[doc(hidden)]
240 fn check_with_prepared_fix(&self, ctx: &LintContext) -> Result<(Vec<LintWarning>, Option<String>), LintError> {
241 self.check(ctx).map(|warnings| (warnings, None))
242 }
243
244 /// Apply this rule's native fixer using its already-computed findings.
245 /// Callers must supply raw diagnostics from this configured instance and
246 /// this exact immutable context. No findings survive a source rewrite.
247 /// Custom rules keep their normal fixer unless they explicitly opt in.
248 #[doc(hidden)]
249 fn fix_with_diagnostics(&self, ctx: &LintContext, _warnings: &[LintWarning]) -> Result<String, LintError> {
250 self.fix(ctx)
251 }
252
253 /// Whether CLI fixing is exactly the application of this rule's diagnostic
254 /// edits, filtered by inline disables. The engine can then consume the
255 /// already-computed edits without checking the same source a second time.
256 ///
257 /// Opt in only when `fix()` performs no additional transformations. The
258 /// default preserves custom fix implementations and third-party rules.
259 /// When opting in, `should_skip(ctx)` must imply that applying the raw
260 /// check diagnostics would leave the source unchanged as well.
261 #[doc(hidden)]
262 fn fixes_from_diagnostics(&self) -> bool {
263 false
264 }
265
266 /// Built-in-only certificate for proving that a source revision needs no fix.
267 /// External rules retain their existing check and fix behavior by default.
268 #[doc(hidden)]
269 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
270 None
271 }
272
273 /// Check if this rule should quickly skip processing based on content.
274 fn should_skip(&self, _ctx: &LintContext) -> bool {
275 false
276 }
277
278 /// Get the category of this rule for selective processing
279 fn category(&self) -> RuleCategory {
280 RuleCategory::Other // Default implementation returns Other
281 }
282
283 /// Whether the content-category prefilter may skip this rule.
284 ///
285 /// The prefilter reads the document's shape alone: a `Link` rule is skipped
286 /// for a document holding no links. A rule whose configuration widens what
287 /// it reads answers `false` for that configuration, so that `should_skip`
288 /// and `check` decide instead. MD051 and MD057 read frontmatter values on
289 /// request, and a document can carry those with no link syntax at all.
290 fn skippable_by_category(&self) -> bool {
291 true
292 }
293
294 /// Return this concrete rule instance for type-based queries.
295 fn as_any(&self) -> &dyn std::any::Any;
296
297 // DocumentStructure has been merged into LintContext - this method is no longer used
298 // fn as_maybe_document_structure(&self) -> Option<&dyn MaybeDocumentStructure> {
299 // None
300 // }
301
302 /// Returns the rule name and default config table if the rule has config.
303 /// If a rule implements this, it MUST be defined on the `impl Rule for ...` block,
304 /// not just the inherent impl.
305 ///
306 /// This is user-facing: it backs `rumdl config`, `rumdl config --defaults` and
307 /// `rumdl explain`, so every value here must be a real default the user could write
308 /// back into a config file. A key whose default cannot be written down (an unset
309 /// `Option`) is therefore absent. Config *validation* reads [`Rule::config_schema`]
310 /// instead, which keeps such keys.
311 fn default_config_section(&self) -> Option<(String, toml::Value)> {
312 None
313 }
314
315 /// Returns the rule name and every config key the rule accepts, for validation.
316 ///
317 /// A key with no representable default (an unset `Option`, or a deserializer that
318 /// accepts several TOML types) carries a sentinel value: the key name is recognized
319 /// while its type check is skipped. Sentinels contain a NUL byte and must never
320 /// reach user-facing output, which is why this is separate from
321 /// [`Rule::default_config_section`].
322 ///
323 /// Defaults to the user-facing table, which is correct for a rule whose every key
324 /// has a representable default.
325 fn config_schema(&self) -> Option<(String, toml::Value)> {
326 self.default_config_section()
327 }
328
329 /// Returns config key aliases for this rule
330 /// This allows rules to accept alternative config key names for backwards compatibility
331 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
332 None
333 }
334
335 /// Returns the list of config keys whose deserializer accepts more than one TOML
336 /// type (e.g. either a scalar or a list). The schema is built from a serialized
337 /// default that can only encode one variant, so the validator would reject the
338 /// alternative form. The registry replaces the schema entry for each listed key
339 /// with a polymorphic sentinel so type checking is skipped while the key name
340 /// is still validated. The registry rewrites [`Rule::config_schema`], so
341 /// `default_config_section()` keeps returning the clean user-facing default.
342 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
343 &[]
344 }
345
346 /// Declares the fix capability of this rule
347 fn fix_capability(&self) -> FixCapability {
348 FixCapability::FullyFixable // Safe default for backward compatibility
349 }
350
351 /// Declares cross-file analysis requirements for this rule
352 ///
353 /// Returns `CrossFileScope::None` by default, meaning the rule only needs
354 /// single-file context. Rules that need workspace-wide data should override
355 /// this to return `CrossFileScope::Workspace`.
356 fn cross_file_scope(&self) -> CrossFileScope {
357 CrossFileScope::None
358 }
359
360 /// Contribute data to the workspace index during linting
361 ///
362 /// Called during the single-file linting phase for rules that return
363 /// `CrossFileScope::Workspace`. Rules should extract headings, links,
364 /// and other data needed for cross-file validation.
365 ///
366 /// This is called as a side effect of linting, so LintContext is already
367 /// created - no duplicate parsing required.
368 fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
369 // Default: no contribution
370 }
371
372 /// Perform cross-file validation after all files have been linted
373 ///
374 /// Called once per file after the entire workspace has been indexed.
375 /// Rules receive the file_index (from contribute_to_index) and the full
376 /// workspace_index for cross-file lookups.
377 ///
378 /// Note: This receives the FileIndex instead of LintContext to avoid re-parsing
379 /// each file. The FileIndex was already populated during contribute_to_index.
380 ///
381 /// Rules can use workspace_index methods for cross-file validation:
382 /// - `get_file(path)` - to look up headings in target files (for MD051)
383 /// - `files()` - to iterate all indexed files
384 ///
385 /// Returns additional warnings for cross-file issues. These are appended
386 /// to the single-file warnings.
387 fn cross_file_check(
388 &self,
389 _file_path: &std::path::Path,
390 _file_index: &crate::workspace_index::FileIndex,
391 _workspace_index: &crate::workspace_index::WorkspaceIndex,
392 ) -> LintResult {
393 Ok(Vec::new()) // Default: no cross-file warnings
394 }
395
396 /// Whether this rule reports on the inline comments that suppressed warnings
397 ///
398 /// Recording every suppression costs work on each file, so the linting driver
399 /// does it only when a rule asks for it. A rule answering `true` receives the
400 /// result through `check_suppressions`.
401 fn observes_suppressions(&self) -> bool {
402 false
403 }
404
405 /// Report on a document's inline disable comments
406 ///
407 /// Called once per document after every single-file rule has run, for rules
408 /// that return `true` from `observes_suppressions`. The report says which
409 /// warnings the comments removed and which rules the run can account for.
410 ///
411 /// Returns warnings that join the single-file warnings.
412 fn check_suppressions(&self, _ctx: &LintContext, _report: &SuppressionReport) -> LintResult {
413 Ok(Vec::new()) // Default: nothing to report
414 }
415
416 /// Factory: create a rule from config (if present), or use defaults.
417 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
418 where
419 Self: Sized,
420 {
421 panic!(
422 "from_config not implemented for rule: {}",
423 std::any::type_name::<Self>()
424 );
425 }
426}
427
428// Implement the cloning logic for the Rule trait object
429dyn_clone::clone_trait_object!(Rule);
430
431/// Extension trait to add downcasting capabilities to Rule
432pub trait RuleExt {
433 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
434}
435
436impl<R: Rule + 'static> RuleExt for Box<R> {
437 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
438 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
439 unsafe { Some(&*std::ptr::from_ref(self.as_ref()).cast::<T>()) }
440 } else {
441 None
442 }
443 }
444}
445
446// Inline config parsing functions are in inline_config.rs.
447// Use InlineConfig::from_content() for the full inline configuration system,
448// or inline_config::parse_disable_comment/parse_enable_comment for low-level parsing.
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn test_severity_serialization() {
456 let warning = LintWarning {
457 message: "Test warning".to_string(),
458 line: 1,
459 column: 1,
460 end_line: 1,
461 end_column: 10,
462 severity: Severity::Warning,
463 fix: None,
464 rule_name: Some("MD001".to_string()),
465 };
466
467 let serialized = serde_json::to_string(&warning).unwrap();
468 assert!(serialized.contains("\"severity\":\"warning\""));
469
470 let error = LintWarning {
471 severity: Severity::Error,
472 ..warning
473 };
474
475 let serialized = serde_json::to_string(&error).unwrap();
476 assert!(serialized.contains("\"severity\":\"error\""));
477 }
478
479 #[test]
480 fn test_fix_serialization() {
481 let fix = Fix::new(0..10, "fixed text".to_string());
482
483 let warning = LintWarning {
484 message: "Test warning".to_string(),
485 line: 1,
486 column: 1,
487 end_line: 1,
488 end_column: 10,
489 severity: Severity::Warning,
490 fix: Some(fix),
491 rule_name: Some("MD001".to_string()),
492 };
493
494 let serialized = serde_json::to_string(&warning).unwrap();
495 assert!(serialized.contains("\"fix\""));
496 assert!(serialized.contains("\"replacement\":\"fixed text\""));
497 }
498
499 #[test]
500 fn test_rule_category_equality() {
501 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
502 assert_ne!(RuleCategory::Heading, RuleCategory::List);
503
504 // Test all categories are distinct
505 let categories = [
506 RuleCategory::Heading,
507 RuleCategory::List,
508 RuleCategory::CodeBlock,
509 RuleCategory::Link,
510 RuleCategory::Image,
511 RuleCategory::Html,
512 RuleCategory::Emphasis,
513 RuleCategory::Whitespace,
514 RuleCategory::Blockquote,
515 RuleCategory::Table,
516 RuleCategory::FrontMatter,
517 RuleCategory::Other,
518 ];
519
520 for (i, cat1) in categories.iter().enumerate() {
521 for (j, cat2) in categories.iter().enumerate() {
522 if i == j {
523 assert_eq!(cat1, cat2);
524 } else {
525 assert_ne!(cat1, cat2);
526 }
527 }
528 }
529 }
530
531 #[test]
532 fn test_lint_error_conversions() {
533 use std::io;
534
535 // Test From<io::Error>
536 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
537 let lint_error: LintError = io_error.into();
538 match lint_error {
539 LintError::IoError(_) => {}
540 _ => panic!("Expected IoError variant"),
541 }
542
543 // Test Display trait
544 let invalid_input = LintError::InvalidInput("bad input".to_string());
545 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
546
547 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
548 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
549
550 let parsing_error = LintError::ParsingError("parse error".to_string());
551 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
552 }
553}