Skip to content

Commit f55ae4b

Browse files
ekropotinclaude
andcommitted
feat: implement MD013 line-length rule with comprehensive architecture enhancements
Major changes: - Implemented MD013 (line-length) rule with configurable limits and exceptions - Enhanced architecture with single-pass document processing and context caching - Added comprehensive rules implementation checklist (48 total markdownlint rules) - Categorized rules into 5 implementation types for optimal development strategy Key architectural improvements: - Enhanced Context system with line caching and node-to-line mapping - Hybrid AST + line processing for optimal performance - Document-wide state preparation for complex rules - Single-use contract enforcement for memory safety Code quality improvements: - Fixed clippy warnings (or_default, format string inlining) - Suppressed false-positive recursive parameter warnings - Added comprehensive documentation and usage examples Test coverage: - 75 total tests passing - Comprehensive MD013 test suite with multiple configuration modes - Integration tests for CLI error reporting Documentation: - Added MD013 rule documentation following project conventions - Updated CLAUDE.md with complete rules roadmap and architecture analysis 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ef18d3c commit f55ae4b

27 files changed

Lines changed: 1669 additions & 169 deletions

.claude/commands/port_rule.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
The goal is to port $ARGEMENTS rule implementation from the original markdownlinter.
2+
Think hard to create an implementation plan. It must include writing comprehensive unit-tests covering as much as possible combinations of rule's settings as possible. Embrace TDD approach. This means, start with writing minimum set of data structurs needed for a test, refrain from writing actual logic for linting at this stage. When, write unit tests. Confirm they are failing. When keep implementing/refining the logic until tests are green.
3+
You'd also need to create new samples for that rule in `test-samples` directory, following existing naming conventions.
4+
Finally, you must validate that the implementation is consistent with markdownlinter. This can be done via running both linters against test samples and when analyzing the output. If any inconsistencies found - you must fix them. Assume markdownlinter is already installed on this machine locally. For any found actual inconsistency, add unit test.
5+
At the end, copy original rule documentation in `docs/rules`

CLAUDE.md

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,120 @@ quickmark/
121121
- Each rule implements `RuleLinter` trait with `feed` method
122122
- Rules are dynamically instantiated based on configuration
123123

124-
### Current Rules
124+
### Rules Implementation Checklist
125+
126+
This section tracks the progress of porting all markdownlint rules to QuickMark. Rules are categorized by their implementation requirements:
127+
128+
#### Line-Based Rules (5 rules)
129+
*Work primarily with raw text lines - high performance, direct text analysis*
130+
131+
- [ ] **MD009** (`no-trailing-spaces`): Trailing spaces at end of lines
132+
- [ ] **MD010** (`no-hard-tabs`): Hard tabs should not be used
133+
- [ ] **MD012** (`no-multiple-blanks`): Multiple consecutive blank lines
134+
- [x] **MD013** (`line-length`): Line length limits with configurable exceptions ✅
135+
- [ ] **MD047** (`single-trailing-newline`): Files should end with a single newline
136+
137+
#### Token-Based Rules (32 rules)
138+
*Work with specific AST node types - cached node filtering for efficiency*
125139

