1use dyn_clone::DynClone;
5use serde::{Deserialize, Serialize};
6use std::ops::Range;
7use thiserror::Error;
8
9use crate::lint_context::LintContext;
10
11#[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, pub column: usize, pub end_line: usize, pub end_column: usize, pub severity: Severity,
45 pub fix: Option<Fix>,
46 pub rule_name: Option<String>,
47}
48
49#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
50pub struct Fix {
51 pub range: Range<usize>,
52 pub replacement: String,
53}
54
55#[derive(Debug, PartialEq, Clone, Copy, Serialize, schemars::JsonSchema)]
56#[serde(rename_all = "lowercase")]
57pub enum Severity {
58 Error,
59 Warning,
60 Info,
61}
62
63impl<'de> serde::Deserialize<'de> for Severity {
64 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65 where
66 D: serde::Deserializer<'de>,
67 {
68 let s = String::deserialize(deserializer)?;
69 match s.to_lowercase().as_str() {
70 "error" => Ok(Severity::Error),
71 "warning" => Ok(Severity::Warning),
72 "info" => Ok(Severity::Info),
73 _ => Err(serde::de::Error::custom(format!(
74 "Invalid severity: '{s}'. Valid values: error, warning, info"
75 ))),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum RuleCategory {
83 Heading,
84 List,
85 CodeBlock,
86 Link,
87 Image,
88 Html,
89 Emphasis,
90 Whitespace,
91 Blockquote,
92 Table,
93 FrontMatter,
94 Other,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum FixCapability {
100 FullyFixable,
102 ConditionallyFixable,
104 Unfixable,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub enum CrossFileScope {
114 #[default]
116 None,
117 Workspace,
119}
120
121pub trait Rule: DynClone + Send + Sync {
123 fn name(&self) -> &'static str;
124 fn description(&self) -> &'static str;
125 fn check(&self, ctx: &LintContext) -> LintResult;
126 fn fix(&self, ctx: &LintContext) -> Result<String, LintError>;
127
128 fn should_skip(&self, _ctx: &LintContext) -> bool {
130 false
131 }
132
133 fn category(&self) -> RuleCategory {
135 RuleCategory::Other }
137
138 fn as_any(&self) -> &dyn std::any::Any;
139
140 fn default_config_section(&self) -> Option<(String, toml::Value)> {
149 None
150 }
151
152 fn config_aliases(&self) -> Option<std::collections::HashMap<String, String>> {
155 None
156 }
157
158 fn fix_capability(&self) -> FixCapability {
160 FixCapability::FullyFixable }
162
163 fn cross_file_scope(&self) -> CrossFileScope {
169 CrossFileScope::None
170 }
171
172 fn contribute_to_index(&self, _ctx: &LintContext, _file_index: &mut crate::workspace_index::FileIndex) {
181 }
183
184 fn cross_file_check(
200 &self,
201 _file_path: &std::path::Path,
202 _file_index: &crate::workspace_index::FileIndex,
203 _workspace_index: &crate::workspace_index::WorkspaceIndex,
204 ) -> LintResult {
205 Ok(Vec::new()) }
207
208 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
210 where
211 Self: Sized,
212 {
213 panic!(
214 "from_config not implemented for rule: {}",
215 std::any::type_name::<Self>()
216 );
217 }
218}
219
220dyn_clone::clone_trait_object!(Rule);
222
223pub trait RuleExt {
225 fn downcast_ref<T: 'static>(&self) -> Option<&T>;
226}
227
228impl<R: Rule + 'static> RuleExt for Box<R> {
229 fn downcast_ref<T: 'static>(&self) -> Option<&T> {
230 if std::any::TypeId::of::<R>() == std::any::TypeId::of::<T>() {
231 unsafe { Some(&*(self.as_ref() as *const _ as *const T)) }
232 } else {
233 None
234 }
235 }
236}
237
238#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_severity_serialization() {
248 let warning = LintWarning {
249 message: "Test warning".to_string(),
250 line: 1,
251 column: 1,
252 end_line: 1,
253 end_column: 10,
254 severity: Severity::Warning,
255 fix: None,
256 rule_name: Some("MD001".to_string()),
257 };
258
259 let serialized = serde_json::to_string(&warning).unwrap();
260 assert!(serialized.contains("\"severity\":\"warning\""));
261
262 let error = LintWarning {
263 severity: Severity::Error,
264 ..warning
265 };
266
267 let serialized = serde_json::to_string(&error).unwrap();
268 assert!(serialized.contains("\"severity\":\"error\""));
269 }
270
271 #[test]
272 fn test_fix_serialization() {
273 let fix = Fix {
274 range: 0..10,
275 replacement: "fixed text".to_string(),
276 };
277
278 let warning = LintWarning {
279 message: "Test warning".to_string(),
280 line: 1,
281 column: 1,
282 end_line: 1,
283 end_column: 10,
284 severity: Severity::Warning,
285 fix: Some(fix),
286 rule_name: Some("MD001".to_string()),
287 };
288
289 let serialized = serde_json::to_string(&warning).unwrap();
290 assert!(serialized.contains("\"fix\""));
291 assert!(serialized.contains("\"replacement\":\"fixed text\""));
292 }
293
294 #[test]
295 fn test_rule_category_equality() {
296 assert_eq!(RuleCategory::Heading, RuleCategory::Heading);
297 assert_ne!(RuleCategory::Heading, RuleCategory::List);
298
299 let categories = [
301 RuleCategory::Heading,
302 RuleCategory::List,
303 RuleCategory::CodeBlock,
304 RuleCategory::Link,
305 RuleCategory::Image,
306 RuleCategory::Html,
307 RuleCategory::Emphasis,
308 RuleCategory::Whitespace,
309 RuleCategory::Blockquote,
310 RuleCategory::Table,
311 RuleCategory::FrontMatter,
312 RuleCategory::Other,
313 ];
314
315 for (i, cat1) in categories.iter().enumerate() {
316 for (j, cat2) in categories.iter().enumerate() {
317 if i == j {
318 assert_eq!(cat1, cat2);
319 } else {
320 assert_ne!(cat1, cat2);
321 }
322 }
323 }
324 }
325
326 #[test]
327 fn test_lint_error_conversions() {
328 use std::io;
329
330 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
332 let lint_error: LintError = io_error.into();
333 match lint_error {
334 LintError::IoError(_) => {}
335 _ => panic!("Expected IoError variant"),
336 }
337
338 let invalid_input = LintError::InvalidInput("bad input".to_string());
340 assert_eq!(invalid_input.to_string(), "Invalid input: bad input");
341
342 let fix_failed = LintError::FixFailed("couldn't fix".to_string());
343 assert_eq!(fix_failed.to_string(), "Fix failed: couldn't fix");
344
345 let parsing_error = LintError::ParsingError("parse error".to_string());
346 assert_eq!(parsing_error.to_string(), "Parsing error: parse error");
347 }
348}