diff --git a/.claude/commands/port_rule.md b/.claude/commands/port_rule.md new file mode 100644 index 0000000..d10cd27 --- /dev/null +++ b/.claude/commands/port_rule.md @@ -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` diff --git a/CLAUDE.md b/CLAUDE.md index c3f5212..2dadfee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -138,7 +248,7 @@ quickmark/ **Shared Context**: `Rc` 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. @@ -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` diff --git a/crates/quickmark/src/main.rs b/crates/quickmark/src/main.rs index f7aa35a..1cecdc4 100644 --- a/crates/quickmark/src/main.rs +++ b/crates/quickmark/src/main.rs @@ -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)] @@ -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); } @@ -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}; @@ -97,6 +94,7 @@ mod tests { heading_style: MD003HeadingStyleTable { style: HeadingStyle::Consistent, }, + line_length: MD013LineLengthTable::default(), }, }, }; diff --git a/crates/quickmark_config/src/lib.rs b/crates/quickmark_config/src/lib.rs index 8880e71..623c38f 100644 --- a/crates/quickmark_config/src/lib.rs +++ b/crates/quickmark_config/src/lib.rs @@ -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; @@ -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)] @@ -105,6 +136,16 @@ pub fn parse_toml_config(config_str: &str) -> Result { 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, + }, }, })) } @@ -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 + } } diff --git a/crates/quickmark_linter/src/config/mod.rs b/crates/quickmark_linter/src/config/mod.rs index 692af85..a329796 100644 --- a/crates/quickmark_linter/src/config/mod.rs +++ b/crates/quickmark_linter/src/config/mod.rs @@ -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)] @@ -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}; @@ -131,6 +159,7 @@ mod test { heading_style: MD003HeadingStyleTable { style: HeadingStyle::ATX, }, + line_length: MD013LineLengthTable::default(), }, }); diff --git a/crates/quickmark_linter/src/lib.rs b/crates/quickmark_linter/src/lib.rs index 9af2a60..97a29da 100644 --- a/crates/quickmark_linter/src/lib.rs +++ b/crates/quickmark_linter/src/lib.rs @@ -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; diff --git a/crates/quickmark_linter/src/linter.rs b/crates/quickmark_linter/src/linter.rs index cbefef9..9dc602a 100644 --- a/crates/quickmark_linter/src/linter.rs +++ b/crates/quickmark_linter/src/linter.rs @@ -1,4 +1,4 @@ -use std::{fmt::Display, path::PathBuf, rc::Rc}; +use std::{cell::RefCell, collections::HashMap, fmt::Display, path::PathBuf, rc::Rc}; use tree_sitter::{Node, Parser}; use tree_sitter_md::LANGUAGE; @@ -91,49 +91,243 @@ impl Display for RuleViolation { } } +/// **SINGLE-USE CONTRACT**: Context instances are designed for one-time use only. +/// +/// Each Context instance should be used to analyze exactly one source document. +/// The lazy initialization of caches (lines, node_cache) happens once and the +/// context becomes immutable after that point. +/// #[derive(Debug)] pub struct Context { pub file_path: PathBuf, pub config: QuickmarkConfig, + /// Raw text lines for line-based rules (MD013, MD010, etc.) - initialized once per document + pub lines: RefCell>, + /// Cached AST nodes filtered by type for efficient access - initialized once per document + pub node_cache: RefCell>>, + /// Original document content for byte-based access - initialized once per document + pub document_content: RefCell, } +/// Lightweight node information for caching +#[derive(Debug, Clone)] +pub struct NodeInfo { + pub line_start: usize, + pub line_end: usize, + pub kind: String, +} + +impl Context { + pub fn new(file_path: PathBuf, config: QuickmarkConfig, source: &str, root_node: &Node) -> Self { + let lines: Vec = source.lines().map(|s| s.to_string()).collect(); + let node_cache = Self::build_node_cache(root_node); + + Self { + file_path, + config, + lines: RefCell::new(lines), + node_cache: RefCell::new(node_cache), + document_content: RefCell::new(source.to_string()), + } + } + + /// Get the full document content as a string reference + /// Returns a reference to the original document content stored during initialization + pub fn get_document_content(&self) -> std::cell::Ref { + self.document_content.borrow() + } + + /// Build cache of nodes filtered by type for efficient rule access + fn build_node_cache(root_node: &Node) -> HashMap> { + let mut cache = HashMap::new(); + Self::collect_nodes_recursive(root_node, &mut cache); + cache + } + + fn collect_nodes_recursive(node: &Node, cache: &mut HashMap>) { + let node_info = NodeInfo { + line_start: node.start_position().row, + line_end: node.end_position().row, + kind: node.kind().to_string(), + }; + + // Add to cache for this node type + cache.entry(node.kind().to_string()) + .or_default() + .push(node_info.clone()); + + // Add to cache for pattern-based lookups (e.g., all heading types) + if node.kind().contains("heading") { + cache.entry("*heading*".to_string()) + .or_default() + .push(node_info.clone()); + } + + // Recursively process children + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + Self::collect_nodes_recursive(&child, cache); + } + } + } + + /// Get cached nodes of specific types - optimized equivalent of filterByTypesCached + pub fn get_nodes(&self, node_types: &[&str]) -> Vec { + let cache = self.node_cache.borrow(); + let mut result = Vec::new(); + for node_type in node_types { + if let Some(nodes) = cache.get(*node_type) { + result.extend(nodes.iter().cloned()); + } + } + result + } + + /// Get the most specific node type that contains a given line number + pub fn get_node_type_for_line(&self, line_number: usize) -> String { + let cache = self.node_cache.borrow(); + // Find the most specific (smallest range) node that contains this line + let mut best_match: Option<&NodeInfo> = None; + let mut smallest_range = usize::MAX; + + for nodes in cache.values() { + for node in nodes { + if line_number >= node.line_start && line_number <= node.line_end { + let range_size = node.line_end - node.line_start; + if range_size < smallest_range { + smallest_range = range_size; + best_match = Some(node); + } + } + } + } + + best_match.map(|n| n.kind.clone()).unwrap_or_else(|| "text".to_string()) + } +} + +/// **SINGLE-USE CONTRACT**: RuleLinter instances are designed for one-time use only. +/// +/// Each RuleLinter instance should be used to analyze exactly one source document +/// and then discarded. This eliminates the complexity of state management and cleanup: +/// +/// - No reset/cleanup methods needed +/// - No state contamination between different documents +/// - Simpler, more predictable behavior +/// +/// After calling `analyze()` on a `MultiRuleLinter`, the entire linter and all its +/// rule instances become invalid and should not be reused. +/// +/// ## Usage Pattern +/// ```rust,no_run +/// # use quickmark_linter::linter::MultiRuleLinter; +/// # use quickmark_linter::config::QuickmarkConfig; +/// # use std::path::PathBuf; +/// # let path = PathBuf::new(); +/// # let config: QuickmarkConfig = unimplemented!(); +/// # let source1 = ""; +/// # let source2 = ""; +/// +/// // Correct: Create fresh linter for each document +/// let mut linter1 = MultiRuleLinter::new_for_document(path.clone(), config.clone(), source1); +/// let violations1 = linter1.analyze(); // Use once, then discard +/// +/// // Create new linter for next document +/// let mut linter2 = MultiRuleLinter::new_for_document(path, config, source2); +/// let violations2 = linter2.analyze(); // Fresh linter, no contamination +/// ``` pub trait RuleLinter { - fn feed(&mut self, node: &Node, source: &str) -> Option; + /// Process a single AST node and potentially return a violation. + /// + /// **CONTRACT**: This method will be called exactly once per AST node + /// for a single document analysis session. Rule linters have access to the + /// document content and parsed data through their initialized Context. + fn feed(&mut self, node: &Node) -> Option; + + /// Called after all nodes have been processed to collect any remaining violations. + /// This is essential for rules that generate more violations than there are AST nodes. + /// + /// **CONTRACT**: This method will be called exactly once at the end of document analysis. + fn finalize(&mut self) -> Vec { + Vec::new() // Default implementation for rules that don't need finalization + } } +/// **SINGLE-USE CONTRACT**: MultiRuleLinter instances are designed for one-time use only. +/// +/// Create a fresh MultiRuleLinter for each document you want to analyze using `new_for_document()`. +/// After calling `analyze()`, the linter and all its rule instances should be discarded. pub struct MultiRuleLinter { linters: Vec>, + tree: tree_sitter::Tree, } impl MultiRuleLinter { - pub fn new(context: Rc) -> Self { - Self { - linters: ALL_RULES - .iter() - .filter(|r| { - *context.config.linters.severity.get(r.alias).unwrap() != RuleSeverity::Off - }) - .map(|r| ((r.new_linter)(context.clone()))) - .collect(), - } - } - - pub fn lint(&mut self, document: &str) -> Vec { + /// **SINGLE-USE API ENFORCEMENT**: Create a MultiRuleLinter bound to a specific document. + /// + /// This constructor enforces the single-use contract by: + /// 1. Taking the document content immediately + /// 2. Parsing and initializing the context cache upfront + /// 3. Creating rule linters with pre-initialized context + /// 4. Making the linter ready for immediate use with `analyze()` + /// + /// After calling `analyze()`, this linter instance should be discarded. + pub fn new_for_document( + file_path: PathBuf, + config: QuickmarkConfig, + document: &str, + ) -> Self { + // Parse the document immediately let mut parser = Parser::new(); parser .set_language(&LANGUAGE.into()) .expect("Error loading Markdown grammar"); let tree = parser.parse(document, None).expect("Parse failed"); + // Create context with pre-initialized cache + let context = Rc::new(Context::new( + file_path, + config, + document, + &tree.root_node(), + )); + + // Create rule linters with fully-initialized context + let linters = ALL_RULES + .iter() + .filter(|r| { + context.config.linters.severity.get(r.alias) + .map(|severity| *severity != RuleSeverity::Off) + .unwrap_or(false) + }) + .map(|r| ((r.new_linter)(context.clone()))) + .collect(); + + Self { linters, tree } + } + + /// Analyze the document that was provided during construction. + /// + /// **SINGLE-USE CONTRACT**: This method should be called exactly once. + /// After calling this method, the linter instance should be discarded. + pub fn analyze(&mut self) -> Vec { let mut violations = Vec::new(); - let walker = TreeSitterWalker::new(&tree); - walker.walk(|_node| { + let walker = TreeSitterWalker::new(&self.tree); + + walker.walk(|node| { let node_violations = self .linters .iter_mut() - .filter_map(|linter| linter.feed(&_node, document)) + .filter_map(|linter| linter.feed(&node)) .collect::>(); violations.extend(node_violations); }); + + // Collect any remaining violations from finalize + for linter in &mut self.linters { + let remaining_violations = linter.finalize(); + violations.extend(remaining_violations); + } + violations } } @@ -145,37 +339,33 @@ mod test { use crate::{ config::{self, QuickmarkConfig, RuleSeverity}, - rules::{md001::MD001, md003::MD003}, + rules::{md001::MD001, md003::MD003, md013::MD013}, }; - use super::{Context, MultiRuleLinter}; + use super::MultiRuleLinter; #[test] fn test_multiple_violations() { - use std::rc::Rc; let severity: HashMap<_, _> = vec![ (MD001.alias.to_string(), RuleSeverity::Error), (MD003.alias.to_string(), RuleSeverity::Error), + (MD013.alias.to_string(), RuleSeverity::Error), ] .into_iter() .collect(); - let context = Rc::new(Context { - file_path: PathBuf::from("test.md"), - config: QuickmarkConfig { - linters: config::LintersTable { - severity, - settings: config::LintersSettingsTable { - heading_style: config::MD003HeadingStyleTable { - style: config::HeadingStyle::ATX, - }, + let config = QuickmarkConfig { + linters: config::LintersTable { + severity, + settings: config::LintersSettingsTable { + heading_style: config::MD003HeadingStyleTable { + style: config::HeadingStyle::ATX, }, + line_length: config::MD013LineLengthTable::default(), }, }, - }); - - let mut linter = MultiRuleLinter::new(context); + }; // This creates a setext h1 after an ATX h1, which should violate: // MD003: mixes ATX and setext styles when ATX is enforced @@ -187,7 +377,8 @@ Second heading #### Fourth level "; - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!( 2, violations.len(), diff --git a/crates/quickmark_linter/src/rules/md001.rs b/crates/quickmark_linter/src/rules/md001.rs index a4cb5db..8767e22 100644 --- a/crates/quickmark_linter/src/rules/md001.rs +++ b/crates/quickmark_linter/src/rules/md001.rs @@ -4,7 +4,7 @@ use tree_sitter::Node; use crate::{ linter::{range_from_tree_sitter, RuleViolation}, - rules::{Context, Rule, RuleLinter}, + rules::{Context, Rule, RuleLinter, RuleType}, }; pub(crate) struct MD001Linter { @@ -51,7 +51,7 @@ fn extract_heading_level(node: &Node) -> u8 { } impl RuleLinter for MD001Linter { - fn feed(&mut self, node: &Node, _source: &str) -> Option { + fn feed(&mut self, node: &Node) -> Option { if node.kind() == "atx_heading" || node.kind() == "setext_heading" { let level = extract_heading_level(node); @@ -81,6 +81,8 @@ pub const MD001: Rule = Rule { alias: "heading-increment", tags: &["headings"], description: "Heading levels should only increment by one level at a time", + rule_type: RuleType::Token, + required_nodes: &["atx_heading", "setext_heading"], new_linter: |context| Box::new(MD001Linter::new(context)), }; @@ -88,36 +90,31 @@ pub const MD001: Rule = Rule { mod test { use std::collections::HashMap; use std::path::PathBuf; - use std::rc::Rc; use crate::config::{ - HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, QuickmarkConfig, + HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable, QuickmarkConfig, RuleSeverity, }; use crate::linter::MultiRuleLinter; - use crate::rules::Context; - fn test_context() -> Rc { + fn test_config() -> QuickmarkConfig { let severity: HashMap<_, _> = vec![ ("heading-style".to_string(), RuleSeverity::Off), ("heading-increment".to_string(), RuleSeverity::Error), ] .into_iter() .collect(); - Context { - file_path: PathBuf::from("test.md"), - config: QuickmarkConfig { - linters: LintersTable { - severity, - settings: LintersSettingsTable { - heading_style: MD003HeadingStyleTable { - style: HeadingStyle::Consistent, - }, + QuickmarkConfig { + linters: LintersTable { + severity, + settings: LintersSettingsTable { + heading_style: MD003HeadingStyleTable { + style: HeadingStyle::Consistent, }, + line_length: MD013LineLengthTable::default(), }, }, } - .into() } #[test] @@ -133,8 +130,9 @@ foobar ### Heading level 3 "; - let mut linter = MultiRuleLinter::new(test_context()); - let violations = linter.lint(input); + let config = test_config(); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(2, violations.len()); let mut iter = violations.iter(); let range1 = &iter.next().unwrap().location().range; @@ -164,8 +162,9 @@ foobar ###### Heading level 6 "; - let mut linter = MultiRuleLinter::new(test_context()); - let violations = linter.lint(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()); } @@ -183,8 +182,9 @@ foobar # level 1 "; - let mut linter = MultiRuleLinter::new(test_context()); - let violations = linter.lint(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()); } @@ -199,8 +199,9 @@ some text some other text "; - let mut linter = MultiRuleLinter::new(test_context()); - let violations = linter.lint(input); + let config = test_config(); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should trigger a violation: setext h1 -> atx h3 (skips h2) assert_eq!(1, violations.len()); let range = &violations[0].location().range; @@ -220,8 +221,9 @@ Heading level 2 some other text "; - let mut linter = MultiRuleLinter::new(test_context()); - let violations = linter.lint(input); + let config = test_config(); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should be no violations: setext h1 -> setext h2 assert_eq!(0, violations.len()); } diff --git a/crates/quickmark_linter/src/rules/md003.rs b/crates/quickmark_linter/src/rules/md003.rs index a40bee0..71f1239 100644 --- a/crates/quickmark_linter/src/rules/md003.rs +++ b/crates/quickmark_linter/src/rules/md003.rs @@ -7,7 +7,7 @@ use crate::{ linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation}, }; -use super::Rule; +use super::{Rule, RuleType}; #[derive(PartialEq, Debug)] enum Style { @@ -76,7 +76,9 @@ impl MD003Linter { } } - fn is_atx_closed(&self, node: &Node, source: &str) -> bool { + fn is_atx_closed(&self, node: &Node) -> bool { + let source = self.context.get_document_content(); + // Extract the text content of the heading from the source let start_byte = node.start_byte(); let end_byte = node.end_byte(); @@ -102,11 +104,11 @@ impl MD003Linter { } impl RuleLinter for MD003Linter { - fn feed(&mut self, node: &Node, source: &str) -> Option { + fn feed(&mut self, node: &Node) -> Option { let style = match node.kind() { "atx_heading" => { // Check if it's closed (has closing hashes) - if self.is_atx_closed(node, source) { + if self.is_atx_closed(node) { Some(Style::AtxClosed) } else { Some(Style::Atx) @@ -162,6 +164,8 @@ pub const MD003: Rule = Rule { alias: "heading-style", tags: &["headings"], description: "Heading style", + rule_type: RuleType::Token, + required_nodes: &["atx_heading", "setext_heading"], new_linter: |context| Box::new(MD003Linter::new(context)), }; @@ -169,40 +173,35 @@ pub const MD003: Rule = Rule { mod test { use std::collections::HashMap; use std::path::PathBuf; - use std::rc::Rc; use crate::config::{ - HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, QuickmarkConfig, + HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable, QuickmarkConfig, RuleSeverity, }; use crate::linter::MultiRuleLinter; - use crate::rules::Context; - fn test_context(style: HeadingStyle) -> Rc { + fn test_config(style: HeadingStyle) -> QuickmarkConfig { let severity: HashMap<_, _> = vec![ ("heading-style".to_string(), RuleSeverity::Error), ("heading-increment".to_string(), RuleSeverity::Off), ] .into_iter() .collect(); - Context { - file_path: PathBuf::from("test.md"), - config: QuickmarkConfig { - linters: LintersTable { - severity, - settings: LintersSettingsTable { - heading_style: MD003HeadingStyleTable { style }, - }, + QuickmarkConfig { + linters: LintersTable { + severity, + settings: LintersSettingsTable { + heading_style: MD003HeadingStyleTable { style }, + line_length: MD013LineLengthTable::default(), }, }, } - .into() } #[test] fn test_heading_style_consistent_positive() { - let context = test_context(HeadingStyle::Consistent); + let config = test_config(HeadingStyle::Consistent); let input = " Setext level 1 @@ -212,14 +211,14 @@ Setext level 2 ### ATX header level 3 #### ATX header level 4 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 2); } #[test] fn test_heading_style_consistent_negative_setext() { - let context = test_context(HeadingStyle::Consistent); + let config = test_config(HeadingStyle::Consistent); let input = " Setext level 1 @@ -227,28 +226,28 @@ Setext level 1 Setext level 2 ============== "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 0); } #[test] fn test_heading_style_consistent_negative_atx() { - let context = test_context(HeadingStyle::Consistent); + let config = test_config(HeadingStyle::Consistent); let input = " # Atx heading 1 ## Atx heading 2 ### Atx heading 3 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 0); } #[test] fn test_heading_style_atx_positive() { - let context = test_context(HeadingStyle::ATX); + let config = test_config(HeadingStyle::ATX); let input = " Setext heading 1 @@ -257,28 +256,28 @@ Setext heading 2 ================ ### Atx heading 3 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 2); } #[test] fn test_heading_style_atx_negative() { - let context = test_context(HeadingStyle::ATX); + let config = test_config(HeadingStyle::ATX); let input = " # Atx heading 1 ## Atx heading 2 ### Atx heading 3 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 0); } #[test] fn test_heading_style_setext_positive() { - let context = test_context(HeadingStyle::Setext); + let config = test_config(HeadingStyle::Setext); let input = " # Atx heading 1 @@ -288,14 +287,14 @@ Setext heading 2 ================ ### Atx heading 3 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 2); } #[test] fn test_heading_style_setext_negative() { - let context = test_context(HeadingStyle::Setext); + let config = test_config(HeadingStyle::Setext); let input = " Setext heading 1 @@ -305,42 +304,42 @@ Setext heading 2 Setext heading 2 ================ "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 0); } #[test] fn test_heading_style_atx_closed_positive() { - let context = test_context(HeadingStyle::ATXClosed); + let config = test_config(HeadingStyle::ATXClosed); let input = " # Open ATX heading 1 ## Open ATX heading 2 ## ### ATX closed heading 3 ### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 1); } #[test] fn test_heading_style_atx_closed_negative() { - let context = test_context(HeadingStyle::ATXClosed); + let config = test_config(HeadingStyle::ATXClosed); let input = " # ATX closed heading 1 # ## ATX closed heading 2 ## ### ATX closed heading 3 ### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); assert_eq!(violations.len(), 0); } #[test] fn test_heading_style_setext_with_atx_positive() { - let context = test_context(HeadingStyle::SetextWithATX); + let config = test_config(HeadingStyle::SetextWithATX); let input = " Setext heading 1 @@ -348,8 +347,8 @@ Setext heading 1 # Open ATX heading 2 ## ATX closed heading 3 ## "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Level-based: setext h2 should be used for level 2, open ATX for level 3 // Violations: ATX heading at level 2, closed ATX at level 3 assert_eq!(violations.len(), 2); @@ -357,7 +356,7 @@ Setext heading 1 #[test] fn test_heading_style_setext_with_atx_negative() { - let context = test_context(HeadingStyle::SetextWithATX); + let config = test_config(HeadingStyle::SetextWithATX); let input = " Setext heading 1 @@ -366,15 +365,15 @@ Setext heading 2 ---------------- ### Open ATX heading 3 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Level-based: setext for 1-2, open ATX for 3+ - all correct assert_eq!(violations.len(), 0); } #[test] fn test_heading_style_setext_with_atx_closed_positive() { - let context = test_context(HeadingStyle::SetextWithATXClosed); + let config = test_config(HeadingStyle::SetextWithATXClosed); let input = " Setext heading 1 @@ -382,8 +381,8 @@ Setext heading 1 # Open ATX heading 2 ### Open ATX heading 3 "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Level-based: setext for 1-2, closed ATX for 3+ // Violations: open ATX at level 2, open ATX at level 3 (should be closed) assert_eq!(violations.len(), 2); @@ -391,7 +390,7 @@ Setext heading 1 #[test] fn test_heading_style_setext_with_atx_closed_negative() { - let context = test_context(HeadingStyle::SetextWithATXClosed); + let config = test_config(HeadingStyle::SetextWithATXClosed); let input = " Setext heading 1 @@ -400,15 +399,15 @@ Setext heading 2 ---------------- ### ATX closed heading 3 ### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Level-based: setext for 1-2, closed ATX for 3+ - all correct assert_eq!(violations.len(), 0); } #[test] fn test_setext_with_atx_level_violations_comprehensive() { - let context = test_context(HeadingStyle::SetextWithATX); + let config = test_config(HeadingStyle::SetextWithATX); let input = " # Level 1 ATX (should be setext) @@ -416,8 +415,8 @@ Setext heading 2 ### Level 3 ATX closed (should be open ATX) ### #### Level 4 ATX closed (should be open ATX) #### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Expect 4 violations: 2 for wrong style at levels 1-2, 2 for closed ATX at levels 3-4 assert_eq!(violations.len(), 4); @@ -430,7 +429,7 @@ Setext heading 2 #[test] fn test_setext_with_atx_correct_level_usage() { - let context = test_context(HeadingStyle::SetextWithATX); + let config = test_config(HeadingStyle::SetextWithATX); let input = " Main Title @@ -444,15 +443,15 @@ Subtitle ##### Level 5 Open ATX ###### Level 6 Open ATX "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should have no violations - correct level-based usage assert_eq!(violations.len(), 0); } #[test] fn test_setext_with_atx_closed_level_violations_comprehensive() { - let context = test_context(HeadingStyle::SetextWithATXClosed); + let config = test_config(HeadingStyle::SetextWithATXClosed); let input = " # Level 1 ATX (should be setext) @@ -461,8 +460,8 @@ Subtitle #### Level 4 open ATX (should be closed ATX) ##### Level 5 closed ATX is correct ##### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Expect 4 violations: 2 for wrong style at levels 1-2, 2 for open ATX at levels 3-4 assert_eq!(violations.len(), 4); @@ -475,7 +474,7 @@ Subtitle #[test] fn test_setext_with_atx_closed_correct_level_usage() { - let context = test_context(HeadingStyle::SetextWithATXClosed); + let config = test_config(HeadingStyle::SetextWithATXClosed); let input = " Main Title @@ -489,15 +488,15 @@ Subtitle ##### Level 5 Closed ATX ##### ###### Level 6 Closed ATX ###### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should have no violations - correct level-based usage assert_eq!(violations.len(), 0); } #[test] fn test_mixed_atx_styles_comprehensive() { - let context = test_context(HeadingStyle::ATXClosed); + let config = test_config(HeadingStyle::ATXClosed); let input = " # Open ATX 1 @@ -507,8 +506,8 @@ Subtitle ##### Open ATX 5 ###### Closed ATX 6 ###### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Expect 3 violations for open ATX headings (levels 1, 3, 5) assert_eq!(violations.len(), 3); @@ -519,7 +518,7 @@ Subtitle #[test] fn test_consistent_style_with_mixed_atx_variations() { - let context = test_context(HeadingStyle::Consistent); + let config = test_config(HeadingStyle::Consistent); let input = " # First heading (sets the standard) @@ -529,8 +528,8 @@ Subtitle Setext heading ============== "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Expect 2 violations: closed ATX and setext (both different from first open ATX) assert_eq!(violations.len(), 2); @@ -540,7 +539,7 @@ Setext heading #[test] fn test_file_without_trailing_newline_edge_case() { - let context = test_context(HeadingStyle::Setext); + let config = test_config(HeadingStyle::Setext); // Test string without trailing newline (like our original issue) let input = "# ATX heading 1 @@ -548,8 +547,8 @@ Setext heading Final setext heading --------------------"; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should catch all 3 violations, including the final setext heading assert_eq!(violations.len(), 2); // Only ATX headings violate setext rule @@ -560,7 +559,7 @@ Final setext heading #[test] fn test_mix_of_styles() { - let context = test_context(HeadingStyle::SetextWithATX); + let config = test_config(HeadingStyle::SetextWithATX); let input = "# Open ATX heading level 1 @@ -586,8 +585,8 @@ Another setext heading Final setext heading -------------------- "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // - Level 1 ATX should be setext (1 violation) // - Level 2 ATX should be setext (2 violations) // - Level 3+ closed ATX should be open ATX (2 violations) @@ -598,7 +597,7 @@ Final setext heading #[test] fn test_atx_closed_detection_comprehensive() { - let context = test_context(HeadingStyle::ATXClosed); + let config = test_config(HeadingStyle::ATXClosed); let input = "# Open ATX # Open ATX with spaces @@ -608,8 +607,8 @@ Final setext heading ##### Closed ATX no spaces ##### ###### Mixed closing hashes ########## "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should detect 3 open ATX violations (lines 1, 2, 3) assert_eq!(violations.len(), 3); @@ -621,7 +620,7 @@ Final setext heading #[test] fn test_atx_closed_detection_edge_cases() { - let context = test_context(HeadingStyle::ATX); + let config = test_config(HeadingStyle::ATX); let input = "# Regular ATX ## Closed ATX ## @@ -630,8 +629,8 @@ Final setext heading ##### Text ending with hash# ###### Actually closed ###### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Lines ending with # are considered closed: 2, 3, 5, 6 // So we expect 4 violations for closed ATX when expecting open ATX @@ -644,7 +643,7 @@ Final setext heading #[test] fn test_whitespace_handling_in_atx_closed_detection() { - let context = test_context(HeadingStyle::ATXClosed); + let config = test_config(HeadingStyle::ATXClosed); let input = "# Open ATX ## Closed with trailing spaces ## @@ -652,8 +651,8 @@ Final setext heading #### Open with trailing spaces ##### Closed no spaces ##### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should detect 2 open ATX violations (lines 1 and 4) assert_eq!(violations.len(), 2); @@ -665,7 +664,7 @@ Final setext heading #[test] fn test_setext_only_supports_levels_1_and_2() { - let context = test_context(HeadingStyle::Setext); + let config = test_config(HeadingStyle::Setext); let input = "Setext Level 1 ============== @@ -676,8 +675,8 @@ Setext Level 2 ### Level 3 must be ATX ### #### Level 4 must be ATX #### "; - let mut linter = MultiRuleLinter::new(context); - let violations = linter.lint(input); + let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input); + let violations = linter.analyze(); // Should detect 2 violations for ATX headings at levels 3-4 assert_eq!(violations.len(), 2); diff --git a/crates/quickmark_linter/src/rules/md013.rs b/crates/quickmark_linter/src/rules/md013.rs new file mode 100644 index 0000000..b52fef4 --- /dev/null +++ b/crates/quickmark_linter/src/rules/md013.rs @@ -0,0 +1,691 @@ +use std::{cell::RefCell, rc::Rc}; + +use tree_sitter::Node; + +use crate::{ + linter::{range_from_tree_sitter, RuleViolation}, + rules::{Context, Rule, RuleLinter, RuleType}, +}; + +/// MD013 Line Length Rule Linter +/// +/// **SINGLE-USE CONTRACT**: This linter is designed for one-time use only. +/// After processing a document (via feed() calls and finalize()), the linter +/// should be discarded. The pending_violations state is not cleared between uses. +pub(crate) struct MD013Linter { + context: Rc, + pending_violations: RefCell>, +} + +impl MD013Linter { + pub fn new(context: Rc) -> Self { + Self { + context, + pending_violations: RefCell::new(Vec::new()), + } + } + + /// Analyze all lines and store all violations for reporting via finalize() + /// Context cache is already initialized by MultiRuleLinter + fn analyze_all_lines(&self) { + let lines = self.context.lines.borrow(); + let mut violations = Vec::new(); + + 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); + violations.push(violation); + } + } + + *self.pending_violations.borrow_mut() = violations; + } + + 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(); + // Check for standalone link: [text](url) + if trimmed.starts_with('[') && trimmed.contains("](") && trimmed.ends_with(')') { + return true; + } + // Check for standalone image: ![alt](url) + if trimmed.starts_with("![") && trimmed.contains("](") && 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 beyond_limit = &line[limit..]; + !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 { + // Heading nodes + 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, + // Code block nodes + "fenced_code_block" | "indented_code_block" | "code_fence_content" => settings.code_blocks, + // Table nodes + "table" | "table_row" => settings.tables, + _ => true, // Check regular text content + } + } + + fn is_heading_line(&self, line: &str) -> bool { + let trimmed = line.trim_start(); + // ATX headings start with # + 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 { + // Heading nodes + 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, + // Code block nodes + "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; + + // Check if this is a heading line and headings are disabled + if self.is_heading_line(line) && !settings.headings { + return false; + } + + // Skip if this node type shouldn't be checked + if !self.should_check_node_type(node_kind) { + return false; + } + + let limit = self.get_line_limit(node_kind); + + // Check if line exceeds limit + if line.len() <= limit { + return false; + } + + // Apply exceptions + if self.is_link_reference_definition(line) { + return false; + } + + if self.is_standalone_link_or_image(line) { + return false; + } + + // Strict mode: all lines beyond limit are violations + if settings.strict { + return true; + } + + // Stern mode: more aggressive than default, but allows lines without spaces beyond limit + if settings.stern { + // In stern mode, allow lines without spaces beyond limit (like default) + // but be more strict about other cases + if self.has_no_spaces_beyond_limit(line, limit) { + return false; + } + // If there are spaces beyond limit, it's a violation in stern mode + return true; + } + + // Default mode: allow lines without spaces beyond the limit + 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) -> Option { + // Analyze all lines when we see the document node + // Context cache is already initialized by MultiRuleLinter + if node.kind() == "document" { + self.analyze_all_lines(); + } + + // Don't return violations during feed - save them for finalize + None + } + + fn finalize(&mut self) -> Vec { + // Return all pending violations at once + std::mem::take(&mut *self.pending_violations.borrow_mut()) + } +} + +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: &[], // Line-based rules don't require specific nodes + new_linter: |context| Box::new(MD013Linter::new(context)), +}; + +#[cfg(test)] +mod test { + use std::collections::HashMap; + use std::path::PathBuf; + + use crate::config::{ + HeadingStyle, LintersSettingsTable, LintersTable, MD003HeadingStyleTable, MD013LineLengthTable, QuickmarkConfig, + RuleSeverity, + }; + use crate::linter::MultiRuleLinter; + + fn test_config() -> QuickmarkConfig { + let severity: HashMap<_, _> = vec![ + ("heading-style".to_string(), RuleSeverity::Off), + ("heading-increment".to_string(), RuleSeverity::Off), + ("line-length".to_string(), RuleSeverity::Error), + ] + .into_iter() + .collect(); + QuickmarkConfig { + linters: LintersTable { + severity, + settings: LintersSettingsTable { + heading_style: MD003HeadingStyleTable { + style: HeadingStyle::Consistent, + }, + line_length: MD013LineLengthTable::default(), + }, + }, + } + } + + fn test_config_with_line_length(line_length_config: MD013LineLengthTable) -> QuickmarkConfig { + let severity: HashMap<_, _> = vec![ + ("heading-style".to_string(), RuleSeverity::Off), + ("heading-increment".to_string(), RuleSeverity::Off), + ("line-length".to_string(), RuleSeverity::Error), + ] + .into_iter() + .collect(); + QuickmarkConfig { + linters: LintersTable { + severity, + settings: LintersSettingsTable { + heading_style: MD003HeadingStyleTable { + style: HeadingStyle::Consistent, + }, + line_length: line_length_config, + }, + }, + } + } + + #[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 = "![This is a very long image alt text that definitely exceeds eighty characters](https://example.com/image.jpg)"; + + 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() { + // Create a string that exceeds 80 chars with a space beyond the limit + let mut input = "This line has exactly eighty characters and should trigger violation".to_string(); + while input.len() < 80 { + input.push('x'); + } + input.push(' '); // Add space beyond limit + + 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()); // Should violate in strict mode + } + + #[test] + fn test_stern_mode_with_spaces_beyond_limit() { + let config = MD013LineLengthTable { + stern: true, + ..MD013LineLengthTable::default() + }; + + // Line with spaces beyond limit - should violate in stern mode + // Make sure the line has exactly 80 chars, then add text with spaces beyond that + 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"); // Add spaces beyond limit + + 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()); // Should violate in stern mode + } + + #[test] + fn test_stern_mode_without_spaces_beyond_limit() { + let config = MD013LineLengthTable { + stern: true, + ..MD013LineLengthTable::default() + }; + + // Line without spaces beyond limit - should NOT violate in stern mode + 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()); // Should NOT violate in stern mode + } + + #[test] + fn test_stern_mode_vs_default_mode() { + // Create line that exceeds limit with spaces beyond limit + 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"); // Add spaces beyond limit + + // Default mode - should violate because there are spaces beyond limit + 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(); + + // Stern mode - should violate because it's more aggressive about lines with spaces + 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(); + + // Both should catch this since it has spaces beyond limit + assert_eq!(1, default_violations.len()); // Default should catch this since it has spaces + assert_eq!(1, stern_violations.len()); // Stern should definitely catch this + } + + #[test] + fn test_stern_vs_strict_vs_default_comprehensive() { + // Case 1: Line with spaces beyond limit - all modes should catch this + 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"); // Add spaces beyond limit + + // Case 2: Line without spaces beyond limit - only strict mode should catch this + let case2 = "This line has exactly eighty characters and then continues without spaces: https://example.com/url".to_string(); + + // Case 3: Line within limit - no mode should catch this + let case3 = "This line is within the eighty character limit".to_string(); + + let test_cases = vec![ + (&case1, true, true, true), // Has spaces beyond limit + (&case2, false, false, true), // No spaces beyond limit + (&case3, false, false, false), // Within limit + ]; + + for (input, expect_default, expect_stern, expect_strict) in test_cases { + // Default mode + 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); + + // Stern mode + 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); + + // Strict mode + 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() { + // This test demonstrates that our concern was valid in theory, but doesn't occur in practice + // because tree-sitter creates enough AST nodes for even simple documents + + let input = "A\nB\nC\n"; // Minimal document - just 3 short lines + + // Count AST nodes for this minimal document + 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 {} AST nodes", node_count); + println!("This explains why our MD013 implementation works correctly"); + + // Even this tiny document creates multiple nodes (document, paragraph, text nodes, etc.) + assert!(node_count >= 3, "Even minimal documents create multiple AST nodes"); + } + + #[test] + fn test_extreme_violations_vs_minimal_nodes() { + // Create the most minimal AST possible: just plain text with no structure + // This should create minimal AST nodes but many violations + let mut input = String::new(); + + // Add 100 long lines of plain text (no markdown structure at all) + 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()); + + // Count how many AST nodes are created by parsing this document + 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()); + + // This is the critical test: with the improved MD013, we should ALWAYS find all violations + // regardless of the node count, because violations are tied to line numbers, not node traversal order + println!("Ratio: {} violations vs {} nodes", violations.len(), node_count); + + // We should find exactly 100 violations + 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() { + // This test creates a scenario where violations > nodes to ensure our fix works + // Create a document with minimal structure but maximum line violations + + let mut input = "# Header\n\n".to_string(); // Creates multiple AST nodes + + // Add 50 long lines that should violate but may not have corresponding unique AST nodes + 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); + + // Should find exactly 50 violations (one per long line), regardless of node count + assert_eq!(50, violations.len(), + "Expected 50 violations but found {}. Improved MD013 must not lose violations!", + violations.len() + ); + + // Verify each violation is on the correct line + for (i, violation) in violations.iter().enumerate() { + let expected_line = i + 2; // Lines 2, 3, 4, ..., 51 (line 0 is header, line 1 is empty) + 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() { + // Create a document with many line violations but few AST nodes + // Structure: simple heading followed by many long lines of plain text + let mut input = "# Short heading\n\n".to_string(); + + // Add 20 long lines that should each trigger violations + 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(); + + // Debug: print actual violations found + println!("Violations found: {}", violations.len()); + for (i, violation) in violations.iter().enumerate() { + println!(" Violation {}: line {}", i + 1, violation.location().range.start.line); + } + + // We should find exactly 20 violations (one per long line) + // If we find fewer, it means some violations were lost due to the bug + assert_eq!(20, violations.len(), + "Expected 20 line length violations but found {}. This suggests violations were lost due to insufficient AST nodes.", + violations.len() + ); + + // Verify violations are on the correct lines (lines 2-21, since line 0 is heading, line 1 is empty) + for (i, violation) in violations.iter().enumerate() { + let expected_line = i + 2; // Lines 2, 3, 4, ..., 21 + 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 + ); + } + } +} \ No newline at end of file diff --git a/crates/quickmark_linter/src/rules/mod.rs b/crates/quickmark_linter/src/rules/mod.rs index e791762..d9b66ed 100644 --- a/crates/quickmark_linter/src/rules/mod.rs +++ b/crates/quickmark_linter/src/rules/mod.rs @@ -4,6 +4,17 @@ use crate::linter::{Context, RuleLinter}; pub mod md001; pub mod md003; +pub mod md013; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleType { + /// Rules that primarily analyze raw text lines (e.g., line length, whitespace) + Line, + /// Rules that analyze specific AST node types (e.g., headings, lists, code blocks) + Token, + /// Rules that require full document analysis (e.g., duplicate headings, cross-references) + Document, +} #[derive(Debug)] pub struct Rule { @@ -11,7 +22,9 @@ pub struct Rule { pub alias: &'static str, pub tags: &'static [&'static str], pub description: &'static str, + pub rule_type: RuleType, + pub required_nodes: &'static [&'static str], // For caching optimization pub new_linter: fn(Rc) -> Box, } -pub const ALL_RULES: &[Rule] = &[md001::MD001, md003::MD003]; +pub const ALL_RULES: &[Rule] = &[md001::MD001, md003::MD003, md013::MD013]; diff --git a/crates/quickmark_linter/src/tree_sitter_walker.rs b/crates/quickmark_linter/src/tree_sitter_walker.rs index 637c9e1..30a09ff 100644 --- a/crates/quickmark_linter/src/tree_sitter_walker.rs +++ b/crates/quickmark_linter/src/tree_sitter_walker.rs @@ -33,12 +33,14 @@ impl<'a> TreeSitterWalker<'a> { } + #[allow(clippy::only_used_in_recursion)] fn walk_pre_order(&self, node: Node, callback: &mut impl FnMut(Node)) { callback(node); for child in node.children(&mut node.walk()) { self.walk_pre_order(child, callback); } } + #[allow(clippy::only_used_in_recursion)] fn walk_post_order(&self, node: Node, callback: &mut impl FnMut(Node)) { for child in node.children(&mut node.walk()) { self.walk_post_order(child, callback); diff --git a/crates/quickmark_server/src/main.rs b/crates/quickmark_server/src/main.rs index 57b187a..db75fca 100644 --- a/crates/quickmark_server/src/main.rs +++ b/crates/quickmark_server/src/main.rs @@ -15,7 +15,7 @@ fn main() -> anyhow::Result<()> { // Show some config details for (rule, severity) in &config.linters.severity { - println!("Rule '{}' is set to {:?}", rule, severity); + println!("Rule '{rule}' is set to {severity:?}"); } println!("Server would run with this configuration..."); diff --git a/docs/rules/md013.md b/docs/rules/md013.md new file mode 100644 index 0000000..4a5d8f3 --- /dev/null +++ b/docs/rules/md013.md @@ -0,0 +1,69 @@ +# `MD013` - Line length + +Tags: `line_length` + +Aliases: `line-length` + +This rule checks for lines exceeding a specified maximum length (default 80 characters). + +## Incorrect code example + +```markdown +This is a very long line that exceeds the maximum allowed length and will trigger the MD013 rule violation +``` + +## Correct code example + +```markdown +This is a properly formatted line that stays within the maximum +allowed length and will not trigger the MD013 rule violation +``` + +## Configuration + +This rule has the following configuration options: + +- `line_length` - Maximum characters per line (default: 80) +- `heading_line_length` - Maximum characters for headings (default: 80) +- `code_block_line_length` - Maximum characters in code blocks (default: 80) +- `code_blocks` - Include code blocks in check (default: true) +- `headings` - Include headings in check (default: true) +- `tables` - Include tables in check (default: true) +- `strict` - Enforce strict length checking (default: false) +- `stern` - Warn about potentially fixable long lines (default: false) + +### Mode Behaviors + +- **Default mode**: Lines without spaces beyond the limit are allowed +- **Stern mode**: Warns about lines with spaces beyond limit that could be wrapped +- **Strict mode**: All lines must be under the limit regardless of spaces + +## Exceptions + +The following types of lines are automatically exempted from length checking: + +- Link reference definitions (e.g., `[label]: https://example.com`) +- Standalone links (e.g., `[Example Link](https://example.com)`) +- Standalone images (e.g., `![Alt Text](image.jpg)`) + +## Examples + +### Lines that would be allowed in default mode: +```markdown +This-line-has-no-spaces-beyond-the-limit-so-it-is-allowed-even-if-very-long +[Link reference]: https://example.com/very/long/url/that/exceeds/normal/limits +![Image](https://example.com/path/to/very/long/image/url.jpg) +``` + +### Lines that would trigger violations: +```markdown +This line has spaces beyond the configured limit and will trigger a violation +``` + +## Rationale + +Extremely long lines can be difficult to work with in some editors and can harm readability. Many style guides recommend keeping line lengths reasonable to improve code and documentation maintainability. + +## Recommendation + +Split long lines into multiple lines or adjust the configuration to match your project's style guidelines. \ No newline at end of file diff --git a/test-samples/.markdownlint-default.json b/test-samples/.markdownlint-default.json new file mode 100644 index 0000000..1e340e4 --- /dev/null +++ b/test-samples/.markdownlint-default.json @@ -0,0 +1,13 @@ +{ + "default": false, + "MD013": { + "line_length": 80, + "heading_line_length": 80, + "code_block_line_length": 80, + "code_blocks": true, + "headings": true, + "tables": true, + "strict": false, + "stern": false + } +} \ No newline at end of file diff --git a/test-samples/.markdownlint-no-headings.json b/test-samples/.markdownlint-no-headings.json new file mode 100644 index 0000000..358d6f1 --- /dev/null +++ b/test-samples/.markdownlint-no-headings.json @@ -0,0 +1,13 @@ +{ + "default": false, + "MD013": { + "line_length": 80, + "heading_line_length": 80, + "code_block_line_length": 80, + "code_blocks": true, + "headings": false, + "tables": true, + "strict": false, + "stern": false + } +} \ No newline at end of file diff --git a/test-samples/.markdownlint-stern.json b/test-samples/.markdownlint-stern.json new file mode 100644 index 0000000..e8308bf --- /dev/null +++ b/test-samples/.markdownlint-stern.json @@ -0,0 +1,13 @@ +{ + "default": false, + "MD013": { + "line_length": 80, + "heading_line_length": 80, + "code_block_line_length": 80, + "code_blocks": true, + "headings": true, + "tables": true, + "strict": false, + "stern": true + } +} \ No newline at end of file diff --git a/test-samples/.markdownlint-strict.json b/test-samples/.markdownlint-strict.json new file mode 100644 index 0000000..cce435f --- /dev/null +++ b/test-samples/.markdownlint-strict.json @@ -0,0 +1,13 @@ +{ + "default": false, + "MD013": { + "line_length": 80, + "heading_line_length": 80, + "code_block_line_length": 80, + "code_blocks": true, + "headings": true, + "tables": true, + "strict": true, + "stern": false + } +} \ No newline at end of file diff --git a/test-samples/quickmark-default.toml b/test-samples/quickmark-default.toml new file mode 100644 index 0000000..ee992bf --- /dev/null +++ b/test-samples/quickmark-default.toml @@ -0,0 +1,14 @@ +[linters.severity] +"line-length" = "err" +"heading-increment" = "off" +"heading-style" = "off" + +[linters.settings.line-length] +line_length = 80 +heading_line_length = 80 +code_block_line_length = 80 +code_blocks = true +headings = true +tables = true +strict = false +stern = false \ No newline at end of file diff --git a/test-samples/quickmark-no-headings.toml b/test-samples/quickmark-no-headings.toml new file mode 100644 index 0000000..f3b9602 --- /dev/null +++ b/test-samples/quickmark-no-headings.toml @@ -0,0 +1,14 @@ +[linters.severity] +"line-length" = "err" +"heading-increment" = "off" +"heading-style" = "off" + +[linters.settings.line-length] +line_length = 80 +heading_line_length = 80 +code_block_line_length = 80 +code_blocks = true +headings = false +tables = true +strict = false +stern = false \ No newline at end of file diff --git a/test-samples/quickmark-stern.toml b/test-samples/quickmark-stern.toml new file mode 100644 index 0000000..87f50fa --- /dev/null +++ b/test-samples/quickmark-stern.toml @@ -0,0 +1,14 @@ +[linters.severity] +"line-length" = "err" +"heading-increment" = "off" +"heading-style" = "off" + +[linters.settings.line-length] +line_length = 80 +heading_line_length = 80 +code_block_line_length = 80 +code_blocks = true +headings = true +tables = true +strict = false +stern = true \ No newline at end of file diff --git a/test-samples/quickmark-strict.toml b/test-samples/quickmark-strict.toml new file mode 100644 index 0000000..043ff6f --- /dev/null +++ b/test-samples/quickmark-strict.toml @@ -0,0 +1,14 @@ +[linters.severity] +"line-length" = "err" +"heading-increment" = "off" +"heading-style" = "off" + +[linters.settings.line-length] +line_length = 80 +heading_line_length = 80 +code_block_line_length = 80 +code_blocks = true +headings = true +tables = true +strict = true +stern = false \ No newline at end of file diff --git a/test-samples/test_md013_comparison.md b/test-samples/test_md013_comparison.md new file mode 100644 index 0000000..cc3989e --- /dev/null +++ b/test-samples/test_md013_comparison.md @@ -0,0 +1,29 @@ +# Comprehensive MD013 Test + +This is a normal line within 80 characters. + +This line is exactly eighty characters long and should be just at the limit!! + +This line definitely exceeds eighty characters and should trigger a violation in both linters. + +[Link reference that is very long]: https://example.com/very-long-url-that-should-be-exempted-from-line-length-rules + +[This is a standalone link with very long text that should be exempted](https://example.com) + +![This is a standalone image with very long alt text that should be exempted](https://example.com/image.jpg) + +Text with URL without spaces beyond limit: https://example.com/very-long-url-without-any-spaces-should-be-exempted + +Text with URL that has spaces beyond the limit: https://example.com/url and then more text. + +## This heading is longer than eighty characters and should trigger a violation + +``` +This code block line is longer than eighty characters and should trigger violation. +``` + +| Column 1 | Column 2 | This table cell is longer than eighty characters and should trigger a violation | +|----------|----------|----------------------------------------------------------------------------------| +| Data | Data | Data | + +Another regular line that exceeds the eighty character limit and should be flagged. \ No newline at end of file diff --git a/test-samples/test_md013_comprehensive.md b/test-samples/test_md013_comprehensive.md new file mode 100644 index 0000000..6b2eab7 --- /dev/null +++ b/test-samples/test_md013_comprehensive.md @@ -0,0 +1,104 @@ +# MD013 Line Length Comprehensive Test + +## Regular Text Lines - Should Violate (Default/Stern/Strict) + +This line is exactly eighty characters long and should not trigger violations. +This line is clearly longer than eighty characters and should trigger a violation in all modes (default, stern, strict) because it contains spaces beyond the limit. + +## Lines Without Spaces Beyond Limit - Should Only Violate in Strict Mode + +This line is exactly eighty characters and then continues with no spaces: https://example.com/very-long-url-without-any-spaces-at-all. +Another line that exceeds the limit but has no spaces beyond eighty characters: file_name_with_underscores_that_cannot_be_broken.txt + +## Link Reference Definitions - Should Never Violate (All Modes) + +[very-long-link-reference-definition-that-exceeds-eighty-characters]: https://example.com/very-long-url-that-should-be-exempted-from-line-length-checking +[another-long-reference]: https://github.com/user/repository/blob/main/some/very/long/path/to/file.md + +## Standalone Links and Images - Should Never Violate (All Modes) + +[This is a very long link text that definitely exceeds eighty characters but should be exempted](https://example.com/url) +![This is a very long image alt text that definitely exceeds eighty characters but should be exempted](https://example.com/image.jpg) + +## Headings - Behavior Depends on 'headings' Setting + +# This is a very long heading that definitely exceeds the eighty character limit and should trigger violations when headings are enabled +## Another very long heading that exceeds the limit - level 2 heading that should also trigger violations when enabled +### Level 3 heading that is also longer than eighty characters and should be caught when headings checking is enabled + +#### Short heading + +## Code Blocks - Behavior Depends on 'code_blocks' Setting + +```python +# This code line is longer than eighty characters and should trigger violations when code_blocks is enabled +def very_long_function_name_that_exceeds_the_line_length_limit_and_should_be_caught(): + pass + +short_line = "ok" +``` + +```javascript +// Another long code line that exceeds eighty characters and should be caught when code_blocks setting is enabled +const very_long_variable_name_that_exceeds_line_limit = "this should trigger violations when code_blocks is true"; +``` + + # Indented code block with long line that exceeds eighty characters and should be caught when code_blocks is enabled + def another_very_long_function_name_that_definitely_exceeds_the_standard_eighty_character_line_length_limit(): + return "This string is also quite long and should trigger a violation" + +## Tables - Behavior Depends on 'tables' Setting + +| Column 1 | Column 2 with very long header text that exceeds eighty characters | Column 3 | +|----------|---------------------------------------------------------------------|----------| +| Short | This table cell contains very long text that exceeds the eighty character limit | More | +| Data | Another very long table cell that should trigger violations when tables checking is enabled | Text | + +## Mixed Scenarios for Mode Testing + +These lines are designed to test the differences between default, stern, and strict modes: + +Line exactly at limit (80 chars): 12345678901234567890123456789012345678901234567890123456789012345678901234567890 +Line with spaces beyond limit that should violate in all modes because it has breakable content here. +Line without spaces beyond limit: https://example.com/very-long-url-that-cannot-be-easily-broken-without-changing-semantics.html +Another-line-with-no-spaces-beyond-limit-that-uses-hyphens-instead-of-spaces-to-connect-words-and-should-only-violate-in-strict-mode. + +## Edge Cases + +Short line. + +A line that is exactly eighty characters long and ends precisely at the limit. + +[Link with long text that exceeds limit](http://example.com) but has URL +![Image with long alt text that exceeds limit](http://example.com/img.jpg) URL + + + +## Setext Headings + +This is a setext heading level 1 that exceeds the eighty character limit +====================================================================== + +This is a setext heading level 2 that also exceeds the character limit +----------------------------------------------------------------------- + +## Mixed Content + +Normal paragraph text that exceeds the eighty character limit and should trigger violations in all modes. + +`This inline code snippet is longer than eighty characters and behavior depends on implementation details.` + +**This bold text is longer than eighty characters and should trigger violations in all modes since it contains spaces.** + +*This italic text is also longer than eighty characters and should trigger violations in all modes as well.* + +## Test Summary + +This document contains: +- Lines that should violate in all modes (default/stern/strict) +- Lines that should only violate in strict mode (no spaces beyond limit) +- Lines that should never violate (link refs, standalone links/images) +- Content that depends on boolean settings (headings, code_blocks, tables) +- Edge cases and boundary conditions +- Different heading types (ATX and Setext) +- Various markdown elements (bold, italic, inline code, comments) \ No newline at end of file diff --git a/test-samples/test_md013_custom_config.md b/test-samples/test_md013_custom_config.md new file mode 100644 index 0000000..9d3cbc2 --- /dev/null +++ b/test-samples/test_md013_custom_config.md @@ -0,0 +1,15 @@ +# MD013 Custom Configuration Test + +This line is 60 characters long and exceeds custom limit. + +This line is 40 characters exactly ok. + +# This heading exceeds custom heading limit + +``` +This code block exceeds custom code limit too. +``` + +| Col1 | Col2 | This table exceeds custom limit | +|------|------|----------------------------------| +| Data | Data | Content | \ No newline at end of file diff --git a/test-samples/test_md013_valid.md b/test-samples/test_md013_valid.md new file mode 100644 index 0000000..cbd24f9 --- /dev/null +++ b/test-samples/test_md013_valid.md @@ -0,0 +1,25 @@ +# MD013 Valid Content + +This line is within the 80 character limit. + +# Normal heading + +Regular text content. + +``` +Code block content that is within limits. +``` + +| Col1 | Col2 | Col3 | +|------|------|------| +| Data | Data | Data | + +[Link reference definition]: https://example.com/very-long-url-that-should-be-exempted-from-line-length-checking + +[Standalone link with very long text that should be exempted](https://example.com) + +![Standalone image with very long alt text that should be exempted](https://example.com/image.jpg) + +Text with URL without spaces beyond limit: https://example.com/very-long-url-without-spaces + +Short line. \ No newline at end of file diff --git a/test-samples/test_md013_violations.md b/test-samples/test_md013_violations.md new file mode 100644 index 0000000..b3c058a --- /dev/null +++ b/test-samples/test_md013_violations.md @@ -0,0 +1,21 @@ +# MD013 Line Length Violations + +This line exceeds the default 80 character limit and should trigger a violation because it's too long. + +This is a normal length line. + +# This heading also exceeds the eighty character limit and should trigger a violation too. + +Regular text that is short. + +``` +This code block line also exceeds the default eighty character limit and should trigger a violation. +``` + +| Column 1 | Column 2 | This table header definitely exceeds the eighty character limit and should trigger a violation | +|----------|----------|------------------------------| +| Short | Normal | Data | + +Another line that is definitely longer than eighty characters and should trigger a violation because it exceeds the limit. + +Some text with a very long URL that has spaces beyond the limit: https://example.com/path and more text here. \ No newline at end of file