126-
- **MD001** (`heading-increment`): Ensures heading levels increment by one
127-
- **MD003** (`heading-style`): Enforces consistent heading styles
140+
**Heading Rules (8 rules):**
141+
- [x] **MD001** (`heading-increment`): Heading levels increment by one ✅
142+
- [x] **MD003** (`heading-style`): Consistent heading styles ✅
143+
- [ ] **MD018** (`no-missing-space-atx`): Space after hash in ATX headings
144+
- [ ] **MD019** (`no-multiple-space-atx`): Multiple spaces after hash in ATX headings
145+
- [ ] **MD020** (`no-missing-space-closed-atx`): Space inside closed ATX headings
146+
- [ ] **MD021** (`no-multiple-space-closed-atx`): Multiple spaces in closed ATX headings
147+
- [ ] **MD023** (`heading-start-left`): Headings start at beginning of line
148+
- [ ] **MD026** (`no-trailing-punctuation`): Trailing punctuation in headings
149+
150+
**List Rules (6 rules):**
151+
- [ ] **MD004** (`ul-style`): Unordered list style consistency
152+
- [ ] **MD005** (`list-indent`): List item indentation at same level
153+
- [ ] **MD006** (`ul-start-left`): Bulleted lists start at beginning of line
154+
- [ ] **MD007** (`ul-indent`): Unordered list indentation consistency
155+
- [ ] **MD029** (`ol-prefix`): Ordered list item prefix consistency
156+
- [ ] **MD030** (`list-marker-space`): Spaces after list markers
157+
158+
**Link Rules (3 rules):**
159+
- [ ] **MD011** (`no-reversed-links`): Reversed link syntax
160+
- [ ] **MD034** (`no-bare-urls`): Bare URLs without proper formatting
161+
- [ ] **MD042** (`no-empty-links`): Empty links
162+
163+
**Code Rules (4 rules):**
164+
- [ ] **MD014** (`commands-show-output`): Dollar signs before shell commands
165+
- [ ] **MD040** (`fenced-code-language`): Language specified for fenced code blocks
166+
- [ ] **MD046** (`code-block-style`): Code block style consistency
167+
- [ ] **MD048** (`code-fence-style`): Code fence style consistency
168+
169+
**Formatting Rules (11 rules):**
170+
- [ ] **MD027** (`no-multiple-space-blockquote`): Multiple spaces after blockquote
171+
- [ ] **MD028** (`no-blanks-blockquote`): Blank lines inside blockquotes
172+
- [ ] **MD033** (`no-inline-html`): Inline HTML usage
173+
- [ ] **MD035** (`hr-style`): Horizontal rule style consistency
174+
- [ ] **MD036** (`no-emphasis-as-heading`): Emphasis used instead of heading
175+
- [ ] **MD037** (`no-space-in-emphasis`): Spaces inside emphasis markers
176+
- [ ] **MD038** (`no-space-in-code`): Spaces inside code span elements
177+
- [ ] **MD039** (`no-space-in-links`): Spaces inside link text
178+
- [ ] **MD045** (`no-alt-text`): Images should have alternate text
179+
- [ ] **MD049** (`emphasis-style`): Emphasis style consistency
180+
- [ ] **MD050** (`strong-style`): Strong style consistency
181+
182+
#### Document-Wide Rules (7 rules)
183+
*Require full document analysis - global state tracking*
184+
185+
- [ ] **MD024** (`no-duplicate-heading`): Multiple headings with same content
186+
- [ ] **MD025** (`single-title`): Multiple top-level headings
187+
- [ ] **MD041** (`first-line-heading`): First line should be top-level heading
188+
- [ ] **MD043** (`required-headings`): Required heading structure
189+
- [ ] **MD051** (`link-fragments`): Link fragments should be valid
190+
- [ ] **MD052** (`reference-links-images`): Reference links should be defined
191+
- [ ] **MD053** (`link-image-reference-definitions`): Reference definitions should be needed
192+
193+
#### Hybrid Rules (3 rules)
194+
*Need both AST analysis and line context - structural elements with spacing*
195+
196+
- [ ] **MD022** (`blanks-around-headings`): Headings surrounded by blank lines
197+
- [ ] **MD031** (`blanks-around-fences`): Fenced code blocks surrounded by blank lines
198+
- [ ] **MD032** (`blanks-around-lists`): Lists surrounded by blank lines
199+
200+
#### Special Rules (1 rule)
201+
*Unique implementation requirements*
202+
203+
- [ ] **MD044** (`proper-names`): Proper names with correct capitalization (requires external dictionaries)
204+
205+
**Implementation Progress: 3/48 rules completed (6.25%)**
206+
207+
### Linting Architecture Evolution
208+
209+
**Performance-Optimized Single-Pass Design**:
210+
211+
QuickMark has evolved from a simple node-based traversal to a sophisticated single-pass architecture that efficiently handles different rule types while maintaining exceptional performance. This design is inspired by the original markdownlint's architecture but leverages Rust's performance advantages and tree-sitter's robust parsing.
212+
213+
**Rule Type Classification**:
214+
215+
Rules are categorized into five types for optimal performance and implementation strategy:
216+
217+
- **Line-Based Rules** (e.g., MD013): Operate directly on raw text lines with AST context for configuration
218+
- **Token-Based Rules** (e.g., MD001, MD003): Work with specific cached AST node types
219+
- **Document-Wide Rules** (e.g., MD024, MD025): Require full document state analysis
220+
- **Hybrid Rules** (e.g., MD022): Need both AST analysis and line context for structural spacing
221+
- **Special Rules** (e.g., MD044): Unique implementation requirements like external dictionaries
222+
223+
**Enhanced Context System**:
224+
225+
The `Context` provides multiple optimized data views:
226+
- Raw text lines for line-based analysis
227+
- Cached filtered AST nodes by type (headings, code blocks, etc.)
228+
- Configuration-driven rule execution with lazy evaluation
229+
230+
**Motivation for Single-Pass Architecture**:
231+
232+
1. **Performance**: Avoids multiple document parsing passes that would compromise QuickMark's speed promise
233+
2. **Memory Efficiency**: Caches commonly-used node types rather than re-filtering AST repeatedly
234+
3. **Scalability**: Supports complex rules (cross-document validation, word analysis) without architectural changes
235+
4. **Compatibility**: Maintains the existing rule interface while enabling performance optimizations
236+
237+
This architecture allows rules like MD013 to work efficiently with raw text while still having access to AST context for proper configuration handling (e.g., different limits for headings vs. code blocks).
128238

