Skip to main content

rumdl_lib/rules/md013_line_length/
md013_config.rs

1use crate::rule_config_serde::RuleConfig;
2use crate::types::LineLength;
3use serde::{Deserialize, Serialize};
4
5/// Reflow mode for MD013
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
7#[serde(rename_all = "kebab-case")]
8pub enum ReflowMode {
9    /// Only reflow lines that exceed the line length limit (default behavior)
10    #[default]
11    Default,
12    /// Normalize all paragraphs to use the full line length
13    Normalize,
14    /// One sentence per line - break at sentence boundaries
15    #[serde(alias = "sentence_per_line")]
16    SentencePerLine,
17    /// Semantic line breaks - cascading strategy:
18    /// 1. Sentence boundaries (always)
19    /// 2. Clause punctuation (when line > line-length)
20    /// 3. English break-words (when line still > line-length)
21    /// 4. Word wrap (fallback)
22    #[serde(alias = "semantic_line_breaks")]
23    SemanticLineBreaks,
24}
25
26/// Length calculation mode for MD013
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
28#[serde(rename_all = "kebab-case")]
29pub enum LengthMode {
30    /// Count Unicode characters (grapheme clusters)
31    /// Use this only if you need backward compatibility with character-based counting
32    #[serde(alias = "chars", alias = "characters")]
33    Chars,
34    /// Count visual display width (CJK characters = 2 columns, emoji = 2, etc.) - default
35    /// This is semantically correct: line-length = 80 means "80 columns on screen"
36    #[default]
37    #[serde(alias = "display", alias = "visual_width")]
38    Visual,
39    /// Count raw bytes (legacy mode, not recommended for Unicode text)
40    Bytes,
41}
42
43/// Configuration for MD013 (Line length)
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45#[serde(rename_all = "kebab-case")]
46pub struct MD013Config {
47    /// Maximum line length (default: 80, 0 means no limit)
48    #[serde(default = "default_line_length", alias = "line_length")]
49    pub line_length: LineLength,
50
51    /// Check code blocks for line length (default: true)
52    #[serde(default = "default_code_blocks", alias = "code_blocks")]
53    pub code_blocks: bool,
54
55    /// Check lines whose length comes from an inline code span (default: true).
56    ///
57    /// Inline code spans (`` `like this` ``) cannot be wrapped, so reflow cannot
58    /// shorten a line whose excess length is one of them. When `false`, a line is
59    /// not reported if it would fit within the limit once its inline code spans are
60    /// excluded - useful with `reflow` so an otherwise-clean file is not failed by
61    /// an unbreakable code incantation.
62    #[serde(default = "default_code_spans", alias = "code_spans")]
63    pub code_spans: bool,
64
65    /// Check tables for line length (default: false)
66    ///
67    /// Note: markdownlint defaults to true, but rumdl defaults to false to avoid
68    /// conflicts with MD060 (table formatting). Tables often require specific widths
69    /// for alignment, which can conflict with line length limits.
70    #[serde(default = "default_tables")]
71    pub tables: bool,
72
73    /// Check headings for line length (default: true)
74    #[serde(default = "default_headings")]
75    pub headings: bool,
76
77    /// Check paragraph/text line length (default: true)
78    /// When false, line length violations in regular text are not reported,
79    /// but reflow can still be used to format paragraphs.
80    #[serde(default = "default_paragraphs")]
81    pub paragraphs: bool,
82
83    /// Check blockquote content for line length (default: true)
84    /// When false, blockquote lines are not checked for line length.
85    /// When paragraphs = false, blockquote content is also skipped
86    /// since blockquote content is paragraph text.
87    #[serde(default = "default_blockquotes")]
88    pub blockquotes: bool,
89
90    /// Strict mode - disables exceptions for URLs, etc. (default: false)
91    #[serde(default)]
92    pub strict: bool,
93
94    /// Stern mode - like strict, but lines that consist of a single
95    /// non-whitespace token (optionally prefixed by heading/blockquote
96    /// markers) are still permitted. Mirrors markdownlint's `stern` option.
97    /// Default: false.
98    #[serde(default)]
99    pub stern: bool,
100
101    /// Per-context maximum line length for headings.
102    ///
103    /// `None` (unset) falls back to `line_length`. `Some(0)` means "no limit
104    /// for headings". Mirrors markdownlint's `heading_line_length`.
105    #[serde(default, alias = "heading_line_length")]
106    pub heading_line_length: Option<LineLength>,
107
108    /// Per-context maximum line length for code blocks (fenced or indented).
109    ///
110    /// `None` (unset) falls back to `line_length`. `Some(0)` means "no limit
111    /// for code blocks". Mirrors markdownlint's `code_block_line_length`.
112    #[serde(default, alias = "code_block_line_length")]
113    pub code_block_line_length: Option<LineLength>,
114
115    /// Enable text reflow to wrap long lines (default: false)
116    #[serde(default, alias = "enable_reflow", alias = "enable-reflow")]
117    pub reflow: bool,
118
119    /// Reflow mode - how to handle reflowing (default: "long-lines")
120    #[serde(default, alias = "reflow_mode")]
121    pub reflow_mode: ReflowMode,
122
123    /// Length calculation mode (default: "chars")
124    /// - "chars": Count Unicode characters (emoji = 1, CJK = 1)
125    /// - "visual": Count visual display width (emoji = 2, CJK = 2)
126    /// - "bytes": Count raw bytes (not recommended for Unicode)
127    #[serde(default, alias = "length_mode")]
128    pub length_mode: LengthMode,
129
130    /// Custom abbreviations for sentence-per-line mode
131    /// Periods are optional - both "Dr" and "Dr." work the same
132    /// Inherited from global config, can be overridden per-rule
133    /// Custom abbreviations are always added to the built-in defaults
134    #[serde(default)]
135    pub abbreviations: Vec<String>,
136
137    /// Whether to require uppercase after periods for sentence detection (default: true).
138    /// When true, only "word. Capital" is treated as a sentence boundary.
139    /// When false, "word. lowercase" is also treated as a sentence boundary.
140    /// Does not affect ! and ? which are always treated as sentence boundaries.
141    #[serde(
142        default = "default_require_sentence_capital",
143        alias = "require_sentence_capital",
144        alias = "strict_sentences",
145        alias = "strict-sentences"
146    )]
147    pub require_sentence_capital: bool,
148}
149
150fn default_line_length() -> LineLength {
151    LineLength::from_const(80)
152}
153
154fn default_code_blocks() -> bool {
155    true
156}
157
158fn default_code_spans() -> bool {
159    true
160}
161
162fn default_tables() -> bool {
163    false
164}
165
166fn default_headings() -> bool {
167    true
168}
169
170fn default_paragraphs() -> bool {
171    true
172}
173
174fn default_blockquotes() -> bool {
175    true
176}
177
178fn default_require_sentence_capital() -> bool {
179    true
180}
181
182impl Default for MD013Config {
183    fn default() -> Self {
184        Self {
185            line_length: default_line_length(),
186            code_blocks: default_code_blocks(),
187            code_spans: default_code_spans(),
188            tables: default_tables(),
189            headings: default_headings(),
190            paragraphs: default_paragraphs(),
191            blockquotes: default_blockquotes(),
192            strict: false,
193            stern: false,
194            heading_line_length: None,
195            code_block_line_length: None,
196            reflow: false,
197            reflow_mode: ReflowMode::default(),
198            length_mode: LengthMode::default(),
199            abbreviations: Vec::new(),
200            require_sentence_capital: default_require_sentence_capital(),
201        }
202    }
203}
204
205impl MD013Config {
206    /// Effective line-length budget for heading lines.
207    /// Falls back to `line_length` when `heading_line_length` is unset.
208    pub fn effective_heading_line_length(&self) -> LineLength {
209        self.heading_line_length.unwrap_or(self.line_length)
210    }
211
212    /// Effective line-length budget for fenced or indented code-block lines.
213    /// Falls back to `line_length` when `code_block_line_length` is unset.
214    pub fn effective_code_block_line_length(&self) -> LineLength {
215        self.code_block_line_length.unwrap_or(self.line_length)
216    }
217
218    /// Smallest applicable line-length budget across all contexts. Used to
219    /// pre-filter candidate lines: any line shorter than this can never
220    /// violate, regardless of which context it falls under.
221    pub fn min_effective_line_length(&self) -> LineLength {
222        let mut limits: Vec<LineLength> = vec![self.line_length];
223        if let Some(h) = self.heading_line_length {
224            limits.push(h);
225        }
226        if let Some(c) = self.code_block_line_length {
227            limits.push(c);
228        }
229        // "Unlimited" (0) is the laxest possible budget, so it must not win
230        // the minimum unless all budgets are unlimited.
231        let bounded: Vec<LineLength> = limits.iter().copied().filter(|l| !l.is_unlimited()).collect();
232        if bounded.is_empty() {
233            LineLength::from_const(0)
234        } else {
235            bounded.into_iter().min_by_key(|l| l.get()).unwrap()
236        }
237    }
238
239    /// Convert abbreviations Vec to Option for ReflowOptions
240    /// Empty Vec means "use defaults only" so it maps to None
241    pub fn abbreviations_for_reflow(&self) -> Option<Vec<String>> {
242        if self.abbreviations.is_empty() {
243            None
244        } else {
245            Some(self.abbreviations.clone())
246        }
247    }
248
249    /// Build a `ReflowOptions` from this configuration.
250    ///
251    /// Converts `reflow_mode`, `length_mode`, `abbreviations`, and `line_length`
252    /// into the unified `ReflowOptions` type used by the reflow engine.
253    pub fn to_reflow_options(&self) -> crate::utils::text_reflow::ReflowOptions {
254        let length_mode = match self.length_mode {
255            LengthMode::Chars => crate::utils::text_reflow::ReflowLengthMode::Chars,
256            LengthMode::Visual => crate::utils::text_reflow::ReflowLengthMode::Visual,
257            LengthMode::Bytes => crate::utils::text_reflow::ReflowLengthMode::Bytes,
258        };
259        crate::utils::text_reflow::ReflowOptions {
260            line_length: self.line_length.get(),
261            break_on_sentences: true,
262            preserve_breaks: false,
263            sentence_per_line: self.reflow_mode == ReflowMode::SentencePerLine,
264            semantic_line_breaks: self.reflow_mode == ReflowMode::SemanticLineBreaks,
265            abbreviations: self.abbreviations_for_reflow(),
266            length_mode,
267            attr_lists: false,
268            myst_roles: false,
269            require_sentence_capital: self.require_sentence_capital,
270            max_list_continuation_indent: None,
271        }
272    }
273}
274
275impl RuleConfig for MD013Config {
276    const RULE_NAME: &'static str = "MD013";
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_reflow_mode_deserialization_kebab_case() {
285        // Test that kebab-case (official format) works
286        // Note: field name is reflow-mode (kebab) due to struct-level rename_all
287        let toml_str = r#"
288            reflow-mode = "sentence-per-line"
289        "#;
290        let config: MD013Config = toml::from_str(toml_str).unwrap();
291        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
292
293        let toml_str = r#"
294            reflow-mode = "default"
295        "#;
296        let config: MD013Config = toml::from_str(toml_str).unwrap();
297        assert_eq!(config.reflow_mode, ReflowMode::Default);
298
299        let toml_str = r#"
300            reflow-mode = "normalize"
301        "#;
302        let config: MD013Config = toml::from_str(toml_str).unwrap();
303        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
304
305        let toml_str = r#"
306            reflow-mode = "semantic-line-breaks"
307        "#;
308        let config: MD013Config = toml::from_str(toml_str).unwrap();
309        assert_eq!(config.reflow_mode, ReflowMode::SemanticLineBreaks);
310    }
311
312    #[test]
313    fn test_reflow_mode_deserialization_snake_case_alias() {
314        // Test that snake_case (alias for backwards compatibility) works
315        // Both for the enum value AND potentially for the field name
316        let toml_str = r#"
317            reflow-mode = "sentence_per_line"
318        "#;
319        let config: MD013Config = toml::from_str(toml_str).unwrap();
320        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
321
322        let toml_str = r#"
323            reflow-mode = "semantic_line_breaks"
324        "#;
325        let config: MD013Config = toml::from_str(toml_str).unwrap();
326        assert_eq!(config.reflow_mode, ReflowMode::SemanticLineBreaks);
327    }
328
329    #[test]
330    fn test_field_name_backwards_compatibility() {
331        // Test that snake_case field names work (for backwards compatibility)
332        // even though docs show kebab-case (like Ruff)
333        let toml_str = r#"
334            line_length = 100
335            code_blocks = false
336            reflow_mode = "sentence_per_line"
337        "#;
338        let config: MD013Config = toml::from_str(toml_str).unwrap();
339        assert_eq!(config.line_length.get(), 100);
340        assert!(!config.code_blocks);
341        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
342
343        // Also test mixed format (should work)
344        let toml_str = r#"
345            line-length = 100
346            code_blocks = false
347            reflow-mode = "normalize"
348        "#;
349        let config: MD013Config = toml::from_str(toml_str).unwrap();
350        assert_eq!(config.line_length.get(), 100);
351        assert!(!config.code_blocks);
352        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
353    }
354
355    #[test]
356    fn test_reflow_mode_serialization() {
357        // Test that serialization always uses kebab-case (primary format)
358        let config = MD013Config {
359            line_length: LineLength::from_const(80),
360            code_blocks: true,
361            code_spans: true,
362            tables: true,
363            headings: true,
364            paragraphs: true,
365            blockquotes: true,
366            strict: false,
367            stern: false,
368            heading_line_length: None,
369            code_block_line_length: None,
370            reflow: true,
371            reflow_mode: ReflowMode::SentencePerLine,
372            length_mode: LengthMode::default(),
373            abbreviations: Vec::new(),
374            require_sentence_capital: true,
375        };
376
377        let toml_str = toml::to_string(&config).unwrap();
378        assert!(toml_str.contains("sentence-per-line"));
379        assert!(!toml_str.contains("sentence_per_line"));
380
381        // Test serialization of SemanticLineBreaks
382        let config = MD013Config {
383            reflow_mode: ReflowMode::SemanticLineBreaks,
384            ..config
385        };
386        let toml_str = toml::to_string(&config).unwrap();
387        assert!(toml_str.contains("semantic-line-breaks"));
388        assert!(!toml_str.contains("semantic_line_breaks"));
389    }
390
391    #[test]
392    fn test_reflow_mode_invalid_value() {
393        // Test that invalid values fail deserialization
394        let toml_str = r#"
395            reflow-mode = "invalid_mode"
396        "#;
397        let result = toml::from_str::<MD013Config>(toml_str);
398        assert!(result.is_err());
399    }
400
401    #[test]
402    fn test_full_config_with_reflow_mode() {
403        let toml_str = r#"
404            line-length = 100
405            code-blocks = false
406            tables = false
407            headings = true
408            strict = true
409            reflow = true
410            reflow-mode = "sentence-per-line"
411        "#;
412        let config: MD013Config = toml::from_str(toml_str).unwrap();
413        assert_eq!(config.line_length.get(), 100);
414        assert!(!config.code_blocks);
415        assert!(!config.tables);
416        assert!(config.headings);
417        assert!(config.strict);
418        assert!(config.reflow);
419        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
420    }
421
422    #[test]
423    fn test_code_spans_default_true_and_parses() {
424        assert!(MD013Config::default().code_spans, "code-spans defaults to true");
425
426        let config: MD013Config = toml::from_str("code-spans = false").unwrap();
427        assert!(!config.code_spans);
428        // snake_case alias also works.
429        let config: MD013Config = toml::from_str("code_spans = false").unwrap();
430        assert!(!config.code_spans);
431    }
432
433    #[test]
434    fn test_paragraphs_default_true() {
435        // Test that paragraphs defaults to true
436        let config = MD013Config::default();
437        assert!(config.paragraphs, "paragraphs should default to true");
438    }
439
440    #[test]
441    fn test_paragraphs_deserialization_kebab_case() {
442        // Test kebab-case (canonical format)
443        let toml_str = r#"
444            paragraphs = false
445        "#;
446        let config: MD013Config = toml::from_str(toml_str).unwrap();
447        assert!(!config.paragraphs);
448    }
449
450    #[test]
451    fn test_paragraphs_full_config() {
452        // Test paragraphs in a full configuration with issue #121 use case
453        let toml_str = r#"
454            line-length = 80
455            code-blocks = true
456            tables = true
457            headings = false
458            paragraphs = false
459            reflow = true
460            reflow-mode = "sentence-per-line"
461        "#;
462        let config: MD013Config = toml::from_str(toml_str).unwrap();
463        assert_eq!(config.line_length.get(), 80);
464        assert!(config.code_blocks, "code-blocks should be true");
465        assert!(config.tables, "tables should be true");
466        assert!(!config.headings, "headings should be false");
467        assert!(!config.paragraphs, "paragraphs should be false");
468        assert!(config.reflow, "reflow should be true");
469        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
470    }
471
472    #[test]
473    fn test_abbreviations_for_reflow_empty_vec() {
474        // Empty vec means "use defaults only" -> returns None
475        let config = MD013Config {
476            abbreviations: Vec::new(),
477            ..Default::default()
478        };
479        assert!(
480            config.abbreviations_for_reflow().is_none(),
481            "Empty abbreviations should return None for reflow"
482        );
483    }
484
485    #[test]
486    fn test_abbreviations_for_reflow_with_custom() {
487        // Non-empty vec means "use these custom abbreviations" -> returns Some
488        let config = MD013Config {
489            abbreviations: vec!["Corp".to_string(), "Inc".to_string()],
490            ..Default::default()
491        };
492        let result = config.abbreviations_for_reflow();
493        assert!(result.is_some(), "Custom abbreviations should return Some");
494        let abbrevs = result.unwrap();
495        assert_eq!(abbrevs.len(), 2);
496        assert!(abbrevs.contains(&"Corp".to_string()));
497        assert!(abbrevs.contains(&"Inc".to_string()));
498    }
499}