use serde::Deserialize;
use std::rc::Rc;
use tree_sitter::Node;
use crate::{
linter::{range_from_tree_sitter, RuleViolation},
rules::{Context, Rule, RuleLinter, RuleType},
};
#[derive(Debug, PartialEq, Clone, Deserialize)]
pub struct MD013LineLengthTable {
#[serde(default)]
pub line_length: usize,
#[serde(default)]
pub code_block_line_length: usize,
#[serde(default)]
pub heading_line_length: usize,
#[serde(default)]
pub code_blocks: bool,
#[serde(default)]
pub headings: bool,
#[serde(default)]
pub tables: bool,
#[serde(default)]
pub strict: bool,
#[serde(default)]
pub stern: bool,
}
impl Default for MD013LineLengthTable {
fn default() -> Self {
Self {
line_length: 80,
code_block_line_length: 80,
heading_line_length: 80,
code_blocks: true,
headings: true,
tables: true,
strict: false,
stern: false,
}
}
}
pub(crate) struct MD013Linter {
context: Rc<Context>,
violations: Vec<RuleViolation>,
}
impl MD013Linter {
pub fn new(context: Rc<Context>) -> Self {
Self {
context,
violations: Vec::new(),
}
}
fn analyze_all_lines(&mut self) {
let lines = self.context.lines.borrow();
for (line_index, line) in lines.iter().enumerate() {
let node_kind = self.context.get_node_type_for_line(line_index);
let should_check = self.should_check_node_type(&node_kind);
let should_violate = if should_check {
self.should_violate_line(line, line_index, &node_kind)
} else {
false
};
if should_violate {
let violation = self.create_violation_for_line(line, line_index, &node_kind);
self.violations.push(violation);
}
}
}
fn is_link_reference_definition(&self, line: &str) -> bool {
line.trim_start().starts_with('[') && line.contains("]:") && line.contains("http")
}
fn is_standalone_link_or_image(&self, line: &str) -> bool {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.contains("](") && trimmed.ends_with(')') {
return true;
}
if trimmed.starts_with(" && trimmed.ends_with(')') {
return true;
}
false
}
fn has_no_spaces_beyond_limit(&self, line: &str, limit: usize) -> bool {
if line.len() <= limit {
return false;
}
let mut char_boundary = limit;
while char_boundary < line.len() && !line.is_char_boundary(char_boundary) {
char_boundary += 1;
}
if char_boundary >= line.len() {
return true; }
let beyond_limit = &line[char_boundary..];
!beyond_limit.contains(' ')
}
fn should_check_node_type(&self, node_kind: &str) -> bool {
let settings = &self.context.config.linters.settings.line_length;
match node_kind {
s if s.starts_with("atx_h") && s.ends_with("_marker") => settings.headings,
s if s.starts_with("setext_h") && s.ends_with("_underline") => settings.headings,
"atx_heading" | "setext_heading" => settings.headings,
"fenced_code_block" | "indented_code_block" | "code_fence_content" => {
settings.code_blocks
}
"table" | "table_row" => settings.tables,
_ => true, }
}
fn is_heading_line(&self, line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with('#') && (trimmed.len() > 1 && trimmed.chars().nth(1) == Some(' '))
}
fn get_line_limit(&self, node_kind: &str) -> usize {
let settings = &self.context.config.linters.settings.line_length;
match node_kind {
s if s.starts_with("atx_h") && s.ends_with("_marker") => settings.heading_line_length,
s if s.starts_with("setext_h") && s.ends_with("_underline") => {
settings.heading_line_length
}
"atx_heading" | "setext_heading" => settings.heading_line_length,
"fenced_code_block" | "indented_code_block" | "code_fence_content" => {
settings.code_block_line_length
}
_ => settings.line_length,
}
}
fn should_violate_line(&self, line: &str, _line_number: usize, node_kind: &str) -> bool {
let settings = &self.context.config.linters.settings.line_length;
if self.is_heading_line(line) && !settings.headings {
return false;
}
if !self.should_check_node_type(node_kind) {
return false;
}
let limit = self.get_line_limit(node_kind);
if line.len() <= limit {
return false;
}
if self.is_link_reference_definition(line) {
return false;
}
if self.is_standalone_link_or_image(line) {
return false;
}
if settings.strict {
return true;
}
if settings.stern {
if self.has_no_spaces_beyond_limit(line, limit) {
return false;
}
return true;
}
if self.has_no_spaces_beyond_limit(line, limit) {
return false;
}
true
}
fn create_violation_for_line(
&self,
line: &str,
line_number: usize,
node_kind: &str,
) -> RuleViolation {
let limit = self.get_line_limit(node_kind);
RuleViolation::new(
&MD013,
format!(
"{} [Expected: <= {}; Actual: {}]",
MD013.description,
limit,
line.len()
),
self.context.file_path.clone(),
range_from_tree_sitter(&tree_sitter::Range {
start_byte: 0,
end_byte: line.len(),
start_point: tree_sitter::Point {
row: line_number,
column: 0,
},
end_point: tree_sitter::Point {
row: line_number,
column: line.len(),
},
}),
)
}
}
impl RuleLinter for MD013Linter {
fn feed(&mut self, node: &Node) {
if node.kind() == "document" {
self.analyze_all_lines();
}
}
fn finalize(&mut self) -> Vec<RuleViolation> {
std::mem::take(&mut self.violations)
}
}
pub const MD013: Rule = Rule {
id: "MD013",
alias: "line-length",
tags: &["line_length"],
description: "Line length should not exceed the configured limit",
rule_type: RuleType::Line,
required_nodes: &[], new_linter: |context| Box::new(MD013Linter::new(context)),
};
#[cfg(test)]
mod test {
use std::path::PathBuf;
use crate::config::{LintersSettingsTable, MD013LineLengthTable, RuleSeverity};
use crate::linter::MultiRuleLinter;
use crate::test_utils::test_helpers::{test_config_with_rules, test_config_with_settings};
fn test_config() -> crate::config::QuickmarkConfig {
test_config_with_rules(vec![
("line-length", RuleSeverity::Error),
("heading-style", RuleSeverity::Off),
("heading-increment", RuleSeverity::Off),
])
}
fn test_config_with_line_length(
line_length_config: MD013LineLengthTable,
) -> crate::config::QuickmarkConfig {
test_config_with_settings(
vec![
("line-length", RuleSeverity::Error),
("heading-style", RuleSeverity::Off),
("heading-increment", RuleSeverity::Off),
],
LintersSettingsTable {
line_length: line_length_config,
..Default::default()
},
)
}
#[test]
fn test_line_length_violation() {
let input = "This is a line that is definitely longer than eighty characters and should trigger a violation.";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
let violation = &violations[0];
assert_eq!("MD013", violation.rule().id);
assert!(violation.message().contains("Expected: <= 80"));
assert!(violation
.message()
.contains(&format!("Actual: {}", input.len())));
}
#[test]
fn test_line_length_no_violation() {
let mut input =
"This line should be exactly eighty characters long and not trigger".to_string();
while input.len() < 80 {
input.push('x');
}
assert_eq!(80, input.len());
let config = test_config();
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_link_reference_definition_exception() {
let input = "[very-long-link-reference-that-exceeds-eighty-characters]: https://example.com/very-long-url-that-should-be-exempted";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_standalone_link_exception() {
let input = "[This is a very long link text that definitely exceeds eighty characters](https://example.com)";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_standalone_image_exception() {
let input = "";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_no_spaces_beyond_limit_exception() {
let input = "This line has exactly eighty characters and then continues without spaces: https://example.com/very-long-url-without-spaces";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_spaces_beyond_limit_violation() {
let mut input =
"This line has exactly eighty characters and should trigger violation".to_string();
while input.len() < 80 {
input.push('x');
}
input.push(' ');
let config = test_config();
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
}
#[test]
fn test_strict_mode() {
let line_length_config = MD013LineLengthTable {
strict: true,
..MD013LineLengthTable::default()
};
let input = "This line has exactly eighty characters and then continues without spaces like: https://example.com/url";
let config = test_config_with_line_length(line_length_config);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len()); }
#[test]
fn test_stern_mode_with_spaces_beyond_limit() {
let config = MD013LineLengthTable {
stern: true,
..MD013LineLengthTable::default()
};
let mut input =
"This line has exactly eighty characters and should trigger violations".to_string();
while input.len() < 80 {
input.push('x');
}
input.push_str(" with spaces");
let full_config = test_config_with_line_length(config);
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), full_config, &input);
let violations = linter.analyze();
assert_eq!(1, violations.len()); }
#[test]
fn test_stern_mode_without_spaces_beyond_limit() {
let config = MD013LineLengthTable {
stern: true,
..MD013LineLengthTable::default()
};
let input = "This line has exactly eighty characters and then continues without spaces: https://example.com/very-long-url-without-spaces";
let full_config = test_config_with_line_length(config);
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), full_config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len()); }
#[test]
fn test_stern_mode_vs_default_mode() {
let mut input =
"This line has exactly eighty characters and then continues with".to_string();
while input.len() < 80 {
input.push('x');
}
input.push_str(" spaces beyond");
let default_config = MD013LineLengthTable::default();
let default_full_config = test_config_with_line_length(default_config);
let mut default_linter = MultiRuleLinter::new_for_document(
PathBuf::from("test.md"),
default_full_config,
&input,
);
let default_violations = default_linter.analyze();
let stern_config = MD013LineLengthTable {
stern: true,
..MD013LineLengthTable::default()
};
let stern_full_config = test_config_with_line_length(stern_config);
let mut stern_linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), stern_full_config, &input);
let stern_violations = stern_linter.analyze();
assert_eq!(1, default_violations.len()); assert_eq!(1, stern_violations.len()); }
#[test]
fn test_stern_vs_strict_vs_default_comprehensive() {
let mut case1 =
"This line has exactly eighty characters and then continues with".to_string();
while case1.len() < 80 {
case1.push('x');
}
case1.push_str(" spaces");
let case2 = "This line has exactly eighty characters and then continues without spaces: https://example.com/url".to_string();
let case3 = "This line is within the eighty character limit".to_string();
let test_cases = vec![
(&case1, true, true, true), (&case2, false, false, true), (&case3, false, false, false), ];
for (input, expect_default, expect_stern, expect_strict) in test_cases {
let default_config = MD013LineLengthTable::default();
let default_full_config = test_config_with_line_length(default_config);
let mut default_linter = MultiRuleLinter::new_for_document(
PathBuf::from("test.md"),
default_full_config,
input,
);
let default_violations = default_linter.analyze();
assert_eq!(
expect_default,
!default_violations.is_empty(),
"Default mode failed for: {input}"
);
let stern_config = MD013LineLengthTable {
stern: true,
..MD013LineLengthTable::default()
};
let stern_full_config = test_config_with_line_length(stern_config);
let mut stern_linter = MultiRuleLinter::new_for_document(
PathBuf::from("test.md"),
stern_full_config,
input,
);
let stern_violations = stern_linter.analyze();
assert_eq!(
expect_stern,
!stern_violations.is_empty(),
"Stern mode failed for: {input}"
);
let strict_config = MD013LineLengthTable {
strict: true,
..MD013LineLengthTable::default()
};
let strict_full_config = test_config_with_line_length(strict_config);
let mut strict_linter = MultiRuleLinter::new_for_document(
PathBuf::from("test.md"),
strict_full_config,
input,
);
let strict_violations = strict_linter.analyze();
assert_eq!(
expect_strict,
!strict_violations.is_empty(),
"Strict mode failed for: {input}"
);
}
}
#[test]
fn test_custom_line_length() {
let line_length_config = MD013LineLengthTable {
line_length: 50,
..MD013LineLengthTable::default()
};
let input = "This line is longer than fifty characters and should violate";
let config = test_config_with_line_length(line_length_config);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
assert!(violations[0].message().contains("Expected: <= 50"));
}
#[test]
fn test_headings_disabled() {
let line_length_config = MD013LineLengthTable {
headings: false,
..MD013LineLengthTable::default()
};
let input = "# This is a very long heading that definitely exceeds the eighty character limit and should not trigger a violation";
let config = test_config_with_line_length(line_length_config);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_multiple_lines() {
let input = "This is a short line.
This is a very long line that definitely exceeds the eighty character limit and should trigger a violation.
Another short line.";
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
}
#[test]
fn test_demonstrates_potential_bug_scenario() {
let input = "A\nB\nC\n";
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_md::LANGUAGE.into())
.unwrap();
let tree = parser.parse(input, None).unwrap();
let mut node_count = 0;
let walker = crate::tree_sitter_walker::TreeSitterWalker::new(&tree);
walker.walk(|_node| {
node_count += 1;
});
println!("Even a 3-line minimal document creates {node_count} AST nodes");
println!("This explains why our MD013 implementation works correctly");
assert!(
node_count >= 3,
"Even minimal documents create multiple AST nodes"
);
}
#[test]
fn test_extreme_violations_vs_minimal_nodes() {
let mut input = String::new();
let long_line = "This line is definitely longer than 80 characters and should trigger a line length violation every single time.\n";
assert!(
long_line.len() > 80,
"Test line should exceed 80 chars, got {}",
long_line.len()
);
for i in 0..100 {
input.push_str(&format!("Violation line {}: {}", i + 1, long_line));
}
println!("Total input length: {} chars", input.len());
println!("Number of lines: {}", input.lines().count());
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_md::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&input, None).unwrap();
let mut node_count = 0;
let walker = crate::tree_sitter_walker::TreeSitterWalker::new(&tree);
walker.walk(|_node| {
node_count += 1;
});
println!("Total AST nodes: {node_count}");
let config = test_config();
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
let violations = linter.analyze();
println!("Violations found: {}", violations.len());
println!(
"Ratio: {} violations vs {} nodes",
violations.len(),
node_count
);
assert_eq!(100, violations.len(),
"Expected 100 line length violations but found {}. The improved MD013 should never lose violations!",
violations.len()
);
}
#[test]
fn test_violation_node_mismatch_scenario() {
let mut input = "# Header\n\n".to_string();
for i in 0..50 {
input.push_str(&format!("Line {} with text that is definitely over eighty characters and should trigger MD013 violation\n", i + 1));
}
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_md::LANGUAGE.into())
.unwrap();
let tree = parser.parse(&input, None).unwrap();
let mut node_count = 0;
let walker = crate::tree_sitter_walker::TreeSitterWalker::new(&tree);
walker.walk(|_node| {
node_count += 1;
});
let config = test_config();
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
let violations = linter.analyze();
println!(
"Stress test: {} violations vs {} nodes",
violations.len(),
node_count
);
assert_eq!(
50,
violations.len(),
"Expected 50 violations but found {}. Improved MD013 must not lose violations!",
violations.len()
);
for (i, violation) in violations.iter().enumerate() {
let expected_line = i + 2; assert_eq!(
expected_line,
violation.location().range.start.line,
"Violation {} should be on line {} but was on line {}",
i + 1,
expected_line,
violation.location().range.start.line
);
}
}
#[test]
fn test_many_violations_vs_few_nodes() {
let mut input = "# Short heading\n\n".to_string();
let long_line = "This line is definitely longer than 80 characters and should trigger a line length violation every time it appears.\n";
assert!(
long_line.len() > 80,
"Test line should exceed 80 chars, got {}",
long_line.len()
);
for i in 0..20 {
input.push_str(&format!("Line {}: {}", i + 1, long_line));
}
println!("Total input length: {} chars", input.len());
println!("Number of lines: {}", input.lines().count());
let config = test_config();
let mut linter =
MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, &input);
let violations = linter.analyze();
println!("Violations found: {}", violations.len());
for (i, violation) in violations.iter().enumerate() {
println!(
" Violation {}: line {}",
i + 1,
violation.location().range.start.line
);
}
assert_eq!(20, violations.len(),
"Expected 20 line length violations but found {}. This suggests violations were lost due to insufficient AST nodes.",
violations.len()
);
for (i, violation) in violations.iter().enumerate() {
let expected_line = i + 2; assert_eq!(
expected_line,
violation.location().range.start.line,
"Violation {} should be on line {} but was on line {}",
i + 1,
expected_line,
violation.location().range.start.line
);
}
}
#[test]
fn test_utf8_character_boundary_fix() {
let input = "| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |";
assert!(input.len() > 80, "Line should exceed 80 characters");
let char_at_79 = input.as_bytes()[79];
assert!(
char_at_79 >= 0x80,
"Should have multi-byte UTF-8 character near position 80"
);
let config = test_config();
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len(), "Should find one line length violation");
assert_eq!("MD013", violations[0].rule().id);
assert!(violations[0].message().contains("Expected: <= 80"));
}
}