quickmark_core/rules/
md055.rs

1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7    linter::{range_from_tree_sitter, RuleViolation},
8    rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11// MD055-specific configuration types
12#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub enum TablePipeStyle {
14    #[serde(rename = "consistent")]
15    Consistent,
16    #[serde(rename = "leading_and_trailing")]
17    LeadingAndTrailing,
18    #[serde(rename = "leading_only")]
19    LeadingOnly,
20    #[serde(rename = "trailing_only")]
21    TrailingOnly,
22    #[serde(rename = "no_leading_or_trailing")]
23    NoLeadingOrTrailing,
24}
25
26impl Default for TablePipeStyle {
27    fn default() -> Self {
28        Self::Consistent
29    }
30}
31
32#[derive(Debug, PartialEq, Clone, Deserialize)]
33pub struct MD055TablePipeStyleTable {
34    #[serde(default)]
35    pub style: TablePipeStyle,
36}
37
38impl Default for MD055TablePipeStyleTable {
39    fn default() -> Self {
40        Self {
41            style: TablePipeStyle::Consistent,
42        }
43    }
44}
45
46/// MD055 - Table pipe style
47///
48/// This rule enforces consistent use of leading and trailing pipes in tables.
49pub(crate) struct MD055Linter {
50    context: Rc<Context>,
51    violations: Vec<RuleViolation>,
52    first_table_style: Option<(bool, bool)>, // (has_leading, has_trailing)
53}
54
55struct ViolationInfo {
56    message: String,
57    column_offset: usize,
58}
59
60impl MD055Linter {
61    pub fn new(context: Rc<Context>) -> Self {
62        Self {
63            context,
64            violations: Vec::new(),
65            first_table_style: None,
66        }
67    }
68}
69
70impl RuleLinter for MD055Linter {
71    fn feed(&mut self, node: &Node) {
72        if node.kind() == "pipe_table" {
73            self.check_table(node);
74        }
75    }
76
77    fn finalize(&mut self) -> Vec<RuleViolation> {
78        std::mem::take(&mut self.violations)
79    }
80}
81
82impl MD055Linter {
83    fn check_table(&mut self, table_node: &Node) {
84        let mut table_rows = Vec::new();
85        let mut cursor = table_node.walk();
86        for child in table_node.children(&mut cursor) {
87            if child.kind() == "pipe_table_header"
88                || child.kind() == "pipe_table_row"
89                || child.kind() == "pipe_table_delimiter_row"
90            {
91                table_rows.push(child);
92            }
93        }
94
95        if table_rows.is_empty() {
96            return;
97        }
98
99        let mut all_violation_infos = Vec::new();
100        {
101            // This scope limits the lifetime of `document_content`'s borrow
102            let document_content = self.context.document_content.borrow();
103            let config_style = &self.context.config.linters.settings.table_pipe_style.style;
104
105            let expected_style = match config_style {
106                TablePipeStyle::Consistent => {
107                    if let Some(style) = self.first_table_style {
108                        style
109                    } else {
110                        let first_row_text = table_rows[0]
111                            .utf8_text(document_content.as_bytes())
112                            .unwrap_or("")
113                            .trim();
114                        let has_leading = first_row_text.starts_with('|');
115                        let has_trailing =
116                            first_row_text.ends_with('|') && first_row_text.len() > 1;
117                        let style = (has_leading, has_trailing);
118                        self.first_table_style = Some(style);
119                        style
120                    }
121                }
122                TablePipeStyle::LeadingAndTrailing => (true, true),
123                TablePipeStyle::LeadingOnly => (true, false),
124                TablePipeStyle::TrailingOnly => (false, true),
125                TablePipeStyle::NoLeadingOrTrailing => (false, false),
126            };
127
128            for row in &table_rows {
129                let infos = self.check_row_pipe_style(row, expected_style, &document_content);
130                if !infos.is_empty() {
131                    all_violation_infos.push((*row, infos));
132                }
133            }
134        }
135
136        for (row, infos) in all_violation_infos {
137            for info in infos {
138                self.create_violation_at_position(&row, info.message, info.column_offset);
139            }
140        }
141    }
142
143    fn check_row_pipe_style(
144        &self,
145        row_node: &Node,
146        expected: (bool, bool),
147        document_content: &str,
148    ) -> Vec<ViolationInfo> {
149        let mut infos = Vec::new();
150        let (expected_leading, expected_trailing) = expected;
151
152        let row_text = row_node
153            .utf8_text(document_content.as_bytes())
154            .unwrap_or("");
155        let leading_whitespace_len = row_text.len() - row_text.trim_start().len();
156        let trimmed_text = row_text.trim();
157
158        let actual_leading = trimmed_text.starts_with('|');
159        let actual_trailing = trimmed_text.ends_with('|') && trimmed_text.len() > 1;
160
161        // Check leading pipe
162        if expected_leading != actual_leading {
163            let message = if expected_leading {
164                "Missing leading pipe"
165            } else {
166                "Unexpected leading pipe"
167            };
168            infos.push(ViolationInfo {
169                message: message.to_string(),
170                column_offset: leading_whitespace_len,
171            });
172        }
173
174        // Check trailing pipe
175        if expected_trailing != actual_trailing {
176            let message = if expected_trailing {
177                "Missing trailing pipe"
178            } else {
179                "Unexpected trailing pipe"
180            };
181            let pos = if actual_trailing {
182                leading_whitespace_len + trimmed_text.len().saturating_sub(1)
183            } else {
184                leading_whitespace_len + trimmed_text.len()
185            };
186            infos.push(ViolationInfo {
187                message: message.to_string(),
188                column_offset: pos,
189            });
190        }
191        infos
192    }
193
194    fn create_violation_at_position(&mut self, node: &Node, message: String, column_offset: usize) {
195        let mut range = range_from_tree_sitter(&node.range());
196        range.start.character += column_offset;
197        range.end.character = range.start.character + 1;
198
199        self.violations.push(RuleViolation::new(
200            &MD055,
201            message,
202            self.context.file_path.clone(),
203            range,
204        ));
205    }
206}
207
208pub const MD055: Rule = Rule {
209    id: "MD055",
210    alias: "table-pipe-style",
211    tags: &["table"],
212    description: "Table pipe style",
213    rule_type: RuleType::Token,
214    required_nodes: &["pipe_table"],
215    new_linter: |context| Box::new(MD055Linter::new(context)),
216};
217
218#[cfg(test)]
219mod test {
220    use std::path::PathBuf;
221
222    use crate::{
223        config::{MD055TablePipeStyleTable, RuleSeverity, TablePipeStyle},
224        linter::MultiRuleLinter,
225        test_utils::test_helpers::test_config_with_rules,
226    };
227
228    fn test_config() -> crate::config::QuickmarkConfig {
229        test_config_with_rules(vec![("table-pipe-style", RuleSeverity::Error)])
230    }
231
232    fn test_config_with_style(style: TablePipeStyle) -> crate::config::QuickmarkConfig {
233        let mut config = test_config();
234        config.linters.settings.table_pipe_style = MD055TablePipeStyleTable { style };
235        config
236    }
237
238    #[test]
239    fn test_consistent_style_with_leading_and_trailing() {
240        let input = r#"| Header 1 | Header 2 |
241| -------- | -------- |
242| Cell 1   | Cell 2   |"#;
243        let config = test_config_with_style(TablePipeStyle::Consistent);
244        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
245        let violations = linter.analyze();
246        assert_eq!(0, violations.len());
247    }
248
249    #[test]
250    fn test_consistent_style_with_leading_only() {
251        let input = r#"| Header 1 | Header 2
252| -------- | --------
253| Cell 1   | Cell 2"#;
254        let config = test_config_with_style(TablePipeStyle::Consistent);
255        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
256        let violations = linter.analyze();
257        assert_eq!(0, violations.len());
258    }
259
260    #[test]
261    fn test_consistent_style_with_trailing_only() {
262        let input = r#"Header 1 | Header 2 |
263-------- | -------- |
264Cell 1   | Cell 2   |"#;
265        let config = test_config_with_style(TablePipeStyle::Consistent);
266        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
267        let violations = linter.analyze();
268        assert_eq!(0, violations.len());
269    }
270
271    #[test]
272    fn test_consistent_style_with_no_leading_or_trailing() {
273        let input = r#"Header 1 | Header 2
274-------- | --------
275Cell 1   | Cell 2"#;
276        let config = test_config_with_style(TablePipeStyle::Consistent);
277        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
278        let violations = linter.analyze();
279        assert_eq!(0, violations.len());
280    }
281
282    #[test]
283    fn test_consistent_style_violation() {
284        let input = r#"| Header 1 | Header 2 |
285| -------- | -------- |
286Cell 1   | Cell 2   |"#; // Missing leading pipe in last row
287        let config = test_config_with_style(TablePipeStyle::Consistent);
288        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
289        let violations = linter.analyze();
290        assert_eq!(1, violations.len());
291        assert!(violations[0].message().contains("Missing leading pipe"));
292    }
293
294    #[test]
295    fn test_leading_and_trailing_style_valid() {
296        let input = r#"| Header 1 | Header 2 |
297| -------- | -------- |
298| Cell 1   | Cell 2   |"#;
299        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
300        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
301        let violations = linter.analyze();
302        assert_eq!(0, violations.len());
303    }
304
305    #[test]
306    fn test_leading_and_trailing_style_missing_leading() {
307        let input = r#"Header 1 | Header 2 |
308-------- | -------- |
309Cell 1   | Cell 2   |"#;
310        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
311        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
312        let violations = linter.analyze();
313        assert_eq!(3, violations.len()); // Header, delimiter, and data row missing leading pipe
314        for violation in &violations {
315            assert!(violation.message().contains("Missing leading pipe"));
316        }
317    }
318
319    #[test]
320    fn test_leading_and_trailing_style_missing_trailing() {
321        let input = r#"| Header 1 | Header 2
322| -------- | --------
323| Cell 1   | Cell 2"#;
324        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
325        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
326        let violations = linter.analyze();
327        assert_eq!(3, violations.len()); // Header, delimiter, and data row missing trailing pipe
328        for violation in &violations {
329            assert!(violation.message().contains("Missing trailing pipe"));
330        }
331    }
332
333    #[test]
334    fn test_leading_only_style_valid() {
335        let input = r#"| Header 1 | Header 2
336| -------- | --------
337| Cell 1   | Cell 2"#;
338        let config = test_config_with_style(TablePipeStyle::LeadingOnly);
339        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
340        let violations = linter.analyze();
341        assert_eq!(0, violations.len());
342    }
343
344    #[test]
345    fn test_leading_only_style_unexpected_trailing() {
346        let input = r#"| Header 1 | Header 2 |
347| -------- | -------- |
348| Cell 1   | Cell 2   |"#;
349        let config = test_config_with_style(TablePipeStyle::LeadingOnly);
350        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
351        let violations = linter.analyze();
352        assert_eq!(3, violations.len()); // Header, delimiter, and data row have unexpected trailing pipe
353        for violation in &violations {
354            assert!(violation.message().contains("Unexpected trailing pipe"));
355        }
356    }
357
358    #[test]
359    fn test_trailing_only_style_valid() {
360        let input = r#"Header 1 | Header 2 |
361-------- | -------- |
362Cell 1   | Cell 2   |"#;
363        let config = test_config_with_style(TablePipeStyle::TrailingOnly);
364        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
365        let violations = linter.analyze();
366        assert_eq!(0, violations.len());
367    }
368
369    #[test]
370    fn test_trailing_only_style_unexpected_leading() {
371        let input = r#"| Header 1 | Header 2 |
372| -------- | -------- |
373| Cell 1   | Cell 2   |"#;
374        let config = test_config_with_style(TablePipeStyle::TrailingOnly);
375        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
376        let violations = linter.analyze();
377        assert_eq!(3, violations.len()); // Header, delimiter, and data row have unexpected leading pipe
378        for violation in &violations {
379            assert!(violation.message().contains("Unexpected leading pipe"));
380        }
381    }
382
383    #[test]
384    fn test_no_leading_or_trailing_style_valid() {
385        let input = r#"Header 1 | Header 2
386-------- | --------
387Cell 1   | Cell 2"#;
388        let config = test_config_with_style(TablePipeStyle::NoLeadingOrTrailing);
389        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
390        let violations = linter.analyze();
391        assert_eq!(0, violations.len());
392    }
393
394    #[test]
395    fn test_no_leading_or_trailing_style_unexpected_leading() {
396        let input = r#"| Header 1 | Header 2
397| -------- | --------
398| Cell 1   | Cell 2"#;
399        let config = test_config_with_style(TablePipeStyle::NoLeadingOrTrailing);
400        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
401        let violations = linter.analyze();
402        assert_eq!(3, violations.len()); // Header, delimiter, and data row have unexpected leading pipe
403        for violation in &violations {
404            assert!(violation.message().contains("Unexpected leading pipe"));
405        }
406    }
407
408    #[test]
409    fn test_no_leading_or_trailing_style_unexpected_trailing() {
410        let input = r#"Header 1 | Header 2 |
411-------- | -------- |
412Cell 1   | Cell 2   |"#;
413        let config = test_config_with_style(TablePipeStyle::NoLeadingOrTrailing);
414        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
415        let violations = linter.analyze();
416        assert_eq!(3, violations.len()); // Header, delimiter, and data row have unexpected trailing pipe
417        for violation in &violations {
418            assert!(violation.message().contains("Unexpected trailing pipe"));
419        }
420    }
421
422    #[test]
423    fn test_multiple_tables_consistent_style() {
424        let input = r#"| Table 1 | Header |
425| ------- | ------ |
426| Cell    | Value  |
427
428Header | Column |
429------ | ------ |
430Data   | Info   |"#;
431        let config = test_config_with_style(TablePipeStyle::Consistent);
432        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
433        let violations = linter.analyze();
434        assert_eq!(3, violations.len()); // Second table (header, delimiter, data) should match first table's style
435        for violation in &violations {
436            assert!(violation.message().contains("Missing"));
437        }
438    }
439
440    #[test]
441    fn test_empty_table() {
442        let input = "";
443        let config = test_config_with_style(TablePipeStyle::Consistent);
444        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
445        let violations = linter.analyze();
446        assert_eq!(0, violations.len());
447    }
448
449    // Edge case tests discovered during parity validation
450
451    #[test]
452    fn test_delimiter_rows_are_checked() {
453        // During parity validation, discovered that delimiter rows must also be checked
454        let input = r#"| Header 1 | Header 2 |
455-------- | -------- |
456| Cell 1  | Cell 2   |"#; // Delimiter row missing leading/trailing pipes
457        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
458        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
459        let violations = linter.analyze();
460
461        // Should detect violations on delimiter row
462        assert!(!violations.is_empty()); // At least delimiter row violations
463        let violation_lines: Vec<usize> = violations
464            .iter()
465            .map(|v| v.location().range.start.line)
466            .collect();
467        assert!(violation_lines.contains(&1)); // Line 1 is the delimiter row (0-indexed)
468
469        // Verify that delimiter row violations are detected
470        let delimiter_violations: Vec<_> = violations
471            .iter()
472            .filter(|v| v.location().range.start.line == 1)
473            .collect();
474        assert!(!delimiter_violations.is_empty()); // Should have at least one violation on delimiter row
475    }
476
477    #[test]
478    fn test_column_position_accuracy() {
479        // During parity validation, discovered exact column positions matter
480        let input = r#"Header 1 | Header 2
481-------- | --------
482Data 1   | Data 2"#; // Missing both leading and trailing pipes
483        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
484        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
485        let violations = linter.analyze();
486
487        assert!(violations.len() >= 2);
488
489        // Leading pipe violations should be at column 0
490        let leading_violations: Vec<_> = violations
491            .iter()
492            .filter(|v| v.message().contains("Missing leading"))
493            .collect();
494        assert!(!leading_violations.is_empty());
495        for violation in leading_violations {
496            assert_eq!(0, violation.location().range.start.character);
497        }
498
499        // Trailing pipe violations should be at end of content
500        let trailing_violations: Vec<_> = violations
501            .iter()
502            .filter(|v| v.message().contains("Missing trailing"))
503            .collect();
504        assert!(!trailing_violations.is_empty());
505        for violation in trailing_violations {
506            // Each should be at the end of its respective line content
507            assert!(violation.location().range.start.character > 0);
508        }
509    }
510
511    #[test]
512    fn test_single_row_table() {
513        // Edge case: table with only header, no data rows
514        let input = r#"| Header 1 | Header 2 |"#;
515        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
516        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
517        let violations = linter.analyze();
518        assert_eq!(0, violations.len()); // Should be valid
519    }
520
521    #[test]
522    fn test_consistent_style_with_first_table_no_pipes() {
523        // Edge case: first table has no pipes, subsequent tables should match
524        let input = r#"Header 1 | Header 2
525-------- | --------
526Data 1   | Data 2
527
528| Another | Table |
529| ------- | ----- |
530| With    | Pipes |"#;
531        let config = test_config_with_style(TablePipeStyle::Consistent);
532        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
533        let violations = linter.analyze();
534
535        // Second table should violate because it has pipes when first doesn't
536        assert!(!violations.is_empty());
537        for violation in &violations {
538            assert!(violation.message().contains("Unexpected"));
539        }
540    }
541
542    #[test]
543    fn test_mixed_violations_same_row() {
544        // Edge case: row with both missing leading AND trailing pipes
545        let input = r#"| Header 1 | Header 2 |
546| -------- | -------- |
547Cell 1    | Cell 2"#; // Missing both leading and trailing pipes
548        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
549        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
550        let violations = linter.analyze();
551
552        // Should report both violations for the last row (0-indexed line 2)
553        let row3_violations: Vec<_> = violations
554            .iter()
555            .filter(|v| v.location().range.start.line == 2)
556            .collect();
557        assert_eq!(2, row3_violations.len()); // Both leading and trailing violations
558    }
559
560    #[test]
561    fn test_table_with_empty_cells() {
562        // Edge case: table with empty cells
563        let input = r#"| Header |  |
564| ------ |  |
565| Value  |  |"#;
566        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
567        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
568        let violations = linter.analyze();
569        assert_eq!(0, violations.len()); // Should be valid despite empty cells
570    }
571
572    #[test]
573    fn test_table_with_escaped_pipes() {
574        // Edge case: table with escaped pipes in content
575        let input = r#"| Header | Content |
576| ------ | ------- |
577| Value  | \| pipe |"#;
578        let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
579        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
580        let violations = linter.analyze();
581        assert_eq!(0, violations.len()); // Escaped pipes shouldn't affect style detection
582    }
583}