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#[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
46pub(crate) struct MD055Linter {
50 context: Rc<Context>,
51 violations: Vec<RuleViolation>,
52 first_table_style: Option<(bool, bool)>, }
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 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 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 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 |"#; 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()); 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()); 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()); 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()); 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()); 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()); 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()); 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 #[test]
452 fn test_delimiter_rows_are_checked() {
453 let input = r#"| Header 1 | Header 2 |
455-------- | -------- |
456| Cell 1 | Cell 2 |"#; 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 assert!(!violations.is_empty()); let violation_lines: Vec<usize> = violations
464 .iter()
465 .map(|v| v.location().range.start.line)
466 .collect();
467 assert!(violation_lines.contains(&1)); let delimiter_violations: Vec<_> = violations
471 .iter()
472 .filter(|v| v.location().range.start.line == 1)
473 .collect();
474 assert!(!delimiter_violations.is_empty()); }
476
477 #[test]
478 fn test_column_position_accuracy() {
479 let input = r#"Header 1 | Header 2
481-------- | --------
482Data 1 | Data 2"#; 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 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 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 assert!(violation.location().range.start.character > 0);
508 }
509 }
510
511 #[test]
512 fn test_single_row_table() {
513 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()); }
520
521 #[test]
522 fn test_consistent_style_with_first_table_no_pipes() {
523 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 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 let input = r#"| Header 1 | Header 2 |
546| -------- | -------- |
547Cell 1 | Cell 2"#; 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 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()); }
559
560 #[test]
561 fn test_table_with_empty_cells() {
562 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()); }
571
572 #[test]
573 fn test_table_with_escaped_pipes() {
574 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()); }
583}