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 display-math blocks for line length (default: true)
78    ///
79    /// A `$$ ... $$` block is unbreakable in the same way a code block or a table
80    /// row is: LaTeX cannot be wrapped without changing the equation. When
81    /// `false`, lines that hold nothing but display math are not reported. This
82    /// covers both a multi-line block and a whole line that is one complete
83    /// `$$...$$` span, delimiter lines included, matching how `code-blocks =
84    /// false` also exempts the surrounding fences.
85    ///
86    /// Inline `$...$` math is not covered, for the same reason `code-spans` is a
87    /// separate key from `code-blocks`.
88    #[serde(default = "default_math_blocks")]
89    pub math_blocks: bool,
90
91    /// Check paragraph/text line length (default: true)
92    /// When false, line length violations in regular text are not reported,
93    /// but reflow can still be used to format paragraphs.
94    #[serde(default = "default_paragraphs")]
95    pub paragraphs: bool,
96
97    /// Check blockquote content for line length (default: true)
98    /// When false, blockquote lines are not checked for line length.
99    /// When paragraphs = false, blockquote content is also skipped
100    /// since blockquote content is paragraph text.
101    #[serde(default = "default_blockquotes")]
102    pub blockquotes: bool,
103
104    /// Strict mode - disables exceptions for URLs, etc. (default: false)
105    #[serde(default)]
106    pub strict: bool,
107
108    /// Stern mode - like strict, but lines that consist of a single
109    /// non-whitespace token (optionally prefixed by heading/blockquote
110    /// markers) are still permitted. Mirrors markdownlint's `stern` option.
111    /// Default: false.
112    #[serde(default)]
113    pub stern: bool,
114
115    /// Whether to ignore inline link/image URLs when measuring line length
116    /// (default: true).
117    ///
118    /// In non-strict mode, a line that exceeds the limit only because of the URL
119    /// portion of an inline `[text](url)` / `![alt](url)` is forgiven (the URL
120    /// cannot be shortened). Set to `false` to count those URLs toward the line
121    /// length so the line is flagged. Combine with `stern` to flag a link line
122    /// that has wrappable text around it while still exempting a line that is a
123    /// single unbreakable token (a bare URL or a standalone link). Has no effect
124    /// in `strict` mode, which already disables all forgiveness.
125    ///
126    /// Accepts the former `semantic-link-understanding` key as an alias.
127    #[serde(
128        default = "default_ignore_link_urls",
129        alias = "ignore_link_urls",
130        alias = "semantic-link-understanding",
131        alias = "semantic_link_understanding"
132    )]
133    pub ignore_link_urls: bool,
134
135    /// Per-context maximum line length for headings.
136    ///
137    /// `None` (unset) falls back to `line_length`. `Some(0)` means "no limit
138    /// for headings". Mirrors markdownlint's `heading_line_length`.
139    #[serde(default, alias = "heading_line_length")]
140    pub heading_line_length: Option<LineLength>,
141
142    /// Per-context maximum line length for code blocks (fenced or indented).
143    ///
144    /// `None` (unset) falls back to `line_length`. `Some(0)` means "no limit
145    /// for code blocks". Mirrors markdownlint's `code_block_line_length`.
146    #[serde(default, alias = "code_block_line_length")]
147    pub code_block_line_length: Option<LineLength>,
148
149    /// Enable text reflow to wrap long lines (default: false)
150    #[serde(default, alias = "enable_reflow", alias = "enable-reflow")]
151    pub reflow: bool,
152
153    /// Reflow mode - how to handle reflowing (default: "long-lines")
154    #[serde(default, alias = "reflow_mode")]
155    pub reflow_mode: ReflowMode,
156
157    /// Length calculation mode (default: "chars")
158    /// - "chars": Count Unicode characters (emoji = 1, CJK = 1)
159    /// - "visual": Count visual display width (emoji = 2, CJK = 2)
160    /// - "bytes": Count raw bytes (not recommended for Unicode)
161    #[serde(default, alias = "length_mode")]
162    pub length_mode: LengthMode,
163
164    /// Custom abbreviations for sentence-per-line mode
165    /// Periods are optional - both "Dr" and "Dr." work the same
166    /// Inherited from global config, can be overridden per-rule
167    /// Custom abbreviations are always added to the built-in defaults
168    #[serde(default)]
169    pub abbreviations: Vec<String>,
170
171    /// Whether to require uppercase after periods for sentence detection (default: true).
172    /// When true, only "word. Capital" is treated as a sentence boundary.
173    /// When false, "word. lowercase" is also treated as a sentence boundary.
174    /// Does not affect ! and ? which are always treated as sentence boundaries.
175    #[serde(
176        default = "default_require_sentence_capital",
177        alias = "require_sentence_capital",
178        alias = "strict_sentences",
179        alias = "strict-sentences"
180    )]
181    pub require_sentence_capital: bool,
182
183    /// Whether to hold emphasis/strong/strikethrough and code spans atomic during reflow.
184    /// When true (default), these spans are treated as atomic units.
185    /// When false, they can be wrapped word-by-word like normal text.
186    #[serde(default = "default_atomic_spans", alias = "atomic_spans")]
187    pub atomic_spans: bool,
188
189    /// Whether reflow measures a line with the same length exemptions the check
190    /// applies (default: false).
191    ///
192    /// Off, reflow measures the markdown as written, so a paragraph whose only
193    /// excess is an inline link destination is wrapped even though the check
194    /// forgives it. On, reflow consults `ignore_link_urls` (an inline
195    /// `[text](url)` costs `[text]`, `![alt](url)` costs `![alt]`) and
196    /// `code_spans` (a code span costs nothing), and leaves such a paragraph
197    /// alone. Reading the same options both sides read is what keeps the
198    /// formatter from producing a line the check then reports.
199    #[serde(default)]
200    pub reflow_length_exemptions: bool,
201}
202
203fn default_line_length() -> LineLength {
204    LineLength::from_const(80)
205}
206
207fn default_code_blocks() -> bool {
208    true
209}
210
211fn default_code_spans() -> bool {
212    true
213}
214
215fn default_tables() -> bool {
216    false
217}
218
219fn default_headings() -> bool {
220    true
221}
222
223fn default_math_blocks() -> bool {
224    true
225}
226
227fn default_paragraphs() -> bool {
228    true
229}
230
231fn default_blockquotes() -> bool {
232    true
233}
234
235fn default_require_sentence_capital() -> bool {
236    true
237}
238
239fn default_ignore_link_urls() -> bool {
240    true
241}
242
243fn default_atomic_spans() -> bool {
244    true
245}
246
247impl Default for MD013Config {
248    fn default() -> Self {
249        Self {
250            line_length: default_line_length(),
251            code_blocks: default_code_blocks(),
252            code_spans: default_code_spans(),
253            tables: default_tables(),
254            headings: default_headings(),
255            math_blocks: default_math_blocks(),
256            paragraphs: default_paragraphs(),
257            blockquotes: default_blockquotes(),
258            strict: false,
259            stern: false,
260            ignore_link_urls: default_ignore_link_urls(),
261            heading_line_length: None,
262            code_block_line_length: None,
263            reflow: false,
264            reflow_mode: ReflowMode::default(),
265            length_mode: LengthMode::default(),
266            abbreviations: Vec::new(),
267            require_sentence_capital: default_require_sentence_capital(),
268            atomic_spans: default_atomic_spans(),
269            reflow_length_exemptions: false,
270        }
271    }
272}
273
274impl MD013Config {
275    /// Effective line-length budget for heading lines.
276    /// Falls back to `line_length` when `heading_line_length` is unset.
277    pub fn effective_heading_line_length(&self) -> LineLength {
278        self.heading_line_length.unwrap_or(self.line_length)
279    }
280
281    /// Effective line-length budget for fenced or indented code-block lines.
282    /// Falls back to `line_length` when `code_block_line_length` is unset.
283    pub fn effective_code_block_line_length(&self) -> LineLength {
284        self.code_block_line_length.unwrap_or(self.line_length)
285    }
286
287    /// Smallest applicable line-length budget across all contexts. Used to
288    /// pre-filter candidate lines: any line shorter than this can never
289    /// violate, regardless of which context it falls under.
290    pub fn min_effective_line_length(&self) -> LineLength {
291        [
292            Some(self.line_length),
293            self.heading_line_length,
294            self.code_block_line_length,
295        ]
296        .into_iter()
297        .flatten()
298        .filter(|l| !l.is_unlimited())
299        .min_by_key(|l| l.get())
300        .unwrap_or(LineLength::from_const(0))
301    }
302
303    /// Convert abbreviations Vec to Option for ReflowOptions
304    /// Empty Vec means "use defaults only" so it maps to None
305    pub fn abbreviations_for_reflow(&self) -> Option<Vec<String>> {
306        if self.abbreviations.is_empty() {
307            None
308        } else {
309            Some(self.abbreviations.clone())
310        }
311    }
312
313    /// The checker's length exemptions, in the form reflow mirrors them.
314    ///
315    /// Empty unless `reflow_length_exemptions` opts in, and then derived from the
316    /// very options the check path reads, so the two measures cannot drift.
317    pub(crate) fn length_exemptions_for_reflow(&self) -> crate::utils::text_reflow::LengthExemptions {
318        if !self.reflow_length_exemptions {
319            return crate::utils::text_reflow::LengthExemptions::default();
320        }
321        crate::utils::text_reflow::LengthExemptions {
322            link_urls: !self.strict && self.ignore_link_urls,
323            code_spans: !self.code_spans,
324        }
325    }
326
327    /// Map the configured length mode to the reflow engine's length mode.
328    pub(crate) fn reflow_length_mode(&self) -> crate::utils::text_reflow::ReflowLengthMode {
329        match self.length_mode {
330            LengthMode::Chars => crate::utils::text_reflow::ReflowLengthMode::Chars,
331            LengthMode::Visual => crate::utils::text_reflow::ReflowLengthMode::Visual,
332            LengthMode::Bytes => crate::utils::text_reflow::ReflowLengthMode::Bytes,
333        }
334    }
335
336    /// Build a `ReflowOptions` from this configuration.
337    ///
338    /// Converts `reflow_mode`, `length_mode`, `abbreviations`, and `line_length`
339    /// into the unified `ReflowOptions` type used by the reflow engine.
340    pub fn to_reflow_options(&self) -> crate::utils::text_reflow::ReflowOptions {
341        crate::utils::text_reflow::ReflowOptions {
342            line_length: self.line_length.get(),
343            break_on_sentences: true,
344            preserve_breaks: false,
345            sentence_per_line: self.reflow_mode == ReflowMode::SentencePerLine,
346            semantic_line_breaks: self.reflow_mode == ReflowMode::SemanticLineBreaks,
347            abbreviations: self.abbreviations_for_reflow(),
348            length_mode: self.reflow_length_mode(),
349            attr_lists: false,
350            myst_roles: false,
351            require_sentence_capital: self.require_sentence_capital,
352            max_list_continuation_indent: None,
353            // No document context here (config-only), so shortcut references
354            // stay atomic. The rule's fix path supplies the defined labels.
355            defined_references: None,
356            atomic_spans: self.atomic_spans,
357            length_exemptions: self.length_exemptions_for_reflow(),
358        }
359    }
360}
361
362impl RuleConfig for MD013Config {
363    const RULE_NAME: &'static str = "MD013";
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_reflow_mode_deserialization_kebab_case() {
372        // Test that kebab-case (official format) works
373        // Note: field name is reflow-mode (kebab) due to struct-level rename_all
374        let toml_str = r#"
375            reflow-mode = "sentence-per-line"
376        "#;
377        let config: MD013Config = toml::from_str(toml_str).unwrap();
378        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
379
380        let toml_str = r#"
381            reflow-mode = "default"
382        "#;
383        let config: MD013Config = toml::from_str(toml_str).unwrap();
384        assert_eq!(config.reflow_mode, ReflowMode::Default);
385
386        let toml_str = r#"
387            reflow-mode = "normalize"
388        "#;
389        let config: MD013Config = toml::from_str(toml_str).unwrap();
390        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
391
392        let toml_str = r#"
393            reflow-mode = "semantic-line-breaks"
394        "#;
395        let config: MD013Config = toml::from_str(toml_str).unwrap();
396        assert_eq!(config.reflow_mode, ReflowMode::SemanticLineBreaks);
397    }
398
399    #[test]
400    fn test_reflow_mode_deserialization_snake_case_alias() {
401        // Test that snake_case (alias for backwards compatibility) works
402        // Both for the enum value AND potentially for the field name
403        let toml_str = r#"
404            reflow-mode = "sentence_per_line"
405        "#;
406        let config: MD013Config = toml::from_str(toml_str).unwrap();
407        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
408
409        let toml_str = r#"
410            reflow-mode = "semantic_line_breaks"
411        "#;
412        let config: MD013Config = toml::from_str(toml_str).unwrap();
413        assert_eq!(config.reflow_mode, ReflowMode::SemanticLineBreaks);
414    }
415
416    #[test]
417    fn test_field_name_backwards_compatibility() {
418        // Test that snake_case field names work (for backwards compatibility)
419        // even though docs show kebab-case (like Ruff)
420        let toml_str = r#"
421            line_length = 100
422            code_blocks = false
423            reflow_mode = "sentence_per_line"
424        "#;
425        let config: MD013Config = toml::from_str(toml_str).unwrap();
426        assert_eq!(config.line_length.get(), 100);
427        assert!(!config.code_blocks);
428        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
429
430        // Also test mixed format (should work)
431        let toml_str = r#"
432            line-length = 100
433            code_blocks = false
434            reflow-mode = "normalize"
435        "#;
436        let config: MD013Config = toml::from_str(toml_str).unwrap();
437        assert_eq!(config.line_length.get(), 100);
438        assert!(!config.code_blocks);
439        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
440    }
441
442    #[test]
443    fn test_reflow_mode_serialization() {
444        // Test that serialization always uses kebab-case (primary format)
445        let config = MD013Config {
446            line_length: LineLength::from_const(80),
447            code_blocks: true,
448            code_spans: true,
449            tables: true,
450            headings: true,
451            paragraphs: true,
452            blockquotes: true,
453            strict: false,
454            stern: false,
455            heading_line_length: None,
456            code_block_line_length: None,
457            reflow: true,
458            reflow_mode: ReflowMode::SentencePerLine,
459            length_mode: LengthMode::default(),
460            abbreviations: Vec::new(),
461            require_sentence_capital: true,
462            ignore_link_urls: true,
463            atomic_spans: true,
464            ..Default::default()
465        };
466
467        let toml_str = toml::to_string(&config).unwrap();
468        assert!(toml_str.contains("sentence-per-line"));
469        assert!(!toml_str.contains("sentence_per_line"));
470
471        // Test serialization of SemanticLineBreaks
472        let config = MD013Config {
473            reflow_mode: ReflowMode::SemanticLineBreaks,
474            ..config
475        };
476        let toml_str = toml::to_string(&config).unwrap();
477        assert!(toml_str.contains("semantic-line-breaks"));
478        assert!(!toml_str.contains("semantic_line_breaks"));
479    }
480
481    #[test]
482    fn test_reflow_mode_invalid_value() {
483        // Test that invalid values fail deserialization
484        let toml_str = r#"
485            reflow-mode = "invalid_mode"
486        "#;
487        let result = toml::from_str::<MD013Config>(toml_str);
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn test_full_config_with_reflow_mode() {
493        let toml_str = r#"
494            line-length = 100
495            code-blocks = false
496            tables = false
497            headings = true
498            strict = true
499            reflow = true
500            reflow-mode = "sentence-per-line"
501        "#;
502        let config: MD013Config = toml::from_str(toml_str).unwrap();
503        assert_eq!(config.line_length.get(), 100);
504        assert!(!config.code_blocks);
505        assert!(!config.tables);
506        assert!(config.headings);
507        assert!(config.strict);
508        assert!(config.reflow);
509        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
510    }
511
512    #[test]
513    fn test_code_spans_default_true_and_parses() {
514        assert!(MD013Config::default().code_spans, "code-spans defaults to true");
515
516        let config: MD013Config = toml::from_str("code-spans = false").unwrap();
517        assert!(!config.code_spans);
518        // snake_case alias also works.
519        let config: MD013Config = toml::from_str("code_spans = false").unwrap();
520        assert!(!config.code_spans);
521    }
522
523    #[test]
524    fn test_paragraphs_default_true() {
525        // Test that paragraphs defaults to true
526        let config = MD013Config::default();
527        assert!(config.paragraphs, "paragraphs should default to true");
528    }
529
530    #[test]
531    fn test_paragraphs_deserialization_kebab_case() {
532        // Test kebab-case (canonical format)
533        let toml_str = r#"
534            paragraphs = false
535        "#;
536        let config: MD013Config = toml::from_str(toml_str).unwrap();
537        assert!(!config.paragraphs);
538    }
539
540    #[test]
541    fn test_paragraphs_full_config() {
542        // Test paragraphs in a full configuration with issue #121 use case
543        let toml_str = r#"
544            line-length = 80
545            code-blocks = true
546            tables = true
547            headings = false
548            paragraphs = false
549            reflow = true
550            reflow-mode = "sentence-per-line"
551        "#;
552        let config: MD013Config = toml::from_str(toml_str).unwrap();
553        assert_eq!(config.line_length.get(), 80);
554        assert!(config.code_blocks, "code-blocks should be true");
555        assert!(config.tables, "tables should be true");
556        assert!(!config.headings, "headings should be false");
557        assert!(!config.paragraphs, "paragraphs should be false");
558        assert!(config.reflow, "reflow should be true");
559        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
560    }
561
562    #[test]
563    fn test_abbreviations_for_reflow_empty_vec() {
564        // Empty vec means "use defaults only" -> returns None
565        let config = MD013Config {
566            abbreviations: Vec::new(),
567            ..Default::default()
568        };
569        assert!(
570            config.abbreviations_for_reflow().is_none(),
571            "Empty abbreviations should return None for reflow"
572        );
573    }
574
575    #[test]
576    fn test_abbreviations_for_reflow_with_custom() {
577        // Non-empty vec means "use these custom abbreviations" -> returns Some
578        let config = MD013Config {
579            abbreviations: vec!["Corp".to_string(), "Inc".to_string()],
580            ..Default::default()
581        };
582        let result = config.abbreviations_for_reflow();
583        assert!(result.is_some(), "Custom abbreviations should return Some");
584        let abbrevs = result.unwrap();
585        assert_eq!(abbrevs.len(), 2);
586        assert!(abbrevs.contains(&"Corp".to_string()));
587        assert!(abbrevs.contains(&"Inc".to_string()));
588    }
589}