use std::{cell::RefCell, collections::HashMap, fmt::Display, path::PathBuf, rc::Rc};
use tree_sitter::{Node, Parser};
use tree_sitter_md::LANGUAGE;
use crate::{
config::{QuickmarkConfig, RuleSeverity},
rules::{Rule, ALL_RULES},
tree_sitter_walker::TreeSitterWalker,
};
#[derive(Debug, Clone)]
pub struct CharPosition {
pub line: usize,
pub character: usize,
}
#[derive(Debug, Clone)]
pub struct Range {
pub start: CharPosition,
pub end: CharPosition,
}
#[derive(Debug)]
pub struct Location {
pub file_path: PathBuf,
pub range: Range,
}
#[derive(Debug)]
pub struct RuleViolation {
location: Location,
message: String,
rule: &'static Rule,
pub(crate) severity: RuleSeverity,
}
impl RuleViolation {
pub fn new(rule: &'static Rule, message: String, file_path: PathBuf, range: Range) -> Self {
Self {
rule,
message,
location: Location { file_path, range },
severity: RuleSeverity::Error, }
}
pub fn location(&self) -> &Location {
&self.location
}
pub fn message(&self) -> &str {
&self.message
}
pub fn rule(&self) -> &'static Rule {
self.rule
}
pub fn severity(&self) -> &RuleSeverity {
&self.severity
}
}
pub fn range_from_tree_sitter(ts_range: &tree_sitter::Range) -> Range {
Range {
start: CharPosition {
line: ts_range.start_point.row,
character: ts_range.start_point.column,
},
end: CharPosition {
line: ts_range.end_point.row,
character: ts_range.end_point.column,
},
}
}
impl Display for RuleViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}:{}:{} {}/{} {}",
self.location().file_path.to_string_lossy(),
self.location().range.start.line,
self.location().range.start.character,
self.rule().id,
self.rule().alias,
self.message()
)
}
}
#[derive(Debug)]
pub struct Context {
pub file_path: PathBuf,
pub config: QuickmarkConfig,
pub lines: RefCell<Vec<String>>,
pub node_cache: RefCell<HashMap<String, Vec<NodeInfo>>>,
pub document_content: RefCell<String>,
}
#[derive(Debug, Clone)]
pub struct NodeInfo {
pub line_start: usize,
pub line_end: usize,
pub kind: String,
}
impl Context {
pub fn new(
file_path: PathBuf,
config: QuickmarkConfig,
source: &str,
root_node: &Node,
) -> Self {
let mut lines: Vec<String> = source.lines().map(String::from).collect();
if source.ends_with('\n') {
lines.push(String::new());
}
let node_cache = Self::build_node_cache(root_node);
Self {
file_path,
config,
lines: RefCell::new(lines),
node_cache: RefCell::new(node_cache),
document_content: RefCell::new(source.to_string()),
}
}
pub fn get_document_content(&self) -> std::cell::Ref<'_, String> {
self.document_content.borrow()
}
fn build_node_cache(root_node: &Node) -> HashMap<String, Vec<NodeInfo>> {
let mut cache = HashMap::new();
Self::collect_nodes_recursive(root_node, &mut cache);
cache
}
fn collect_nodes_recursive(node: &Node, cache: &mut HashMap<String, Vec<NodeInfo>>) {
let kind = node.kind();
let kind_string = kind.to_string();
let node_info = NodeInfo {
line_start: node.start_position().row,
line_end: node.end_position().row,
kind: kind_string.clone(),
};
cache
.entry(kind_string)
.or_default()
.push(node_info.clone());
if kind.contains("heading") {
cache
.entry("*heading*".to_string())
.or_default()
.push(node_info);
}
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
Self::collect_nodes_recursive(&child, cache);
}
}
}
pub fn get_nodes(&self, node_types: &[&str]) -> Vec<NodeInfo> {
let cache = self.node_cache.borrow();
let mut result = Vec::new();
for node_type in node_types {
if let Some(nodes) = cache.get(*node_type) {
result.extend(nodes.iter().cloned());
}
}
result
}
pub fn get_node_type_for_line(&self, line_number: usize) -> String {
let cache = self.node_cache.borrow();
let mut best_match: Option<&NodeInfo> = None;
let mut smallest_range = usize::MAX;
for nodes in cache.values() {
for node in nodes {
if line_number >= node.line_start && line_number <= node.line_end {
let range_size = node.line_end - node.line_start;
if range_size < smallest_range {
smallest_range = range_size;
best_match = Some(node);
}
}
}
}
best_match
.map(|n| n.kind.clone())
.unwrap_or_else(|| "text".to_string())
}
}
pub trait RuleLinter {
fn feed(&mut self, node: &Node);
fn finalize(&mut self) -> Vec<RuleViolation>;
}
pub struct MultiRuleLinter {
linters: Vec<Box<dyn RuleLinter>>,
tree: Option<tree_sitter::Tree>,
config: QuickmarkConfig,
}
impl MultiRuleLinter {
pub fn new_for_document(file_path: PathBuf, config: QuickmarkConfig, document: &str) -> Self {
let active_rules: Vec<_> = ALL_RULES
.iter()
.filter(|r| {
config
.linters
.severity
.get(r.alias)
.map(|severity| *severity != RuleSeverity::Off)
.unwrap_or(false)
})
.collect();
if active_rules.is_empty() {
return Self {
linters: Vec::new(),
tree: None,
config,
};
}
let mut parser = Parser::new();
parser
.set_language(&LANGUAGE.into())
.expect("Error loading Markdown grammar");
let tree = parser.parse(document, None).expect("Parse failed");
let context = Rc::new(Context::new(
file_path,
config.clone(),
document,
&tree.root_node(),
));
let linters = active_rules
.iter()
.map(|r| ((r.new_linter)(context.clone())))
.collect();
Self {
linters,
tree: Some(tree),
config,
}
}
pub fn analyze(&mut self) -> Vec<RuleViolation> {
if self.linters.is_empty() {
return Vec::new();
}
let tree = match &self.tree {
Some(tree) => tree,
None => return Vec::new(),
};
let walker = TreeSitterWalker::new(tree);
walker.walk(|node| {
for linter in &mut self.linters {
linter.feed(&node);
}
});
let mut violations = Vec::new();
for linter in &mut self.linters {
let mut linter_violations = linter.finalize();
for violation in &mut linter_violations {
let severity = self
.config
.linters
.severity
.get(violation.rule().alias)
.cloned()
.unwrap_or(RuleSeverity::Error);
violation.severity = severity;
}
violations.extend(linter_violations);
}
violations
}
}
#[cfg(test)]
mod test {
use std::{collections::HashMap, path::PathBuf};
use crate::{
config::{self, QuickmarkConfig, RuleSeverity},
rules::{md001::MD001, md003::MD003, md013::MD013},
};
use super::MultiRuleLinter;
#[test]
fn test_multiple_violations() {
let severity: HashMap<_, _> = vec![
(MD001.alias.to_string(), RuleSeverity::Error),
(MD003.alias.to_string(), RuleSeverity::Error),
(MD013.alias.to_string(), RuleSeverity::Error),
]
.into_iter()
.collect();
let config = QuickmarkConfig {
linters: config::LintersTable {
severity,
settings: config::LintersSettingsTable {
heading_style: config::MD003HeadingStyleTable {
style: config::HeadingStyle::ATX,
},
..Default::default()
},
},
};
let input = "
# First heading
Second heading
==============
#### Fourth level
";
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(
2,
violations.len(),
"Should find both MD001 and MD003 violations"
);
assert_eq!(MD001.id, violations[0].rule().id);
assert_eq!(4, violations[0].location().range.start.line);
assert_eq!(MD003.id, violations[1].rule().id);
assert_eq!(2, violations[1].location().range.start.line);
}
}