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