Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/commands/port_rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
The goal is to port $ARGEMENTS rule implementation from the original markdownlinter.
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.
You'd also need to create new samples for that rule in `test-samples` directory, following existing naming conventions.
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.
At the end, copy original rule documentation in `docs/rules`
127 changes: 122 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,120 @@ quickmark/
- Each rule implements `RuleLinter` trait with `feed` method
- Rules are dynamically instantiated based on configuration

### Current Rules
### Rules Implementation Checklist

This section tracks the progress of porting all markdownlint rules to QuickMark. Rules are categorized by their implementation requirements:

#### Line-Based Rules (5 rules)
*Work primarily with raw text lines - high performance, direct text analysis*

- [ ] **MD009** (`no-trailing-spaces`): Trailing spaces at end of lines
- [ ] **MD010** (`no-hard-tabs`): Hard tabs should not be used
- [ ] **MD012** (`no-multiple-blanks`): Multiple consecutive blank lines
- [x] **MD013** (`line-length`): Line length limits with configurable exceptions ✅
- [ ] **MD047** (`single-trailing-newline`): Files should end with a single newline

#### Token-Based Rules (32 rules)
*Work with specific AST node types - cached node filtering for efficiency*

- **MD001** (`heading-increment`): Ensures heading levels increment by one
- **MD003** (`heading-style`): Enforces consistent heading styles
**Heading Rules (8 rules):**
- [x] **MD001** (`heading-increment`): Heading levels increment by one ✅
- [x] **MD003** (`heading-style`): Consistent heading styles ✅
- [ ] **MD018** (`no-missing-space-atx`): Space after hash in ATX headings
- [ ] **MD019** (`no-multiple-space-atx`): Multiple spaces after hash in ATX headings
- [ ] **MD020** (`no-missing-space-closed-atx`): Space inside closed ATX headings
- [ ] **MD021** (`no-multiple-space-closed-atx`): Multiple spaces in closed ATX headings
- [ ] **MD023** (`heading-start-left`): Headings start at beginning of line
- [ ] **MD026** (`no-trailing-punctuation`): Trailing punctuation in headings

**List Rules (6 rules):**
- [ ] **MD004** (`ul-style`): Unordered list style consistency
- [ ] **MD005** (`list-indent`): List item indentation at same level
- [ ] **MD006** (`ul-start-left`): Bulleted lists start at beginning of line
- [ ] **MD007** (`ul-indent`): Unordered list indentation consistency
- [ ] **MD029** (`ol-prefix`): Ordered list item prefix consistency
- [ ] **MD030** (`list-marker-space`): Spaces after list markers

**Link Rules (3 rules):**
- [ ] **MD011** (`no-reversed-links`): Reversed link syntax
- [ ] **MD034** (`no-bare-urls`): Bare URLs without proper formatting
- [ ] **MD042** (`no-empty-links`): Empty links

**Code Rules (4 rules):**
- [ ] **MD014** (`commands-show-output`): Dollar signs before shell commands
- [ ] **MD040** (`fenced-code-language`): Language specified for fenced code blocks
- [ ] **MD046** (`code-block-style`): Code block style consistency
- [ ] **MD048** (`code-fence-style`): Code fence style consistency

**Formatting Rules (11 rules):**
- [ ] **MD027** (`no-multiple-space-blockquote`): Multiple spaces after blockquote
- [ ] **MD028** (`no-blanks-blockquote`): Blank lines inside blockquotes
- [ ] **MD033** (`no-inline-html`): Inline HTML usage
- [ ] **MD035** (`hr-style`): Horizontal rule style consistency
- [ ] **MD036** (`no-emphasis-as-heading`): Emphasis used instead of heading
- [ ] **MD037** (`no-space-in-emphasis`): Spaces inside emphasis markers
- [ ] **MD038** (`no-space-in-code`): Spaces inside code span elements
- [ ] **MD039** (`no-space-in-links`): Spaces inside link text
- [ ] **MD045** (`no-alt-text`): Images should have alternate text
- [ ] **MD049** (`emphasis-style`): Emphasis style consistency
- [ ] **MD050** (`strong-style`): Strong style consistency

#### Document-Wide Rules (7 rules)
*Require full document analysis - global state tracking*

- [ ] **MD024** (`no-duplicate-heading`): Multiple headings with same content
- [ ] **MD025** (`single-title`): Multiple top-level headings
- [ ] **MD041** (`first-line-heading`): First line should be top-level heading
- [ ] **MD043** (`required-headings`): Required heading structure
- [ ] **MD051** (`link-fragments`): Link fragments should be valid
- [ ] **MD052** (`reference-links-images`): Reference links should be defined
- [ ] **MD053** (`link-image-reference-definitions`): Reference definitions should be needed