129239
### Key Design Patterns
130240

@@ -138,7 +248,7 @@ quickmark/
138248

139249
**Shared Context**: `Rc<Context>` is passed to all rule linters, containing file path and configuration.
140250

141-
**AST Traversal**: Uses tree-sitter node iteration with each rule's `feed` method processing nodes.
251+
**Hybrid AST + Line Processing**: Uses tree-sitter for structural analysis with cached node filtering, plus direct text line access for line-based rules. Rules receive an enhanced context with multiple optimized data views.
142252

143253
**Configuration-Driven**: Rule severity and settings are externally configurable via TOML files.
144254

@@ -175,11 +285,18 @@ quickmark/
175285
## Adding New Rules
176286

177287
1. Create a new rule module in `crates/quickmark_linter/src/rules/`
178-
2. Implement the `RuleLinter` trait
288+
2. Implement the `RuleLinter` trait with appropriate `RuleType` classification
179289
3. Add the rule to `ALL_RULES` in `crates/quickmark_linter/src/rules/mod.rs`
180290
4. Add any rule-specific configuration to the config structs
181291
5. Update TOML parsing in `quickmark_config` if needed
182292

293+
**Rule Type Guidelines**:
294+
- Use `RuleType::Line` for rules that primarily analyze text content (line length, whitespace, etc.)
295+
- Use `RuleType::Token` for rules that analyze document structure (headings, lists, code blocks)
296+
- Use `RuleType::Document` for rules requiring full document analysis (duplicate headings, cross-references)
297+
- Use `RuleType::Hybrid` for rules needing both AST nodes and line context (blank line spacing around elements)
298+
- Use `RuleType::Special` for rules with unique requirements (external dictionaries, complex text analysis)
299+
183300
## Adding New Configuration Formats
184301

185302
1. Create conversion functions in `quickmark_config`

crates/quickmark/src/main.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
use anyhow::Context;
22
use clap::Parser;
33
use quickmark_config::config_in_path_or_default;
4-
use quickmark_linter::linter::{Context as LintContext, MultiRuleLinter, RuleViolation};
4+
use quickmark_linter::linter::{MultiRuleLinter, RuleViolation};
55
use quickmark_linter::config::{QuickmarkConfig, RuleSeverity};
66
use std::cmp::min;
77
use std::env;
8-
use std::rc::Rc;
98
use std::{fs, path::PathBuf, process::exit};
109

