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    /// Strict mode - disables exceptions for URLs, etc. (default: false)
74    #[serde(default)]
75    pub strict: bool,
76
77    /// Enable text reflow to wrap long lines (default: false)
78    #[serde(default, alias = "enable_reflow", alias = "enable-reflow")]
79    pub reflow: bool,
80
81    /// Reflow mode - how to handle reflowing (default: "long-lines")
82    #[serde(default, alias = "reflow_mode")]
83    pub reflow_mode: ReflowMode,
84
85    /// Length calculation mode (default: "chars")
86    /// - "chars": Count Unicode characters (emoji = 1, CJK = 1)
87    /// - "visual": Count visual display width (emoji = 2, CJK = 2)
88    /// - "bytes": Count raw bytes (not recommended for Unicode)
89    #[serde(default, alias = "length_mode")]
90    pub length_mode: LengthMode,
91
92    /// Custom abbreviations for sentence-per-line mode
93    /// Periods are optional - both "Dr" and "Dr." work the same
94    /// Inherited from global config, can be overridden per-rule
95    /// Custom abbreviations are always added to the built-in defaults
96    #[serde(default)]
97    pub abbreviations: Vec<String>,
98}
99
100fn default_line_length() -> LineLength {
101    LineLength::from_const(80)
102}
103
104fn default_code_blocks() -> bool {
105    true
106}
107
108fn default_tables() -> bool {
109    false
110}
111
112fn default_headings() -> bool {
113    true
114}
115
116fn default_paragraphs() -> bool {
117    true
118}
119
120impl Default for MD013Config {
121    fn default() -> Self {
122        Self {
123            line_length: default_line_length(),
124            code_blocks: default_code_blocks(),
125            tables: default_tables(),
126            headings: default_headings(),
127            paragraphs: default_paragraphs(),
128            strict: false,
129            reflow: false,
130            reflow_mode: ReflowMode::default(),
131            length_mode: LengthMode::default(),
132            abbreviations: Vec::new(),
133        }
134    }
135}
136
137impl MD013Config {
138    /// Convert abbreviations Vec to Option for ReflowOptions
139    /// Empty Vec means "use defaults only" so it maps to None
140    pub fn abbreviations_for_reflow(&self) -> Option<Vec<String>> {
141        if self.abbreviations.is_empty() {
142            None
143        } else {
144            Some(self.abbreviations.clone())
145        }
146    }
147}
148
149impl RuleConfig for MD013Config {
150    const RULE_NAME: &'static str = "MD013";
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_reflow_mode_deserialization_kebab_case() {
159        // Test that kebab-case (official format) works
160        // Note: field name is reflow-mode (kebab) due to struct-level rename_all
161        let toml_str = r#"
162            reflow-mode = "sentence-per-line"
163        "#;
164        let config: MD013Config = toml::from_str(toml_str).unwrap();
165        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
166
167        let toml_str = r#"
168            reflow-mode = "default"
169        "#;
170        let config: MD013Config = toml::from_str(toml_str).unwrap();
171        assert_eq!(config.reflow_mode, ReflowMode::Default);
172
173        let toml_str = r#"
174            reflow-mode = "normalize"
175        "#;
176        let config: MD013Config = toml::from_str(toml_str).unwrap();
177        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
178
179        let toml_str = r#"
180            reflow-mode = "semantic-line-breaks"
181        "#;
182        let config: MD013Config = toml::from_str(toml_str).unwrap();
183        assert_eq!(config.reflow_mode, ReflowMode::SemanticLineBreaks);
184    }
185
186    #[test]
187    fn test_reflow_mode_deserialization_snake_case_alias() {
188        // Test that snake_case (alias for backwards compatibility) works
189        // Both for the enum value AND potentially for the field name
190        let toml_str = r#"
191            reflow-mode = "sentence_per_line"
192        "#;
193        let config: MD013Config = toml::from_str(toml_str).unwrap();
194        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
195
196        let toml_str = r#"
197            reflow-mode = "semantic_line_breaks"
198        "#;
199        let config: MD013Config = toml::from_str(toml_str).unwrap();
200        assert_eq!(config.reflow_mode, ReflowMode::SemanticLineBreaks);
201    }
202
203    #[test]
204    fn test_field_name_backwards_compatibility() {
205        // Test that snake_case field names work (for backwards compatibility)
206        // even though docs show kebab-case (like Ruff)
207        let toml_str = r#"
208            line_length = 100
209            code_blocks = false
210            reflow_mode = "sentence_per_line"
211        "#;
212        let config: MD013Config = toml::from_str(toml_str).unwrap();
213        assert_eq!(config.line_length.get(), 100);
214        assert!(!config.code_blocks);
215        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
216
217        // Also test mixed format (should work)
218        let toml_str = r#"
219            line-length = 100
220            code_blocks = false
221            reflow-mode = "normalize"
222        "#;
223        let config: MD013Config = toml::from_str(toml_str).unwrap();
224        assert_eq!(config.line_length.get(), 100);
225        assert!(!config.code_blocks);
226        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
227    }
228
229    #[test]
230    fn test_reflow_mode_serialization() {
231        // Test that serialization always uses kebab-case (primary format)
232        let config = MD013Config {
233            line_length: LineLength::from_const(80),
234            code_blocks: true,
235            tables: true,
236            headings: true,
237            paragraphs: true,
238            strict: false,
239            reflow: true,
240            reflow_mode: ReflowMode::SentencePerLine,
241            length_mode: LengthMode::default(),
242            abbreviations: Vec::new(),
243        };
244
245        let toml_str = toml::to_string(&config).unwrap();
246        assert!(toml_str.contains("sentence-per-line"));
247        assert!(!toml_str.contains("sentence_per_line"));
248
249        // Test serialization of SemanticLineBreaks
250        let config = MD013Config {
251            reflow_mode: ReflowMode::SemanticLineBreaks,
252            ..config
253        };
254        let toml_str = toml::to_string(&config).unwrap();
255        assert!(toml_str.contains("semantic-line-breaks"));
256        assert!(!toml_str.contains("semantic_line_breaks"));
257    }
258
259    #[test]
260    fn test_reflow_mode_invalid_value() {
261        // Test that invalid values fail deserialization
262        let toml_str = r#"
263            reflow-mode = "invalid_mode"
264        "#;
265        let result = toml::from_str::<MD013Config>(toml_str);
266        assert!(result.is_err());
267    }
268
269    #[test]
270    fn test_full_config_with_reflow_mode() {
271        let toml_str = r#"
272            line-length = 100
273            code-blocks = false
274            tables = false
275            headings = true
276            strict = true
277            reflow = true
278            reflow-mode = "sentence-per-line"
279        "#;
280        let config: MD013Config = toml::from_str(toml_str).unwrap();
281        assert_eq!(config.line_length.get(), 100);
282        assert!(!config.code_blocks);
283        assert!(!config.tables);
284        assert!(config.headings);
285        assert!(config.strict);
286        assert!(config.reflow);
287        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
288    }
289
290    #[test]
291    fn test_paragraphs_default_true() {
292        // Test that paragraphs defaults to true
293        let config = MD013Config::default();
294        assert!(config.paragraphs, "paragraphs should default to true");
295    }
296
297    #[test]
298    fn test_paragraphs_deserialization_kebab_case() {
299        // Test kebab-case (canonical format)
300        let toml_str = r#"
301            paragraphs = false
302        "#;
303        let config: MD013Config = toml::from_str(toml_str).unwrap();
304        assert!(!config.paragraphs);
305    }
306
307    #[test]
308    fn test_paragraphs_full_config() {
309        // Test paragraphs in a full configuration with issue #121 use case
310        let toml_str = r#"
311            line-length = 80
312            code-blocks = true
313            tables = true
314            headings = false
315            paragraphs = false
316            reflow = true
317            reflow-mode = "sentence-per-line"
318        "#;
319        let config: MD013Config = toml::from_str(toml_str).unwrap();
320        assert_eq!(config.line_length.get(), 80);
321        assert!(config.code_blocks, "code-blocks should be true");
322        assert!(config.tables, "tables should be true");
323        assert!(!config.headings, "headings should be false");
324        assert!(!config.paragraphs, "paragraphs should be false");
325        assert!(config.reflow, "reflow should be true");
326        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
327    }
328
329    #[test]
330    fn test_abbreviations_for_reflow_empty_vec() {
331        // Empty vec means "use defaults only" -> returns None
332        let config = MD013Config {
333            abbreviations: Vec::new(),
334            ..Default::default()
335        };
336        assert!(
337            config.abbreviations_for_reflow().is_none(),
338            "Empty abbreviations should return None for reflow"
339        );
340    }
341
342    #[test]
343    fn test_abbreviations_for_reflow_with_custom() {
344        // Non-empty vec means "use these custom abbreviations" -> returns Some
345        let config = MD013Config {
346            abbreviations: vec!["Corp".to_string(), "Inc".to_string()],
347            ..Default::default()
348        };
349        let result = config.abbreviations_for_reflow();
350        assert!(result.is_some(), "Custom abbreviations should return Some");
351        let abbrevs = result.unwrap();
352        assert_eq!(abbrevs.len(), 2);
353        assert!(abbrevs.contains(&"Corp".to_string()));
354        assert!(abbrevs.contains(&"Inc".to_string()));
355    }
356}