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