1110
#[derive(Parser, Debug)]
@@ -63,12 +62,10 @@ fn main() -> anyhow::Result<()> {
6362
let pwd = env::current_dir()?;
6463
let config = config_in_path_or_default(&pwd)?;
6564

66-
let context = Rc::new(LintContext { file_path, config });
65+
let mut linter = MultiRuleLinter::new_for_document(file_path, config.clone(), &file_content);
6766

68-
let mut linter = MultiRuleLinter::new(context.clone());
69-
70-
let lint_res = linter.lint(&file_content);
71-
let (errs, _) = print_cli_errors(&lint_res, &context.config);
67+
let lint_res = linter.analyze();
68+
let (errs, _) = print_cli_errors(&lint_res, &config);
7269
let exit_code = min(errs, 1);
7370
exit(exit_code);
7471
}
@@ -78,7 +75,7 @@ mod tests {
7875
use super::*;
7976
use std::collections::HashMap;
8077
use std::path::PathBuf;
81-
use quickmark_linter::config::{HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable};
78+
use quickmark_linter::config::{HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable};
8279
use quickmark_linter::linter::{CharPosition, Range};
8380
use quickmark_linter::rules::{md001::MD001, md003::MD003};
8481

@@ -97,6 +94,7 @@ mod tests {
9794
heading_style: MD003HeadingStyleTable {
9895
style: HeadingStyle::Consistent,
9996
},
97+
line_length: MD013LineLengthTable::default(),
10098
},
10199
},
102100
};

crates/quickmark_config/src/lib.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use anyhow::Result;
22
use quickmark_linter::config::{
3-
normalize_severities, HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable,
3+
normalize_severities, HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable,
44
QuickmarkConfig, RuleSeverity,
55
};
66
use serde::Deserialize;
@@ -38,11 +38,42 @@ struct TomlMD003HeadingStyleTable {
3838
style: TomlHeadingStyle,
3939
}
4040

41+
#[derive(Deserialize)]
42+
#[derive(Default)]
43+
struct TomlMD013LineLengthTable {
44+
#[serde(default = "default_line_length")]
45+
line_length: usize,
46+
#[serde(default = "default_code_block_line_length")]
47+
code_block_line_length: usize,
48+
#[serde(default = "default_heading_line_length")]
49+
heading_line_length: usize,
50+
#[serde(default = "default_true")]
51+
code_blocks: bool,
52+
#[serde(default = "default_true")]
53+
headings: bool,
54+
#[serde(default = "default_true")]
55+
tables: bool,
56+
#[serde(default = "default_false")]
57+
strict: bool,
58+
#[serde(default = "default_false")]
59+
stern: bool,
60+
}
61+
62+
fn default_line_length() -> usize { 80 }
63+
fn default_code_block_line_length() -> usize { 80 }
64+
fn default_heading_line_length() -> usize { 80 }
65+
fn default_true() -> bool { true }
66+
fn default_false() -> bool { false }
67+
4168
#[derive(Deserialize)]
4269
#[derive(Default)]
4370
struct TomlLintersSettingsTable {
4471
#[serde(rename = "heading-style")]
72+
#[serde(default)]
4573
heading_style: TomlMD003HeadingStyleTable,
74+
#[serde(rename = "line-length")]
75+
#[serde(default)]
76+
line_length: TomlMD013LineLengthTable,
4677
}
4778