#### Hybrid Rules (3 rules)
*Need both AST analysis and line context - structural elements with spacing*

- [ ] **MD022** (`blanks-around-headings`): Headings surrounded by blank lines
- [ ] **MD031** (`blanks-around-fences`): Fenced code blocks surrounded by blank lines
- [ ] **MD032** (`blanks-around-lists`): Lists surrounded by blank lines

#### Special Rules (1 rule)
*Unique implementation requirements*

- [ ] **MD044** (`proper-names`): Proper names with correct capitalization (requires external dictionaries)

**Implementation Progress: 3/48 rules completed (6.25%)**

### Linting Architecture Evolution

**Performance-Optimized Single-Pass Design**:

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.

**Rule Type Classification**:

Rules are categorized into five types for optimal performance and implementation strategy:

- **Line-Based Rules** (e.g., MD013): Operate directly on raw text lines with AST context for configuration
- **Token-Based Rules** (e.g., MD001, MD003): Work with specific cached AST node types
- **Document-Wide Rules** (e.g., MD024, MD025): Require full document state analysis
- **Hybrid Rules** (e.g., MD022): Need both AST analysis and line context for structural spacing
- **Special Rules** (e.g., MD044): Unique implementation requirements like external dictionaries

**Enhanced Context System**:

The `Context` provides multiple optimized data views:
- Raw text lines for line-based analysis
- Cached filtered AST nodes by type (headings, code blocks, etc.)
- Configuration-driven rule execution with lazy evaluation

**Motivation for Single-Pass Architecture**:

1. **Performance**: Avoids multiple document parsing passes that would compromise QuickMark's speed promise
2. **Memory Efficiency**: Caches commonly-used node types rather than re-filtering AST repeatedly
3. **Scalability**: Supports complex rules (cross-document validation, word analysis) without architectural changes
4. **Compatibility**: Maintains the existing rule interface while enabling performance optimizations

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).

### Key Design Patterns

Expand All @@ -138,7 +248,7 @@ quickmark/

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

**AST Traversal**: Uses tree-sitter node iteration with each rule's `feed` method processing nodes.
**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.

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

Expand Down Expand Up @@ -175,11 +285,18 @@ quickmark/
## Adding New Rules

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

**Rule Type Guidelines**:
- Use `RuleType::Line` for rules that primarily analyze text content (line length, whitespace, etc.)
- Use `RuleType::Token` for rules that analyze document structure (headings, lists, code blocks)
- Use `RuleType::Document` for rules requiring full document analysis (duplicate headings, cross-references)
- Use `RuleType::Hybrid` for rules needing both AST nodes and line context (blank line spacing around elements)
- Use `RuleType::Special` for rules with unique requirements (external dictionaries, complex text analysis)

## Adding New Configuration Formats

1. Create conversion functions in `quickmark_config`
Expand Down
14 changes: 6 additions & 8 deletions crates/quickmark/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use anyhow::Context;
use clap::Parser;
use quickmark_config::config_in_path_or_default;
use quickmark_linter::linter::{Context as LintContext, MultiRuleLinter, RuleViolation};
use quickmark_linter::linter::{MultiRuleLinter, RuleViolation};
use quickmark_linter::config::{QuickmarkConfig, RuleSeverity};
use std::cmp::min;
use std::env;
use std::rc::Rc;
use std::{fs, path::PathBuf, process::exit};

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

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

let mut linter = MultiRuleLinter::new(context.clone());

let lint_res = linter.lint(&file_content);
let (errs, _) = print_cli_errors(&lint_res, &context.config);
let lint_res = linter.analyze();
let (errs, _) = print_cli_errors(&lint_res, &config);
let exit_code = min(errs, 1);
exit(exit_code);
}
Expand All @@ -78,7 +75,7 @@ mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use quickmark_linter::config::{HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable};
use quickmark_linter::config::{HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable};
use quickmark_linter::linter::{CharPosition, Range};
use quickmark_linter::rules::{md001::MD001, md003::MD003};

Expand All @@ -97,6 +94,7 @@ mod tests {
heading_style: MD003HeadingStyleTable {
style: HeadingStyle::Consistent,
},
line_length: MD013LineLengthTable::default(),
},
},
};
Expand Down
55 changes: 54 additions & 1 deletion crates/quickmark_config/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use anyhow::Result;
use quickmark_linter::config::{
normalize_severities, HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable,
normalize_severities, HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable,
QuickmarkConfig, RuleSeverity,
};
use serde::Deserialize;
Expand Down Expand Up @@ -38,11 +38,42 @@ struct TomlMD003HeadingStyleTable {
style: TomlHeadingStyle,
}

