1use std::{cell::RefCell, collections::HashMap, fmt::Display, path::PathBuf, rc::Rc};
2use tree_sitter::{Node, Parser};
3use tree_sitter_md::LANGUAGE;
4
5use crate::{
6 config::{QuickmarkConfig, RuleSeverity},
7 rules::{Rule, ALL_RULES},
8 tree_sitter_walker::TreeSitterWalker,
9};
10
11#[derive(Debug, Clone)]
12pub struct CharPosition {
13 pub line: usize,
14 pub character: usize,
15}
16
17#[derive(Debug, Clone)]
18pub struct Range {
19 pub start: CharPosition,
20 pub end: CharPosition,
21}
22#[derive(Debug)]
23pub struct Location {
24 pub file_path: PathBuf,
25 pub range: Range,
26}
27
28#[derive(Debug)]
29pub struct RuleViolation {
30 location: Location,
31 message: String,
32 rule: &'static Rule,
33}
34
35impl RuleViolation {
36 pub fn new(rule: &'static Rule, message: String, file_path: PathBuf, range: Range) -> Self {
37 Self {
38 rule,
39 message,
40 location: Location { file_path, range },
41 }
42 }
43
44 pub fn location(&self) -> &Location {
45 &self.location
46 }
47
48 pub fn message(&self) -> &str {
49 &self.message
50 }
51
52 pub fn rule(&self) -> &'static Rule {
53 self.rule
54 }
55}
56
57pub fn range_from_tree_sitter(ts_range: &tree_sitter::Range) -> Range {
59 Range {
60 start: CharPosition {
61 line: ts_range.start_point.row,
62 character: ts_range.start_point.column,
63 },
64 end: CharPosition {
65 line: ts_range.end_point.row,
66 character: ts_range.end_point.column,
67 },
68 }
69}
70
71impl Display for RuleViolation {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(
74 f,
75 "{}:{}:{} {}/{} {}",
76 self.location().file_path.to_string_lossy(),
77 self.location().range.start.line,
78 self.location().range.start.character,
79 self.rule().id,
80 self.rule().alias,
81 self.message()
82 )
83 }
84}
85
86#[derive(Debug)]
93pub struct Context {
94 pub file_path: PathBuf,
95 pub config: QuickmarkConfig,
96 pub lines: RefCell<Vec<String>>,
98 pub node_cache: RefCell<HashMap<String, Vec<NodeInfo>>>,
100 pub document_content: RefCell<String>,
102}
103
104#[derive(Debug, Clone)]
106pub struct NodeInfo {
107 pub line_start: usize,
108 pub line_end: usize,
109 pub kind: String,
110}
111
112impl Context {
113 pub fn new(
114 file_path: PathBuf,
115 config: QuickmarkConfig,
116 source: &str,
117 root_node: &Node,
118 ) -> Self {
119 let mut lines: Vec<String> = source.lines().map(String::from).collect();
122
123 if source.ends_with('\n') {
125 lines.push(String::new());
126 }
127 let node_cache = Self::build_node_cache(root_node);
128
129 Self {
130 file_path,
131 config,
132 lines: RefCell::new(lines),
133 node_cache: RefCell::new(node_cache),
134 document_content: RefCell::new(source.to_string()),
135 }
136 }
137
138 pub fn get_document_content(&self) -> std::cell::Ref<'_, String> {
141 self.document_content.borrow()
142 }
143
144 fn build_node_cache(root_node: &Node) -> HashMap<String, Vec<NodeInfo>> {
146 let mut cache = HashMap::new();
147 Self::collect_nodes_recursive(root_node, &mut cache);
148 cache
149 }
150
151 fn collect_nodes_recursive(node: &Node, cache: &mut HashMap<String, Vec<NodeInfo>>) {
152 let kind = node.kind();
153 let kind_string = kind.to_string();
154 let node_info = NodeInfo {
155 line_start: node.start_position().row,
156 line_end: node.end_position().row,
157 kind: kind_string.clone(),
158 };
159
160 cache
162 .entry(kind_string)
163 .or_default()
164 .push(node_info.clone());
165
166 if kind.contains("heading") {
168 cache
169 .entry("*heading*".to_string())
170 .or_default()
171 .push(node_info);
172 }
173
174 for i in 0..node.child_count() {
176 if let Some(child) = node.child(i) {
177 Self::collect_nodes_recursive(&child, cache);
178 }
179 }
180 }
181
182 pub fn get_nodes(&self, node_types: &[&str]) -> Vec<NodeInfo> {
184 let cache = self.node_cache.borrow();
185 let mut result = Vec::new();
186 for node_type in node_types {
187 if let Some(nodes) = cache.get(*node_type) {
188 result.extend(nodes.iter().cloned());
189 }
190 }
191 result
192 }
193
194 pub fn get_node_type_for_line(&self, line_number: usize) -> String {
196 let cache = self.node_cache.borrow();
197 let mut best_match: Option<&NodeInfo> = None;
199 let mut smallest_range = usize::MAX;
200
201 for nodes in cache.values() {
202 for node in nodes {
203 if line_number >= node.line_start && line_number <= node.line_end {
204 let range_size = node.line_end - node.line_start;
205 if range_size < smallest_range {
206 smallest_range = range_size;
207 best_match = Some(node);
208 }
209 }
210 }
211 }
212
213 best_match
214 .map(|n| n.kind.clone())
215 .unwrap_or_else(|| "text".to_string())
216 }
217}
218
219pub trait RuleLinter {
250 fn feed(&mut self, node: &Node);
256
257 fn finalize(&mut self) -> Vec<RuleViolation>;
261}
262pub struct MultiRuleLinter {
267 linters: Vec<Box<dyn RuleLinter>>,
268 tree: Option<tree_sitter::Tree>,
269}
270
271impl MultiRuleLinter {
272 pub fn new_for_document(file_path: PathBuf, config: QuickmarkConfig, document: &str) -> Self {
282 let active_rules: Vec<_> = ALL_RULES
284 .iter()
285 .filter(|r| {
286 config
287 .linters
288 .severity
289 .get(r.alias)
290 .map(|severity| *severity != RuleSeverity::Off)
291 .unwrap_or(false)
292 })
293 .collect();
294
295 if active_rules.is_empty() {
297 return Self {
298 linters: Vec::new(),
299 tree: None,
300 };
301 }
302
303 let mut parser = Parser::new();
305 parser
306 .set_language(&LANGUAGE.into())
307 .expect("Error loading Markdown grammar");
308 let tree = parser.parse(document, None).expect("Parse failed");
309
310 let context = Rc::new(Context::new(file_path, config, document, &tree.root_node()));
312
313 let linters = active_rules
315 .iter()
316 .map(|r| ((r.new_linter)(context.clone())))
317 .collect();
318
319 Self {
320 linters,
321 tree: Some(tree),
322 }
323 }
324
325 pub fn analyze(&mut self) -> Vec<RuleViolation> {
330 if self.linters.is_empty() {
332 return Vec::new();
333 }
334
335 let tree = match &self.tree {
337 Some(tree) => tree,
338 None => return Vec::new(),
339 };
340
341 let walker = TreeSitterWalker::new(tree);
342
343 walker.walk(|node| {
345 for linter in &mut self.linters {
346 linter.feed(&node);
347 }
348 });
349
350 let mut violations = Vec::new();
352 for linter in &mut self.linters {
353 let linter_violations = linter.finalize();
354 violations.extend(linter_violations);
355 }
356
357 violations
358 }
359}
360
361#[cfg(test)]
362mod test {
363 use std::{collections::HashMap, path::PathBuf};
364
365 use crate::{
366 config::{self, QuickmarkConfig, RuleSeverity},
367 rules::{md001::MD001, md003::MD003, md013::MD013},
368 };
369
370 use super::MultiRuleLinter;
371
372 #[test]
373 fn test_multiple_violations() {
374 let severity: HashMap<_, _> = vec![
375 (MD001.alias.to_string(), RuleSeverity::Error),
376 (MD003.alias.to_string(), RuleSeverity::Error),
377 (MD013.alias.to_string(), RuleSeverity::Error),
378 ]
379 .into_iter()
380 .collect();
381
382 let config = QuickmarkConfig {
383 linters: config::LintersTable {
384 severity,
385 settings: config::LintersSettingsTable {
386 heading_style: config::MD003HeadingStyleTable {
387 style: config::HeadingStyle::ATX,
388 },
389 ..Default::default()
390 },
391 },
392 };
393
394 let input = "
398# First heading
399Second heading
400==============
401#### Fourth level
402";
403
404 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
405 let violations = linter.analyze();
406 assert_eq!(
407 2,
408 violations.len(),
409 "Should find both MD001 and MD003 violations"
410 );
411 assert_eq!(MD001.id, violations[0].rule().id);
412 assert_eq!(4, violations[0].location().range.start.line);
413 assert_eq!(MD003.id, violations[1].rule().id);
414 assert_eq!(2, violations[1].location().range.start.line);
415 }
416}