4879
#[derive(Deserialize)]
@@ -105,6 +136,16 @@ pub fn parse_toml_config(config_str: &str) -> Result<QuickmarkConfig> {
105136
heading_style: MD003HeadingStyleTable {
106137
style: convert_toml_heading_style(toml_config.linters.settings.heading_style.style),
107138
},
139+
line_length: MD013LineLengthTable {
140+
line_length: toml_config.linters.settings.line_length.line_length,
141+
code_block_line_length: toml_config.linters.settings.line_length.code_block_line_length,
142+
heading_line_length: toml_config.linters.settings.line_length.heading_line_length,
143+
code_blocks: toml_config.linters.settings.line_length.code_blocks,
144+
headings: toml_config.linters.settings.line_length.headings,
145+
tables: toml_config.linters.settings.line_length.tables,
146+
strict: toml_config.linters.settings.line_length.strict,
147+
stern: toml_config.linters.settings.line_length.stern,
148+
},
108149
},
109150
}))
110151
}
@@ -244,4 +285,16 @@ mod tests {
244285
parsed.linters.settings.heading_style.style
245286
);
246287
}
288+
289+
#[test]
290+
fn test_parse_toml_config_with_line_length() {
291+
let config_str = r#"
292+
[linters.settings.line-length]
293+
line_length = 50
294+
"#;
295+
296+
let parsed = parse_toml_config(config_str).unwrap();
297+
assert_eq!(50, parsed.linters.settings.line_length.line_length);
298+
assert_eq!(80, parsed.linters.settings.line_length.code_block_line_length); // default
299+
}
247300
}

crates/quickmark_linter/src/config/mod.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,37 @@ impl Default for MD003HeadingStyleTable {
3232
}
3333
}
3434

35+
#[derive(Debug, PartialEq, Clone)]
36+
pub struct MD013LineLengthTable {
37+
pub line_length: usize,
38+
pub code_block_line_length: usize,
39+
pub heading_line_length: usize,
40+
pub code_blocks: bool,
41+
pub headings: bool,
42+
pub tables: bool,
43+
pub strict: bool,
44+
pub stern: bool,
45+
}
46+
47+
impl Default for MD013LineLengthTable {
48+
fn default() -> Self {
49+
Self {
50+
line_length: 80,
51+
code_block_line_length: 80,
52+
heading_line_length: 80,
53+
code_blocks: true,
54+
headings: true,
55+
tables: true,
56+
strict: false,
57+
stern: false,
58+
}
59+
}
60+
}
61+
3562
#[derive(Debug, Default, PartialEq, Clone)]
3663
pub struct LintersSettingsTable {
3764
pub heading_style: MD003HeadingStyleTable,
65+
pub line_length: MD013LineLengthTable,
3866
}
3967

4068
#[derive(Debug, Default, PartialEq, Clone)]
@@ -75,7 +103,7 @@ mod test {
75103
use std::collections::HashMap;
76104

77105
use crate::config::{
78-
HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, RuleSeverity,
106+
HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable, RuleSeverity,
79107
};
80108

81109
use super::{normalize_severities, QuickmarkConfig};
@@ -131,6 +159,7 @@ mod test {
131159
heading_style: MD003HeadingStyleTable {
132160
style: HeadingStyle::ATX,
133161
},
162+
line_length: MD013LineLengthTable::default(),
134163
},
135164
});
136165

crates/quickmark_linter/src/lib.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
//! # QuickMark Linter Core
2+
//!
3+
//! ## Single-Use Architecture Contract
4+
//!
5+
//! **IMPORTANT**: All linter components in this crate follow a strict single-use contract:
6+
//!
7+
//! - **Context**: One context instance per document analysis
8+
//! - **MultiRuleLinter**: One linter instance per document analysis
9+
//! - **RuleLinter**: Individual rule linters are used once and discarded
10+
//!
11+
//! This design eliminates state management complexity.
12+
//!
13+
//! ### Usage Pattern
14+
//! ```rust,no_run
15+
//! use quickmark_linter::linter::MultiRuleLinter;
16+
//! use quickmark_linter::config::QuickmarkConfig;
17+
//! use std::path::PathBuf;
18+
//!
19+
//! // Example usage (variables would be provided by your application)
20+
//! # let path = PathBuf::new();
21+
//! # let config: QuickmarkConfig = unimplemented!();
22+
//! # let source = "";
23+
//!
24+
//! // Correct: Fresh instances for each document
25+
//! let mut linter = MultiRuleLinter::new_for_document(path, config, source);
26+
//! let violations = linter.analyze();
27+
//! // linter is now invalid - create new one for next document
28+
//! ```
29+
130
pub mod config;
231
pub mod linter;
332
pub mod rules;

0 commit comments

Comments
 (0)