#[derive(Deserialize)]
#[derive(Default)]
struct TomlMD013LineLengthTable {
#[serde(default = "default_line_length")]
line_length: usize,
#[serde(default = "default_code_block_line_length")]
code_block_line_length: usize,
#[serde(default = "default_heading_line_length")]
heading_line_length: usize,
#[serde(default = "default_true")]
code_blocks: bool,
#[serde(default = "default_true")]
headings: bool,
#[serde(default = "default_true")]
tables: bool,
#[serde(default = "default_false")]
strict: bool,
#[serde(default = "default_false")]
stern: bool,
}

fn default_line_length() -> usize { 80 }
fn default_code_block_line_length() -> usize { 80 }
fn default_heading_line_length() -> usize { 80 }
fn default_true() -> bool { true }
fn default_false() -> bool { false }

#[derive(Deserialize)]
#[derive(Default)]
struct TomlLintersSettingsTable {
#[serde(rename = "heading-style")]
#[serde(default)]
heading_style: TomlMD003HeadingStyleTable,
#[serde(rename = "line-length")]
#[serde(default)]
line_length: TomlMD013LineLengthTable,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -105,6 +136,16 @@ pub fn parse_toml_config(config_str: &str) -> Result<QuickmarkConfig> {
heading_style: MD003HeadingStyleTable {
style: convert_toml_heading_style(toml_config.linters.settings.heading_style.style),
},
line_length: MD013LineLengthTable {
line_length: toml_config.linters.settings.line_length.line_length,
code_block_line_length: toml_config.linters.settings.line_length.code_block_line_length,
heading_line_length: toml_config.linters.settings.line_length.heading_line_length,
code_blocks: toml_config.linters.settings.line_length.code_blocks,
headings: toml_config.linters.settings.line_length.headings,
tables: toml_config.linters.settings.line_length.tables,
strict: toml_config.linters.settings.line_length.strict,
stern: toml_config.linters.settings.line_length.stern,
},
},
}))
}
Expand Down Expand Up @@ -244,4 +285,16 @@ mod tests {
parsed.linters.settings.heading_style.style
);
}

#[test]
fn test_parse_toml_config_with_line_length() {
let config_str = r#"
[linters.settings.line-length]
line_length = 50
"#;

let parsed = parse_toml_config(config_str).unwrap();
assert_eq!(50, parsed.linters.settings.line_length.line_length);
assert_eq!(80, parsed.linters.settings.line_length.code_block_line_length); // default
}
}
31 changes: 30 additions & 1 deletion crates/quickmark_linter/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,37 @@ impl Default for MD003HeadingStyleTable {
}
}

#[derive(Debug, PartialEq, Clone)]
pub struct MD013LineLengthTable {
pub line_length: usize,
pub code_block_line_length: usize,
pub heading_line_length: usize,
pub code_blocks: bool,
pub headings: bool,
pub tables: bool,
pub strict: bool,
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,
}
}
}

#[derive(Debug, Default, PartialEq, Clone)]
pub struct LintersSettingsTable {
pub heading_style: MD003HeadingStyleTable,
pub line_length: MD013LineLengthTable,
}

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

use crate::config::{
HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, RuleSeverity,
HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable, RuleSeverity,
};

use super::{normalize_severities, QuickmarkConfig};
Expand Down Expand Up @@ -131,6 +159,7 @@ mod test {
heading_style: MD003HeadingStyleTable {
style: HeadingStyle::ATX,
},
line_length: MD013LineLengthTable::default(),
},
});

Expand Down
29 changes: 29 additions & 0 deletions crates/quickmark_linter/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
//! # QuickMark Linter Core
//!
//! ## Single-Use Architecture Contract
//!
//! **IMPORTANT**: All linter components in this crate follow a strict single-use contract:
//!
//! - **Context**: One context instance per document analysis
//! - **MultiRuleLinter**: One linter instance per document analysis
//! - **RuleLinter**: Individual rule linters are used once and discarded
//!
//! This design eliminates state management complexity.
//!
//! ### Usage Pattern
//! ```rust,no_run
//! use quickmark_linter::linter::MultiRuleLinter;
//! use quickmark_linter::config::QuickmarkConfig;
//! use std::path::PathBuf;
//!
//! // Example usage (variables would be provided by your application)
//! # let path = PathBuf::new();
//! # let config: QuickmarkConfig = unimplemented!();
//! # let source = "";
//!
//! // Correct: Fresh instances for each document
//! let mut linter = MultiRuleLinter::new_for_document(path, config, source);
//! let violations = linter.analyze();
//! // linter is now invalid - create new one for next document
//! ```

pub mod config;
pub mod linter;
pub mod rules;
Expand Down
Loading