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}
18
19/// Length calculation mode for MD013
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
21#[serde(rename_all = "kebab-case")]
22pub enum LengthMode {
23    /// Count Unicode characters (grapheme clusters)
24    /// Use this only if you need backward compatibility with character-based counting
25    #[serde(alias = "chars", alias = "characters")]
26    Chars,
27    /// Count visual display width (CJK characters = 2 columns, emoji = 2, etc.) - default
28    /// This is semantically correct: line-length = 80 means "80 columns on screen"
29    #[default]
30    #[serde(alias = "display", alias = "visual_width")]
31    Visual,
32    /// Count raw bytes (legacy mode, not recommended for Unicode text)
33    Bytes,
34}
35
36/// Configuration for MD013 (Line length)
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(rename_all = "kebab-case")]
39pub struct MD013Config {
40    /// Maximum line length (default: 80, 0 means no limit)
41    #[serde(default = "default_line_length", alias = "line_length")]
42    pub line_length: LineLength,
43
44    /// Check code blocks for line length (default: true)
45    #[serde(default = "default_code_blocks", alias = "code_blocks")]
46    pub code_blocks: bool,
47
48    /// Check tables for line length (default: false)
49    ///
50    /// Note: markdownlint defaults to true, but rumdl defaults to false to avoid
51    /// conflicts with MD060 (table formatting). Tables often require specific widths
52    /// for alignment, which can conflict with line length limits.
53    #[serde(default = "default_tables")]
54    pub tables: bool,
55
56    /// Check headings for line length (default: true)
57    #[serde(default = "default_headings")]
58    pub headings: bool,
59
60    /// Check paragraph/text line length (default: true)
61    /// When false, line length violations in regular text are not reported,
62    /// but reflow can still be used to format paragraphs.
63    #[serde(default = "default_paragraphs")]
64    pub paragraphs: bool,
65
66    /// Strict mode - disables exceptions for URLs, etc. (default: false)
67    #[serde(default)]
68    pub strict: bool,
69
70    /// Enable text reflow to wrap long lines (default: false)
71    #[serde(default, alias = "enable_reflow", alias = "enable-reflow")]
72    pub reflow: bool,
73
74    /// Reflow mode - how to handle reflowing (default: "long-lines")
75    #[serde(default, alias = "reflow_mode")]
76    pub reflow_mode: ReflowMode,
77
78    /// Length calculation mode (default: "chars")
79    /// - "chars": Count Unicode characters (emoji = 1, CJK = 1)
80    /// - "visual": Count visual display width (emoji = 2, CJK = 2)
81    /// - "bytes": Count raw bytes (not recommended for Unicode)
82    #[serde(default, alias = "length_mode")]
83    pub length_mode: LengthMode,
84
85    /// Custom abbreviations for sentence-per-line mode
86    /// Periods are optional - both "Dr" and "Dr." work the same
87    /// Inherited from global config, can be overridden per-rule
88    /// Custom abbreviations are always added to the built-in defaults
89    #[serde(default)]
90    pub abbreviations: Vec<String>,
91}
92
93fn default_line_length() -> LineLength {
94    LineLength::from_const(80)
95}
96
97fn default_code_blocks() -> bool {
98    true
99}
100
101fn default_tables() -> bool {
102    false
103}
104
105fn default_headings() -> bool {
106    true
107}
108
109fn default_paragraphs() -> bool {
110    true
111}
112
113impl Default for MD013Config {
114    fn default() -> Self {
115        Self {
116            line_length: default_line_length(),
117            code_blocks: default_code_blocks(),
118            tables: default_tables(),
119            headings: default_headings(),
120            paragraphs: default_paragraphs(),
121            strict: false,
122            reflow: false,
123            reflow_mode: ReflowMode::default(),
124            length_mode: LengthMode::default(),
125            abbreviations: Vec::new(),
126        }
127    }
128}
129
130impl MD013Config {
131    /// Convert abbreviations Vec to Option for ReflowOptions
132    /// Empty Vec means "use defaults only" so it maps to None
133    pub fn abbreviations_for_reflow(&self) -> Option<Vec<String>> {
134        if self.abbreviations.is_empty() {
135            None
136        } else {
137            Some(self.abbreviations.clone())
138        }
139    }
140}
141
142impl RuleConfig for MD013Config {
143    const RULE_NAME: &'static str = "MD013";
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_reflow_mode_deserialization_kebab_case() {
152        // Test that kebab-case (official format) works
153        // Note: field name is reflow-mode (kebab) due to struct-level rename_all
154        let toml_str = r#"
155            reflow-mode = "sentence-per-line"
156        "#;
157        let config: MD013Config = toml::from_str(toml_str).unwrap();
158        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
159
160        let toml_str = r#"
161            reflow-mode = "default"
162        "#;
163        let config: MD013Config = toml::from_str(toml_str).unwrap();
164        assert_eq!(config.reflow_mode, ReflowMode::Default);
165
166        let toml_str = r#"
167            reflow-mode = "normalize"
168        "#;
169        let config: MD013Config = toml::from_str(toml_str).unwrap();
170        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
171    }
172
173    #[test]
174    fn test_reflow_mode_deserialization_snake_case_alias() {
175        // Test that snake_case (alias for backwards compatibility) works
176        // Both for the enum value AND potentially for the field name
177        let toml_str = r#"
178            reflow-mode = "sentence_per_line"
179        "#;
180        let config: MD013Config = toml::from_str(toml_str).unwrap();
181        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
182    }
183
184    #[test]
185    fn test_field_name_backwards_compatibility() {
186        // Test that snake_case field names work (for backwards compatibility)
187        // even though docs show kebab-case (like Ruff)
188        let toml_str = r#"
189            line_length = 100
190            code_blocks = false
191            reflow_mode = "sentence_per_line"
192        "#;
193        let config: MD013Config = toml::from_str(toml_str).unwrap();
194        assert_eq!(config.line_length.get(), 100);
195        assert!(!config.code_blocks);
196        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
197
198        // Also test mixed format (should work)
199        let toml_str = r#"
200            line-length = 100
201            code_blocks = false
202            reflow-mode = "normalize"
203        "#;
204        let config: MD013Config = toml::from_str(toml_str).unwrap();
205        assert_eq!(config.line_length.get(), 100);
206        assert!(!config.code_blocks);
207        assert_eq!(config.reflow_mode, ReflowMode::Normalize);
208    }
209
210    #[test]
211    fn test_reflow_mode_serialization() {
212        // Test that serialization always uses kebab-case (primary format)
213        let config = MD013Config {
214            line_length: LineLength::from_const(80),
215            code_blocks: true,
216            tables: true,
217            headings: true,
218            paragraphs: true,
219            strict: false,
220            reflow: true,
221            reflow_mode: ReflowMode::SentencePerLine,
222            length_mode: LengthMode::default(),
223            abbreviations: Vec::new(),
224        };
225
226        let toml_str = toml::to_string(&config).unwrap();
227        assert!(toml_str.contains("sentence-per-line"));
228        assert!(!toml_str.contains("sentence_per_line"));
229    }
230
231    #[test]
232    fn test_reflow_mode_invalid_value() {
233        // Test that invalid values fail deserialization
234        let toml_str = r#"
235            reflow-mode = "invalid_mode"
236        "#;
237        let result = toml::from_str::<MD013Config>(toml_str);
238        assert!(result.is_err());
239    }
240
241    #[test]
242    fn test_full_config_with_reflow_mode() {
243        let toml_str = r#"
244            line-length = 100
245            code-blocks = false
246            tables = false
247            headings = true
248            strict = true
249            reflow = true
250            reflow-mode = "sentence-per-line"
251        "#;
252        let config: MD013Config = toml::from_str(toml_str).unwrap();
253        assert_eq!(config.line_length.get(), 100);
254        assert!(!config.code_blocks);
255        assert!(!config.tables);
256        assert!(config.headings);
257        assert!(config.strict);
258        assert!(config.reflow);
259        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
260    }
261
262    #[test]
263    fn test_paragraphs_default_true() {
264        // Test that paragraphs defaults to true
265        let config = MD013Config::default();
266        assert!(config.paragraphs, "paragraphs should default to true");
267    }
268
269    #[test]
270    fn test_paragraphs_deserialization_kebab_case() {
271        // Test kebab-case (canonical format)
272        let toml_str = r#"
273            paragraphs = false
274        "#;
275        let config: MD013Config = toml::from_str(toml_str).unwrap();
276        assert!(!config.paragraphs);
277    }
278
279    #[test]
280    fn test_paragraphs_full_config() {
281        // Test paragraphs in a full configuration with issue #121 use case
282        let toml_str = r#"
283            line-length = 80
284            code-blocks = true
285            tables = true
286            headings = false
287            paragraphs = false
288            reflow = true
289            reflow-mode = "sentence-per-line"
290        "#;
291        let config: MD013Config = toml::from_str(toml_str).unwrap();
292        assert_eq!(config.line_length.get(), 80);
293        assert!(config.code_blocks, "code-blocks should be true");
294        assert!(config.tables, "tables should be true");
295        assert!(!config.headings, "headings should be false");
296        assert!(!config.paragraphs, "paragraphs should be false");
297        assert!(config.reflow, "reflow should be true");
298        assert_eq!(config.reflow_mode, ReflowMode::SentencePerLine);
299    }
300
301    #[test]
302    fn test_abbreviations_for_reflow_empty_vec() {
303        // Empty vec means "use defaults only" -> returns None
304        let config = MD013Config {
305            abbreviations: Vec::new(),
306            ..Default::default()
307        };
308        assert!(
309            config.abbreviations_for_reflow().is_none(),
310            "Empty abbreviations should return None for reflow"
311        );
312    }
313
314    #[test]
315    fn test_abbreviations_for_reflow_with_custom() {
316        // Non-empty vec means "use these custom abbreviations" -> returns Some
317        let config = MD013Config {
318            abbreviations: vec!["Corp".to_string(), "Inc".to_string()],
319            ..Default::default()
320        };
321        let result = config.abbreviations_for_reflow();
322        assert!(result.is_some(), "Custom abbreviations should return Some");
323        let abbrevs = result.unwrap();
324        assert_eq!(abbrevs.len(), 2);
325        assert!(abbrevs.contains(&"Corp".to_string()));
326        assert!(abbrevs.contains(&"Inc".to_string()));
327    }
328}