use crate::ast::{Arena, KindData, Meta, NodeRef};
use std::collections::HashSet;
use crate::emoji::EmojiData;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Warning,
#[allow(dead_code)]
Error,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Warning => "warning",
Severity::Error => "error",
}
}
}
pub enum FixOp {
ReplaceLine { line: usize, text: String },
DeleteLine { line: usize },
EnsureFinalNewline,
SetCodeLanguage { line: usize },
}
pub struct Violation {
pub rule: &'static str,
pub name: &'static str,
pub message: String,
pub line: Option<usize>,
pub column: Option<usize>,
pub span: Option<(usize, usize)>,
pub severity: Severity,
pub fix: Option<FixOp>,
}
impl Violation {
fn warn(
rule: &'static str,
name: &'static str,
line: Option<usize>,
column: Option<usize>,
span: Option<(usize, usize)>,
message: impl Into<String>,
) -> Self {
Violation {
rule,
name,
message: message.into(),
line,
column,
span,
severity: Severity::Warning,
fix: None,
}
}
fn warn_fix(
rule: &'static str,
name: &'static str,
line: Option<usize>,
column: Option<usize>,
span: Option<(usize, usize)>,
message: impl Into<String>,
fix: FixOp,
) -> Self {
Violation {
rule,
name,
message: message.into(),
line,
column,
span,
severity: Severity::Warning,
fix: Some(fix),
}
}
}
#[allow(dead_code)]
pub struct Edit {
pub start: usize,
pub end: usize,
pub replacement: String,
}
pub struct FixOutcome {
pub output: String,
pub fixed: Vec<Violation>,
pub unfixable: Vec<Violation>,
pub remaining: Vec<Violation>,
}
#[derive(Debug, Clone)]
pub struct RuleParams {
pub heading_style: String,
pub line_length: usize,
pub line_length_ignore_threshold: usize,
pub spaces_per_tab: usize,
#[allow(dead_code)]
pub siblings_only: bool,
#[allow(dead_code)]
pub default_language: Option<String>,
}
impl Default for RuleParams {
fn default() -> Self {
RuleParams {
heading_style: "consistent".to_string(),
line_length: 80,
line_length_ignore_threshold: 0,
spaces_per_tab: 4,
siblings_only: false,
default_language: None,
}
}
}
#[derive(Debug, Clone)]
pub struct SuppressionDirective {
pub line: usize,
pub rules: Option<Vec<String>>,
pub action: String,
}
#[derive(Debug, Clone, Default)]
pub struct LintConfig {
pub disable: Vec<String>,
pub enable: Option<Vec<String>>,
#[allow(dead_code)]
pub _enabled_when_default_false: Option<Vec<String>>,
pub suppressions: Vec<SuppressionDirective>,
pub params: RuleParams,
}
impl LintConfig {
fn is_enabled(&self, rule: &str) -> bool {
if let Some(enable) = &self.enable {
enable.iter().any(|r| r == rule)
} else {
!self.disable.iter().any(|r| r == rule)
}
}
fn is_suppressed(&self, rule: &str, line: usize) -> bool {
let mut disable_all = false;
let mut disabled: HashSet<String> = HashSet::new();
let mut enabled: HashSet<String> = HashSet::new();
for directive in &self.suppressions {
if directive.line > line {
break;
}
let all = directive.rules.as_deref().map_or(true, |r| r.is_empty());
match directive.action.as_str() {
"disable-next-line" => {
if directive.line + 1 == line
&& (all || directive.rules.as_ref().unwrap().iter().any(|r| r == rule))
{
return true;
}
}
"disable" => {
if all {
disable_all = true;
enabled.clear();
} else {
for r in directive.rules.as_ref().unwrap() {
disabled.insert(r.clone());
enabled.remove(r);
}
}
}
"enable" => {
if all {
disable_all = false;
disabled.clear();
enabled.clear();
} else {
for r in directive.rules.as_ref().unwrap() {
enabled.insert(r.clone());
disabled.remove(r);
}
}
}
_ => {}
}
}
if enabled.contains(rule) {
false
} else if disabled.contains(rule) {
true
} else {
disable_all
}
}
}
struct Source<'s> {
text: &'s str,
lines: Vec<&'s str>,
}
struct HeadingInfo {
level: u8,
line: Option<usize>, text: String,
}
struct LinkInfo {
destination: String,
line: Option<usize>,
}
struct ImageInfo {
alt: String,
line: Option<usize>,
}
struct CodeBlockInfo {
language: Option<String>,
fenced: bool,
line: Option<usize>,
}
struct CodeRegion {
start: usize, end: usize, #[allow(dead_code)]
fenced: bool, }
fn byte_offset_to_line(source: &Source, offset: usize) -> Option<usize> {
let mut pos = 0usize;
for (i, line_str) in source.lines.iter().enumerate() {
let line_end = pos + line_str.len() + 1;
if offset < line_end {
return Some(i);
}
pos = line_end;
}
if pos <= offset {
Some(source.lines.len())
} else {
None
}
}
#[derive(Default)]
struct Collected {
headings: Vec<HeadingInfo>,
links: Vec<LinkInfo>,
images: Vec<ImageInfo>,
code_blocks: Vec<CodeBlockInfo>,
code_regions: Vec<CodeRegion>,
frontmatter_title: Option<String>,
heading_anchors: Vec<String>,
}
fn has_fence_char(s: &str) -> bool {
s.contains("```") || s.contains("~~~")
}
fn collect_text(arena: &Arena, node_ref: NodeRef, source: &str) -> String {
let mut result = String::new();
let mut child = arena[node_ref].first_child();
while let Some(nref) = child {
match &arena[nref].kind_data() {
KindData::Text(t) => result.push_str(t.str(source)),
KindData::CodeSpan(c) => result.push_str(c.str(source).as_ref()),
KindData::RawHtml(r) => result.push_str(r.str(source).as_ref()),
KindData::Extension(ext) => {
if let Some(emoji_data) = (ext.as_ref() as &dyn std::any::Any).downcast_ref::<EmojiData>() {
result.push_str(emoji_data.as_str());
}
}
_ => result.push_str(&collect_text(arena, nref, source)),
}
child = arena[nref].next_sibling();
}
result
}
fn heading_anchor(text: &str) -> String {
let slug: String = text.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() {
c
} else if c == '-' {
'-'
} else {
'-'
}
})
.collect();
slug.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
fn build(arena: &Arena, node_ref: NodeRef, src: &Source, out: &mut Collected) {
match &arena[node_ref].kind_data() {
KindData::Document(doc) => {
let meta = doc.metadata();
if let Some(title_val) = meta.get("title") {
if let Meta::String(title_str) = title_val {
if !title_str.trim().is_empty() {
out.frontmatter_title = Some(title_str.clone());
}
}
}
}
KindData::Heading(h) => {
let line_num = arena[node_ref].pos().and_then(|p| byte_offset_to_line(src, p));
let heading_text = collect_text(arena, node_ref, src.text);
out.headings.push(HeadingInfo {
level: h.level(),
line: line_num,
text: heading_text.clone(),
});
let anchor = heading_anchor(&heading_text);
if !anchor.is_empty() {
out.heading_anchors.push(anchor);
}
}
KindData::Link(l) => out.links.push(LinkInfo {
destination: l.destination_str(src.text).to_string(),
line: arena[node_ref].pos(),
}),
KindData::Image(_) => out.images.push(ImageInfo {
alt: collect_text(arena, node_ref, src.text),
line: arena[node_ref].pos(),
}),
KindData::CodeBlock(cb) => {
let node_pos = arena[node_ref].pos();
let line_num = node_pos.and_then(|p| byte_offset_to_line(src, p));
let fenced = line_num
.and_then(|p| src.lines.get(p))
.map(|l| has_fence_char(l))
.unwrap_or(false);
out.code_blocks.push(CodeBlockInfo {
language: cb.language_str(src.text).map(|s| s.to_string()),
fenced,
line: line_num,
});
if let Some(pos) = line_num {
let content_lines: usize = cb.value().iter(src.text).count();
let (region_start, region_end) = if fenced {
let candidate_end = pos + 1 + content_lines;
if candidate_end < src.lines.len() && has_fence_char(src.lines.get(candidate_end).unwrap_or(&"")) {
(pos, candidate_end)
} else {
let opening = pos.saturating_sub(1).saturating_sub(content_lines);
(opening, pos)
}
} else {
let opening = pos.saturating_sub(content_lines.saturating_sub(1));
(opening, pos)
};
out.code_regions.push(CodeRegion { start: region_start, end: region_end, fenced });
}
}
_ => {}
}
let mut child = arena[node_ref].first_child();
while let Some(c) = child {
build(arena, c, src, out);
child = arena[c].next_sibling();
}
}
pub fn parse_suppressions(source: &str) -> Vec<SuppressionDirective> {
let mut directives: Vec<SuppressionDirective> = Vec::new();
let lines: Vec<&str> = source.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with("<!--") || !trimmed.contains("markdownlint-") {
continue;
}
let content = if let Some(start) = trimmed.find("markdownlint-") {
let end = trimmed.find("-->").unwrap_or(trimmed.len());
trimmed[start + "markdownlint-".len()..end].trim() } else {
continue;
};
let parts: Vec<&str> = content.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let action = parts[0].to_string();
let rules: Option<Vec<String>> = if parts.len() > 1 {
Some(parts[1..].iter().map(|s| s.to_string()).collect())
} else {
None };
directives.push(SuppressionDirective {
line: i,
rules,
action,
});
}
directives
}
fn code_mask(regions: &[CodeRegion], n_lines: usize) -> Vec<bool> {
let mut mask = vec![false; n_lines];
for r in regions {
for i in r.start..=r.end.min(n_lines.saturating_sub(1)) {
mask[i] = true;
}
}
mask
}
fn md001(m: &Collected, out: &mut Vec<Violation>) {
let mut prev: Option<u8> = None;
for h in &m.headings {
if let Some(p) = prev {
if h.level > p + 1 {
out.push(Violation::warn(
"MD001",
"heading-increment",
h.line.map(|l| l + 1),
None, h.line.map(|l| (l, l + 1)), format!(
"Heading level jumps from h{} to h{} (expected h{} next)",
p,
h.level,
p + 1
),
));
}
}
prev = Some(h.level);
}
}
fn md024(m: &Collected, out: &mut Vec<Violation>) {
let mut seen: HashSet<String> = HashSet::new();
for h in &m.headings {
let key = h.text.trim().to_string();
if key.is_empty() {
continue;
}
if !seen.insert(key) {
out.push(Violation::warn(
"MD024",
"no-duplicate-heading",
h.line.map(|l| l + 1),
None, h.line.map(|l| (l, l + 1)), format!("Duplicate heading content: \"{}\"", h.text.trim()),
));
}
}
}
fn md025(m: &Collected, out: &mut Vec<Violation>) {
let mut count = 0;
for h in &m.headings {
if h.level == 1 {
count += 1;
if count > 1 {
out.push(Violation::warn(
"MD025",
"single-h1",
h.line.map(|l| l + 1),
None, h.line.map(|l| (l, l + 1)), "Multiple top-level (h1) headings in the same document",
));
}
}
}
}
fn md040(m: &Collected, out: &mut Vec<Violation>) {
for cb in &m.code_blocks {
if !cb.fenced {
continue;
}
let missing = cb
.language
.as_deref()
.map(|l| l.trim().is_empty())
.unwrap_or(true);
if missing {
let v = match cb.line {
Some(l0) => Violation::warn_fix(
"MD040",
"fenced-code-language",
Some(l0 + 1),
None, Some((l0, l0 + 1)), "Fenced code block should specify a language",
FixOp::SetCodeLanguage { line: l0 },
),
None => Violation::warn(
"MD040",
"fenced-code-language",
None,
None, None, "Fenced code block should specify a language",
),
};
out.push(v);
}
}
}
fn md042(m: &Collected, out: &mut Vec<Violation>) {
for l in &m.links {
let dest = l.destination.trim();
if dest.is_empty() || dest == "#" {
out.push(Violation::warn(
"MD042",
"no-empty-links",
l.line.map(|x| x + 1),
None, l.line.map(|x| (x, x + 1)), "Link has an empty destination",
));
continue;
}
if let Some(fragment) = dest.strip_prefix('#') {
if !fragment.is_empty() && !m.heading_anchors.contains(&fragment.to_string()) {
out.push(Violation::warn(
"MD042",
"no-empty-links",
l.line.map(|x| x + 1),
None, l.line.map(|x| (x, x + 1)), format!("Link references unknown anchor: \"{}\"", fragment),
));
}
}
}
}
fn md045(m: &Collected, out: &mut Vec<Violation>) {
for img in &m.images {
if img.alt.trim().is_empty() {
out.push(Violation::warn(
"MD045",
"no-alt-text",
img.line.map(|l| l + 1),
None, img.line.map(|l| (l, l + 1)), "Image should have alternate text",
));
}
}
}
fn md009(src: &Source, mask: &[bool], out: &mut Vec<Violation>) {
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
let trimmed = line.trim_end();
if line.len() == trimmed.len() {
continue; }
let trailing = &line[trimmed.len()..];
let is_hard_break = !trimmed.is_empty() && trailing == " ";
if is_hard_break {
continue;
}
let col = trimmed.len() + 1; out.push(Violation::warn_fix(
"MD009",
"no-trailing-spaces",
Some(i + 1),
Some(col), Some((i, i + 1)), "Line has trailing whitespace",
FixOp::ReplaceLine {
line: i,
text: trimmed.to_string(),
},
));
}
}
fn md012(src: &Source, mask: &[bool], out: &mut Vec<Violation>) {
let mut blank_run = 0usize;
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
blank_run = 0;
continue;
}
if line.trim().is_empty() {
blank_run += 1;
if blank_run > 1 {
out.push(Violation::warn_fix(
"MD012",
"no-multiple-blanks",
Some(i + 1),
None, Some((i, i + 1)), "Multiple consecutive blank lines",
FixOp::DeleteLine { line: i },
));
}
} else {
blank_run = 0;
}
}
}
fn md047(src: &Source, out: &mut Vec<Violation>) {
if !src.text.is_empty() && !src.text.ends_with('\n') {
let last_line = src.lines.last().map(|s| s.len() + 1).unwrap_or(1);
out.push(Violation::warn_fix(
"MD047",
"single-trailing-newline",
Some(src.lines.len().max(1)),
Some(last_line), Some((src.text.len(), src.text.len())), "File should end with a single newline character",
FixOp::EnsureFinalNewline,
));
}
}
fn md010(src: &Source, mask: &[bool], params: &RuleParams, out: &mut Vec<Violation>) {
let spaces = " ".repeat(params.spaces_per_tab);
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
if !line.contains('\t') {
continue;
}
let fixed = line.replace('\t', &spaces);
out.push(Violation::warn_fix(
"MD010",
"no-hard-tabs",
Some(i + 1),
Some(line.find('\t').unwrap_or(0) + 1), Some((i, i + 1)),
"Hard tab character(s) found",
FixOp::ReplaceLine { line: i, text: fixed },
));
}
}
fn md018(src: &Source, mask: &[bool], out: &mut Vec<Violation>) {
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
continue;
}
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
continue;
}
if hash_count < trimmed.len() && trimmed.as_bytes()[hash_count] != b' ' {
out.push(Violation::warn(
"MD018",
"atx-closing-spaces",
Some(i + 1),
Some(hash_count + 1),
Some((i, i + 1)),
"ATX heading should have a space after the opening `#` characters",
));
}
}
}
fn md019(src: &Source, mask: &[bool], out: &mut Vec<Violation>) {
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
continue;
}
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
continue;
}
let rest = &trimmed[hash_count..];
if rest.trim().is_empty() {
continue; }
if rest.as_bytes()[0] != b' ' {
out.push(Violation::warn(
"MD019",
"atx-spacing",
Some(i + 1),
Some(hash_count + 1),
Some((i, i + 1)),
"ATX heading should have a space after the opening `#` characters",
));
}
}
}
fn md020(src: &Source, mask: &[bool], out: &mut Vec<Violation>) {
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
continue;
}
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
continue;
}
let rest = &trimmed[hash_count..].trim();
if !rest.ends_with('#') {
continue;
}
let without_closing = rest[..rest.len() - 1].trim_end();
if !without_closing.is_empty() && rest.as_bytes()[rest.len() - 2] != b' ' {
out.push(Violation::warn(
"MD020",
"atx-closing-spaces",
Some(i + 1),
None,
Some((i, i + 1)),
"ATX heading should have a space before the closing `#` characters",
));
}
}
}
fn md021(src: &Source, mask: &[bool], out: &mut Vec<Violation>) {
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
continue;
}
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
continue;
}
let rest = trimmed[hash_count..].trim();
if rest.ends_with('#') {
let without_closing = rest[..rest.len() - 1].trim_end();
if !without_closing.is_empty() {
let last_char = without_closing.chars().last();
if last_char == Some(' ') {
out.push(Violation::warn(
"MD021",
"atx-heading-space",
Some(i + 1),
None,
Some((i, i + 1)),
"Multiple spaces inside ATX heading",
));
}
}
}
}
}
fn md022(collected: &Collected, src: &Source, out: &mut Vec<Violation>) {
let n_lines = src.lines.len();
for h in &collected.headings {
let h_line = h.line.unwrap_or(0); if h_line > 0 {
let prev_line = h_line - 1;
if !src.lines[prev_line].trim().is_empty() {
out.push(Violation::warn(
"MD022",
"heading-blank-lines",
Some(h_line + 1),
None,
Some((prev_line, prev_line + 1)),
"Heading should be preceded by a blank line",
));
}
}
let next_line = h_line + 1;
if next_line < n_lines {
if !src.lines[next_line].trim().is_empty() {
out.push(Violation::warn(
"MD022",
"heading-blank-lines",
Some(h_line + 1),
None,
Some((h_line, h_line + 1)),
"Heading should be followed by a blank line",
));
}
}
}
}
fn md026(collected: &Collected, src: &Source, out: &mut Vec<Violation>) {
for h in &collected.headings {
let text = h.text.trim();
if text.is_empty() {
continue;
}
let last_char = text.chars().last().unwrap_or('\0');
if matches!(last_char, '.' | '!' | '?') {
let line_num = h.line.unwrap_or(0); if let Some(orig_line) = src.lines.get(line_num) {
let fixed_line = orig_line.trim_end_matches(last_char);
out.push(Violation::warn_fix(
"MD026",
"no-trailing-punctuation",
Some(line_num + 1),
None,
Some((line_num, line_num + 1)),
format!("Heading should not end with trailing punctuation ({last_char})"),
FixOp::ReplaceLine {
line: line_num,
text: fixed_line.to_string(),
},
));
}
}
}
}
fn md031(collected: &Collected, src: &Source, out: &mut Vec<Violation>) {
let lines = src.lines.len();
for cb in collected.code_blocks.iter().filter(|cb| cb.fenced) {
let cb_line = cb.line.unwrap_or(0); if cb_line > 0 {
let prev_line = cb_line - 1;
if !src.lines[prev_line].trim().is_empty() {
out.push(Violation::warn(
"MD031",
"fenced-code-blocks-working",
Some(cb_line + 1),
None,
Some((prev_line, prev_line + 1)),
"Fenced code block should be preceded by a blank line",
));
}
}
if cb_line + 1 < lines {
let next_line_idx = cb_line + 1;
if next_line_idx < lines && !src.lines[next_line_idx].trim().is_empty() {
let mut after_closing = false;
if collected.code_regions.len() > 0 {
for region in &collected.code_regions {
if region.start == cb_line && next_line_idx == region.end + 1 {
after_closing = true;
break;
}
}
}
if after_closing && !src.lines[next_line_idx].trim().is_empty() {
out.push(Violation::warn(
"MD031",
"fenced-code-blocks-working",
Some(next_line_idx + 1),
None,
Some((next_line_idx, next_line_idx + 1)),
"Fenced code block should be followed by a blank line",
));
}
}
}
}
}
fn md032(collected: &Collected, src: &Source, out: &mut Vec<Violation>) {
let lines = src.lines.len();
for cb in collected.code_blocks.iter().filter(|cb| !cb.fenced) {
let cb_line = cb.line.unwrap_or(0); if cb_line > 0 {
let prev_line = cb_line - 1;
if !src.lines[prev_line].trim().is_empty() {
out.push(Violation::warn(
"MD032",
"indented-code-block",
Some(cb_line + 1),
None,
Some((prev_line, prev_line + 1)),
"Indented code block should be preceded by a blank line",
));
}
}
if cb_line + 1 < lines {
let next_line_idx = cb_line + 1;
if next_line_idx < lines && !src.lines[next_line_idx].trim().is_empty() {
out.push(Violation::warn(
"MD032",
"indented-code-block",
Some(next_line_idx + 1),
None,
Some((next_line_idx, next_line_idx + 1)),
"Indented code block should be followed by a blank line",
));
}
}
}
}
fn md034(collected: &Collected, _out: &mut Vec<Violation>) {
let _ = collected;
}
fn md003(_collected: &Collected, _params: &RuleParams, _out: &mut Vec<Violation>) {
}
fn md013(src: &Source, mask: &[bool], params: &RuleParams, out: &mut Vec<Violation>) {
let limit = params.line_length;
let threshold = params.line_length_ignore_threshold;
for (i, line) in src.lines.iter().enumerate() {
if mask.get(i).copied().unwrap_or(false) {
continue;
}
if line.len() <= threshold {
continue;
}
if line.len() > limit {
out.push(Violation::warn(
"MD013",
"line-length",
Some(i + 1),
None,
Some((i, i + 1)),
format!("Line is {} characters long (max {})", line.len(), limit),
));
}
}
}
fn md046(collected: &Collected, src: &Source, out: &mut Vec<Violation>) {
for cb in &collected.code_blocks {
if !cb.fenced {
continue; }
let cb_line = cb.line.unwrap_or(0);
if let Some(line) = src.lines.get(cb_line) {
let leading_spaces = line.chars().take_while(|&c| c == ' ').count();
if leading_spaces > 0 && leading_spaces < 4 {
out.push(Violation::warn(
"MD046",
"code-block-indentation",
Some(cb_line + 1),
None,
Some((cb_line, cb_line + 1)),
"Fenced code block should use 4-space indentation or no indentation",
));
}
}
}
}
fn md048(collected: &Collected, src: &Source, out: &mut Vec<Violation>) {
for cb in &collected.code_blocks {
if !cb.fenced {
continue;
}
let cb_line = cb.line.unwrap_or(0);
if let Some(line) = src.lines.get(cb_line) {
let trimmed = line.trim_start();
if trimmed.starts_with("~~~") {
out.push(Violation::warn(
"MD048",
"fenced-code-block-punctuation",
Some(cb_line + 1),
None,
Some((cb_line, cb_line + 1)),
"Fenced code block should use backticks, not tildes",
));
}
}
}
}
fn md049(_collected: &Collected, _out: &mut Vec<Violation>) {
}
fn md050(_collected: &Collected, _out: &mut Vec<Violation>) {
}
pub fn run_lint(source: &str, arena: &Arena, root: NodeRef, cfg: &LintConfig) -> Vec<Violation> {
run_lint_with_params(source, arena, root, cfg, &cfg.params)
}
pub fn run_lint_with_params(
source: &str,
arena: &Arena,
root: NodeRef,
cfg: &LintConfig,
params: &RuleParams,
) -> Vec<Violation> {
let src = Source {
text: source,
lines: source.lines().collect(),
};
let mut collected = Collected::default();
build(arena, root, &src, &mut collected);
let mask = code_mask(&collected.code_regions, src.lines.len());
let mut out: Vec<Violation> = Vec::new();
md001(&collected, &mut out);
md024(&collected, &mut out);
md025(&collected, &mut out);
md040(&collected, &mut out);
md042(&collected, &mut out);
md045(&collected, &mut out);
md018(&src, &mask, &mut out);
md019(&src, &mask, &mut out);
md020(&src, &mask, &mut out);
md021(&src, &mask, &mut out);
md022(&collected, &src, &mut out);
md026(&collected, &src, &mut out);
md031(&collected, &src, &mut out);
md032(&collected, &src, &mut out);
md034(&collected, &mut out);
md003(&collected, params, &mut out);
md049(&collected, &mut out);
md050(&collected, &mut out);
md009(&src, &mask, &mut out);
md012(&src, &mask, &mut out);
md047(&src, &mut out);
md010(&src, &mask, params, &mut out);
md013(&src, &mask, params, &mut out);
md046(&collected, &src, &mut out);
md048(&collected, &src, &mut out);
out.retain(|v| {
let line_0indexed = v.line.map(|l| l.saturating_sub(1));
cfg.is_enabled(v.rule) && (!line_0indexed.map(|l| cfg.is_suppressed(v.rule, l)).unwrap_or(false))
});
out.sort_by(|a, b| {
a.line
.unwrap_or(usize::MAX)
.cmp(&b.line.unwrap_or(usize::MAX))
.then_with(|| a.rule.cmp(b.rule))
});
out
}
fn set_fence_language(orig: &str, lang: &str) -> String {
let trimmed = orig.trim_start();
let indent = &orig[..orig.len() - trimmed.len()];
let fence_char = trimmed.chars().next().unwrap_or('`');
let fence_len = trimmed.chars().take_while(|&c| c == fence_char).count();
let fence: String = std::iter::repeat(fence_char).take(fence_len).collect();
format!("{indent}{fence}{lang}")
}
fn apply_fixes(source: &str, fixes: &[Violation], default_language: Option<&str>) -> String {
let lines: Vec<&str> = source.lines().collect();
let mut replace: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut delete: HashSet<usize> = HashSet::new();
let mut ensure_nl = false;
for v in fixes {
match &v.fix {
Some(FixOp::ReplaceLine { line, text }) => {
replace.insert(*line, text.clone());
}
Some(FixOp::DeleteLine { line }) => {
delete.insert(*line);
}
Some(FixOp::EnsureFinalNewline) => {
ensure_nl = true;
}
Some(FixOp::SetCodeLanguage { line }) => {
if let Some(lang) = default_language {
if let Some(orig) = lines.get(*line) {
replace.insert(*line, set_fence_language(orig, lang));
}
}
}
None => {}
}
}
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
if delete.contains(&i) {
continue; }
match replace.get(&i) {
Some(t) => out.push(t.clone()),
None => out.push((*line).to_string()),
}
}
let mut result = out.join("\n");
if source.ends_with('\n') || ensure_nl {
result.push('\n');
}
result
}
#[derive(Debug, Clone)]
pub struct RuleSpec {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub fixable: bool,
pub default_params: &'static str,
}
pub fn lint_rules() -> Vec<RuleSpec> {
vec![
RuleSpec { id: "MD001", name: "heading-increment", description: "Heading levels should increment by one at a time", fixable: false, default_params: "{}" },
RuleSpec { id: "MD003", name: "heading-style", description: "Heading style consistency", fixable: false, default_params: "{\"heading_style\": \"consistent\"}" },
RuleSpec { id: "MD009", name: "no-trailing-spaces", description: "Lines should not have trailing spaces", fixable: true, default_params: "{}" },
RuleSpec { id: "MD010", name: "no-hard-tabs", description: "Lines should not contain hard tabs", fixable: true, default_params: "{\"spaces_per_tab\": 4}" },
RuleSpec { id: "MD012", name: "no-multiple-blanks", description: "There should be no more than one consecutive blank line", fixable: true, default_params: "{}" },
RuleSpec { id: "MD013", name: "line-length", description: "Lines should not exceed a specified number of characters", fixable: false, default_params: "{\"line_length\": 80, \"line_length_ignore_threshold\": 0}" },
RuleSpec { id: "MD018", name: "atx-spacing", description: "ATX headings should have a space after the opening '#'", fixable: false, default_params: "{}" },
RuleSpec { id: "MD019", name: "atx-closing-spaces", description: "ATX leaf headings should not have closing '#'", fixable: false, default_params: "{}" },
RuleSpec { id: "MD020", name: "atx-closing-spaces", description: "ATX headings should have a space before the closing '#'", fixable: false, default_params: "{}" },
RuleSpec { id: "MD021", name: "atx-heading-space", description: "Multiple spaces inside ATX heading", fixable: false, default_params: "{}" },
RuleSpec { id: "MD022", name: "heading-blank-lines", description: "Headings should have blank lines around them", fixable: true, default_params: "{}" },
RuleSpec { id: "MD024", name: "no-duplicate-heading", description: "Multiple headings with the same content", fixable: false, default_params: "{\"siblings_only\": false}" },
RuleSpec { id: "MD025", name: "single-h1", description: "Document should have only one h1 heading", fixable: false, default_params: "{}" },
RuleSpec { id: "MD026", name: "no-trailing-punctuation", description: "Headings should not end with trailing punctuation", fixable: true, default_params: "{}" },
RuleSpec { id: "MD031", name: "fenced-code-blocks-working", description: "Fenced code blocks should have blank lines around them", fixable: true, default_params: "{}" },
RuleSpec { id: "MD032", name: "indented-code-block", description: "Indented code blocks should have blank lines around them", fixable: false, default_params: "{}" },
RuleSpec { id: "MD034", name: "no-bare-urls", description: "Bare URLs should be in angle brackets", fixable: false, default_params: "{}" },
RuleSpec { id: "MD040", name: "fenced-code-language", description: "Fenced code blocks should specify a language", fixable: true, default_params: "{\"default_language\": null}" },
RuleSpec { id: "MD042", name: "no-empty-links", description: "Links should have a non-empty destination", fixable: false, default_params: "{}" },
RuleSpec { id: "MD045", name: "no-alt-text", description: "Images should have alternate text", fixable: false, default_params: "{}" },
RuleSpec { id: "MD046", name: "code-block-indentation", description: "Fenced code blocks should use 4-space indentation", fixable: false, default_params: "{}" },
RuleSpec { id: "MD047", name: "single-trailing-newline", description: "Files should end with a single trailing newline", fixable: true, default_params: "{}" },
RuleSpec { id: "MD048", name: "fenced-code-block-punctuation", description: "Fenced code blocks should use backticks, not tildes", fixable: false, default_params: "{}" },
RuleSpec { id: "MD049", name: "emphasis-style", description: "Emphasis style consistency", fixable: false, default_params: "{}" },
RuleSpec { id: "MD050", name: "strong-style", description: "Strong style consistency", fixable: false, default_params: "{}" },
]
}
#[cfg(test)]
mod tests {
use super::*;
fn src(s: &str) -> Source<'_> {
Source {
text: s,
lines: s.lines().collect(),
}
}
#[test]
fn set_fence_language_basic() {
assert_eq!(set_fence_language("```", "py"), "```py");
assert_eq!(set_fence_language("~~~", "js"), "~~~js");
}
#[test]
fn set_fence_language_preserves_indentation() {
assert_eq!(set_fence_language(" ```", "python"), " ```python");
}
#[test]
fn set_fence_language_preserves_tilde_fence() {
assert_eq!(set_fence_language("~~~~", "text"), "~~~~text");
}
#[test]
fn md009_detects_trailing_spaces() {
let s = src("hello \n");
let mask: Vec<bool> = Vec::new(); let mut v: Vec<Violation> = Vec::new();
md009(&s, &mask, &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule, "MD009");
assert!(v[0].fix.is_some());
}
#[test]
fn md009_ignores_hard_line_break() {
let s = src("line one \n");
let mask: Vec<bool> = Vec::new();
let mut v: Vec<Violation> = Vec::new();
md009(&s, &mask, &mut v);
assert!(v.is_empty());
}
#[test]
fn md009_ignores_code_region() {
let s = src("```\ncode \n```\n");
let mask = vec![true, true, true]; let mut v: Vec<Violation> = Vec::new();
md009(&s, &mask, &mut v);
assert!(v.is_empty());
}
#[test]
fn md009_ignores_clean_lines() {
let s = src("clean line\n");
let mask: Vec<bool> = Vec::new();
let mut v: Vec<Violation> = Vec::new();
md009(&s, &mask, &mut v);
assert!(v.is_empty());
}
#[test]
fn md009_multiple_violations() {
let s = src("a \nb \n");
let mask: Vec<bool> = Vec::new();
let mut v: Vec<Violation> = Vec::new();
md009(&s, &mask, &mut v);
assert_eq!(v.len(), 2);
}
#[test]
fn md012_detects_extra_blank() {
let s = src("a\n\n\nb\n");
let mask: Vec<bool> = Vec::new();
let mut v: Vec<Violation> = Vec::new();
md012(&s, &mask, &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule, "MD012");
}
#[test]
fn md012_allows_single_blank() {
let s = src("a\n\nb\n");
let mask: Vec<bool> = Vec::new();
let mut v: Vec<Violation> = Vec::new();
md012(&s, &mask, &mut v);
assert!(v.is_empty());
}
#[test]
fn md012_skips_code_regions() {
let s = src("```\n\n\n```\n");
let mask = vec![true, true, true, true];
let mut v: Vec<Violation> = Vec::new();
md012(&s, &mask, &mut v);
assert!(v.is_empty());
}
#[test]
fn md047_detects_missing_newline() {
let s = src("no newline");
let mut v: Vec<Violation> = Vec::new();
md047(&s, &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].rule, "MD047");
}
#[test]
fn md047_allows_existing_newline() {
let s = src("has newline\n");
let mut v: Vec<Violation> = Vec::new();
md047(&s, &mut v);
assert!(v.is_empty());
}
#[test]
fn md047_empty_document_ok() {
let s = src("");
let mut v: Vec<Violation> = Vec::new();
md047(&s, &mut v);
assert!(v.is_empty());
}
#[test]
fn apply_fixes_strips_trailing_whitespace() {
let fixes = vec![Violation::warn_fix(
"MD009", "no-trailing-spaces", Some(1), None, None, "",
FixOp::ReplaceLine { line: 0, text: "hello".to_string() },
)];
let result = apply_fixes("hello \n", &fixes, None);
assert_eq!(result, "hello\n");
}
#[test]
fn apply_fixes_deletes_blank_lines() {
let fixes = vec![Violation::warn_fix(
"MD012", "no-multiple-blanks", Some(3), None, None, "",
FixOp::DeleteLine { line: 2 },
)];
let result = apply_fixes("a\n\n\nb\n", &fixes, None);
assert_eq!(result, "a\n\nb\n");
}
#[test]
fn apply_fixes_adds_final_newline() {
let fixes = vec![Violation::warn_fix(
"MD047", "single-trailing-newline", Some(1), None, None, "",
FixOp::EnsureFinalNewline,
)];
let result = apply_fixes("no newline", &fixes, None);
assert_eq!(result, "no newline\n");
}
#[test]
fn apply_fixes_deletion_wins_over_replacement() {
let fixes = vec![
Violation::warn_fix("MD009", "x", Some(1), None, None, "", FixOp::ReplaceLine { line: 1, text: "replaced".to_string() }),
Violation::warn_fix("MD012", "x", Some(2), None, None, "", FixOp::DeleteLine { line: 1 }),
];
let result = apply_fixes("a\n \nb\n", &fixes, None);
assert_eq!(result, "a\nb\n");
}
#[test]
fn apply_fixes_multiple_on_same_line() {
let fixes = vec![
Violation::warn_fix("MD009", "x", Some(1), None, None, "", FixOp::ReplaceLine { line: 0, text: "first".to_string() }),
Violation::warn_fix("MD009", "x", Some(1), None, None, "", FixOp::ReplaceLine { line: 0, text: "second".to_string() }),
];
let result = apply_fixes("hello \n", &fixes, None);
assert_eq!(result, "second\n");
}
#[test]
fn code_mask_marks_single_fenced_block() {
let regions = vec![CodeRegion { start: 0, end: 2, fenced: true }];
let mask = code_mask(®ions, 3);
assert_eq!(mask, vec![true, true, true]);
}
#[test]
fn code_mask_marks_multiple_regions() {
let regions = vec![
CodeRegion { start: 0, end: 0, fenced: true }, CodeRegion { start: 3, end: 4, fenced: true }, ];
let mask = code_mask(®ions, 5);
assert_eq!(mask, vec![true, false, false, true, true]);
}
#[test]
fn code_mask_skips_non_code_lines() {
let regions = vec![CodeRegion { start: 1, end: 2, fenced: true }];
let mask = code_mask(®ions, 5);
assert_eq!(mask, vec![false, true, true, false, false]);
}
#[test]
fn code_mask_clamps_to_line_count() {
let regions = vec![CodeRegion { start: 3, end: 10, fenced: true }];
let mask = code_mask(®ions, 5);
assert_eq!(mask, vec![false, false, false, true, true]);
}
#[test]
fn code_mask_empty() {
let mask = code_mask(&[], 3);
assert_eq!(mask, vec![false, false, false]);
}
#[test]
fn fix_idempotent_trailing_space() {
let fixes = vec![Violation::warn_fix(
"MD009", "x", Some(1), None, None, "", FixOp::ReplaceLine { line: 0, text: "hello".to_string() })];
let once = apply_fixes("hello \n", &fixes, None);
let twice = apply_fixes(&once, &[], None);
assert_eq!(once, twice);
}
#[test]
fn fix_idempotent_blank_lines() {
let fixes = vec![Violation::warn_fix(
"MD012", "x", Some(3), None, None, "", FixOp::DeleteLine { line: 2 })];
let once = apply_fixes("a\n\n\nb\n", &fixes, None);
let twice = apply_fixes(&once, &[], None);
assert_eq!(once, twice);
}
#[test]
fn fix_idempotent_final_newline() {
let fixes = vec![Violation::warn_fix(
"MD047", "x", Some(1), None, None, "", FixOp::EnsureFinalNewline)];
let once = apply_fixes("no newline", &fixes, None);
let twice = apply_fixes(&once, &[], None);
assert_eq!(once, twice);
}
#[test]
fn md009_fix_preserves_rendered_output() {
let src = "hello \n";
let fixes = vec![Violation::warn_fix(
"MD009", "x", Some(1), None, None, "", FixOp::ReplaceLine { line: 0, text: "hello".to_string() })];
let fixed = apply_fixes(src, &fixes, None);
assert_eq!(fixed, "hello\n");
}
}
pub fn run_fix(
source: &str,
arena: &Arena,
root: NodeRef,
cfg: &LintConfig,
default_language: Option<&str>,
) -> FixOutcome {
run_fix_with_params(source, arena, root, cfg, &RuleParams::default(), default_language)
}
pub fn run_fix_with_params(
source: &str,
arena: &Arena,
root: NodeRef,
cfg: &LintConfig,
params: &RuleParams,
default_language: Option<&str>,
) -> FixOutcome {
let max_iterations = 10;
let mut current_source = source.to_string();
let mut all_fixed: Vec<Violation> = Vec::new();
let mut all_unfixable: Vec<Violation> = Vec::new();
for _iteration in 0..max_iterations {
let violations = run_lint_with_params(¤t_source, arena, root, cfg, params);
let mut fixed: Vec<Violation> = Vec::new();
let mut unfixable: Vec<Violation> = Vec::new();
for v in violations {
let applicable = match &v.fix {
Some(FixOp::SetCodeLanguage { .. }) => default_language.is_some(),
Some(_) => true,
None => false,
};
if applicable {
fixed.push(v);
} else {
unfixable.push(v);
}
}
let no_more_fixable = fixed.is_empty();
all_fixed.extend(fixed);
all_unfixable.extend(unfixable);
if no_more_fixable {
break;
}
current_source = apply_fixes(¤t_source, &all_fixed, default_language);
}
let remaining = run_lint_with_params(¤t_source, arena, root, cfg, params);
FixOutcome {
output: current_source,
fixed: all_fixed,
unfixable: all_unfixable,
remaining,
}
}