1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::TableUtils;
4use unicode_width::UnicodeWidthStr;
5
6mod md060_config;
7use crate::md013_line_length::MD013Config;
8use md060_config::MD060Config;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11enum ColumnAlignment {
12 Left,
13 Center,
14 Right,
15}
16
17#[derive(Debug, Clone)]
18struct TableFormatResult {
19 lines: Vec<String>,
20 auto_compacted: bool,
21 aligned_width: Option<usize>,
22}
23
24#[derive(Debug, Clone)]
143pub struct MD060TableFormat {
144 config: MD060Config,
145 md013_line_length: usize,
146}
147
148impl Default for MD060TableFormat {
149 fn default() -> Self {
150 Self {
151 config: MD060Config::default(),
152 md013_line_length: 80,
153 }
154 }
155}
156
157impl MD060TableFormat {
158 pub fn new(enabled: bool, style: String) -> Self {
159 use crate::types::LineLength;
160 Self {
161 config: MD060Config {
162 enabled,
163 style,
164 max_width: LineLength::from_const(0),
165 },
166 md013_line_length: 80, }
168 }
169
170 pub fn from_config_struct(config: MD060Config, md013_line_length: usize) -> Self {
171 Self {
172 config,
173 md013_line_length,
174 }
175 }
176
177 fn effective_max_width(&self) -> usize {
182 if self.config.max_width.is_unlimited() {
183 self.md013_line_length
184 } else {
185 self.config.max_width.get()
186 }
187 }
188
189 fn contains_problematic_chars(text: &str) -> bool {
200 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
205
206 fn calculate_cell_display_width(cell_content: &str) -> usize {
207 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
208 masked.trim().width()
209 }
210
211 fn parse_table_row(line: &str) -> Vec<String> {
212 let trimmed = line.trim();
213 let masked = TableUtils::mask_pipes_for_table_parsing(trimmed);
214
215 let has_leading = masked.starts_with('|');
216 let has_trailing = masked.ends_with('|');
217
218 let mut masked_content = masked.as_str();
219 let mut orig_content = trimmed;
220
221 if has_leading {
222 masked_content = &masked_content[1..];
223 orig_content = &orig_content[1..];
224 }
225 if has_trailing && !masked_content.is_empty() {
226 masked_content = &masked_content[..masked_content.len() - 1];
227 orig_content = &orig_content[..orig_content.len() - 1];
228 }
229
230 let masked_parts: Vec<&str> = masked_content.split('|').collect();
231 let mut cells = Vec::new();
232 let mut pos = 0;
233
234 for masked_cell in masked_parts {
235 let cell_len = masked_cell.len();
236 let orig_cell = if pos + cell_len <= orig_content.len() {
237 &orig_content[pos..pos + cell_len]
238 } else {
239 masked_cell
240 };
241 cells.push(orig_cell.to_string());
242 pos += cell_len + 1;
243 }
244
245 cells
246 }
247
248 fn is_delimiter_row(row: &[String]) -> bool {
249 if row.is_empty() {
250 return false;
251 }
252 row.iter().all(|cell| {
253 let trimmed = cell.trim();
254 !trimmed.is_empty()
257 && trimmed.contains('-')
258 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
259 })
260 }
261
262 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
263 delimiter_row
264 .iter()
265 .map(|cell| {
266 let trimmed = cell.trim();
267 let has_left_colon = trimmed.starts_with(':');
268 let has_right_colon = trimmed.ends_with(':');
269
270 match (has_left_colon, has_right_colon) {
271 (true, true) => ColumnAlignment::Center,
272 (false, true) => ColumnAlignment::Right,
273 _ => ColumnAlignment::Left,
274 }
275 })
276 .collect()
277 }
278
279 fn calculate_column_widths(table_lines: &[&str]) -> Vec<usize> {
280 let mut column_widths = Vec::new();
281 let mut delimiter_cells: Option<Vec<String>> = None;
282
283 for line in table_lines {
284 let cells = Self::parse_table_row(line);
285
286 if Self::is_delimiter_row(&cells) {
288 delimiter_cells = Some(cells);
289 continue;
290 }
291
292 for (i, cell) in cells.iter().enumerate() {
293 let width = Self::calculate_cell_display_width(cell);
294 if i >= column_widths.len() {
295 column_widths.push(width);
296 } else {
297 column_widths[i] = column_widths[i].max(width);
298 }
299 }
300 }
301
302 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
305
306 if let Some(delimiter_cells) = delimiter_cells {
309 for (i, cell) in delimiter_cells.iter().enumerate() {
310 if i < final_widths.len() {
311 let trimmed = cell.trim();
312 let has_left_colon = trimmed.starts_with(':');
313 let has_right_colon = trimmed.ends_with(':');
314 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
315
316 let min_width_for_delimiter = 3 + colon_count;
318 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
319 }
320 }
321 }
322
323 final_widths
324 }
325
326 fn format_table_row(
327 cells: &[String],
328 column_widths: &[usize],
329 column_alignments: &[ColumnAlignment],
330 is_delimiter: bool,
331 ) -> String {
332 let formatted_cells: Vec<String> = cells
333 .iter()
334 .enumerate()
335 .map(|(i, cell)| {
336 let target_width = column_widths.get(i).copied().unwrap_or(0);
337 if is_delimiter {
338 let trimmed = cell.trim();
339 let has_left_colon = trimmed.starts_with(':');
340 let has_right_colon = trimmed.ends_with(':');
341
342 let dash_count = if has_left_colon && has_right_colon {
345 target_width.saturating_sub(2)
346 } else if has_left_colon || has_right_colon {
347 target_width.saturating_sub(1)
348 } else {
349 target_width
350 };
351
352 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
354 format!(":{dashes}:")
355 } else if has_left_colon {
356 format!(":{dashes}")
357 } else if has_right_colon {
358 format!("{dashes}:")
359 } else {
360 dashes
361 };
362
363 format!(" {delimiter_content} ")
365 } else {
366 let trimmed = cell.trim();
367 let current_width = Self::calculate_cell_display_width(cell);
368 let padding = target_width.saturating_sub(current_width);
369
370 let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
372 match alignment {
373 ColumnAlignment::Left => {
374 format!(" {trimmed}{} ", " ".repeat(padding))
376 }
377 ColumnAlignment::Center => {
378 let left_padding = padding / 2;
380 let right_padding = padding - left_padding;
381 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
382 }
383 ColumnAlignment::Right => {
384 format!(" {}{trimmed} ", " ".repeat(padding))
386 }
387 }
388 }
389 })
390 .collect();
391
392 format!("|{}|", formatted_cells.join("|"))
393 }
394
395 fn format_table_compact(cells: &[String]) -> String {
396 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
397 format!("|{}|", formatted_cells.join("|"))
398 }
399
400 fn format_table_tight(cells: &[String]) -> String {
401 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
402 format!("|{}|", formatted_cells.join("|"))
403 }
404
405 fn detect_table_style(table_lines: &[&str]) -> Option<String> {
406 if table_lines.is_empty() {
407 return None;
408 }
409
410 let mut is_tight = true;
413 let mut is_compact = true;
414
415 for line in table_lines {
416 let cells = Self::parse_table_row(line);
417
418 if cells.is_empty() {
419 continue;
420 }
421
422 if Self::is_delimiter_row(&cells) {
424 continue;
425 }
426
427 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
429
430 let row_has_single_space = cells.iter().all(|cell| {
432 let trimmed = cell.trim();
433 cell == &format!(" {trimmed} ")
434 });
435
436 if !row_has_no_padding {
438 is_tight = false;
439 }
440
441 if !row_has_single_space {
443 is_compact = false;
444 }
445
446 if !is_tight && !is_compact {
448 return Some("aligned".to_string());
449 }
450 }
451
452 if is_tight {
454 Some("tight".to_string())
455 } else if is_compact {
456 Some("compact".to_string())
457 } else {
458 Some("aligned".to_string())
459 }
460 }
461
462 fn fix_table_block(
463 &self,
464 lines: &[&str],
465 table_block: &crate::utils::table_utils::TableBlock,
466 ) -> TableFormatResult {
467 let mut result = Vec::new();
468 let mut auto_compacted = false;
469 let mut aligned_width = None;
470
471 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
472 .chain(std::iter::once(lines[table_block.delimiter_line]))
473 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
474 .collect();
475
476 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
477 return TableFormatResult {
478 lines: table_lines.iter().map(|s| s.to_string()).collect(),
479 auto_compacted: false,
480 aligned_width: None,
481 };
482 }
483
484 let style = self.config.style.as_str();
485
486 match style {
487 "any" => {
488 let detected_style = Self::detect_table_style(&table_lines);
489 if detected_style.is_none() {
490 return TableFormatResult {
491 lines: table_lines.iter().map(|s| s.to_string()).collect(),
492 auto_compacted: false,
493 aligned_width: None,
494 };
495 }
496
497 let target_style = detected_style.unwrap();
498
499 let delimiter_cells = Self::parse_table_row(table_lines[1]);
501 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
502
503 for line in &table_lines {
504 let cells = Self::parse_table_row(line);
505 match target_style.as_str() {
506 "tight" => result.push(Self::format_table_tight(&cells)),
507 "compact" => result.push(Self::format_table_compact(&cells)),
508 _ => {
509 let column_widths = Self::calculate_column_widths(&table_lines);
510 let is_delimiter = Self::is_delimiter_row(&cells);
511 result.push(Self::format_table_row(
512 &cells,
513 &column_widths,
514 &column_alignments,
515 is_delimiter,
516 ));
517 }
518 }
519 }
520 }
521 "compact" => {
522 for line in table_lines {
523 let cells = Self::parse_table_row(line);
524 result.push(Self::format_table_compact(&cells));
525 }
526 }
527 "tight" => {
528 for line in table_lines {
529 let cells = Self::parse_table_row(line);
530 result.push(Self::format_table_tight(&cells));
531 }
532 }
533 "aligned" => {
534 let column_widths = Self::calculate_column_widths(&table_lines);
535
536 let num_columns = column_widths.len();
538 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
539 aligned_width = Some(calc_aligned_width);
540
541 if calc_aligned_width > self.effective_max_width() {
543 auto_compacted = true;
544 for line in table_lines {
545 let cells = Self::parse_table_row(line);
546 result.push(Self::format_table_compact(&cells));
547 }
548 } else {
549 let delimiter_cells = Self::parse_table_row(table_lines[1]);
551 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
552
553 for line in table_lines {
554 let cells = Self::parse_table_row(line);
555 let is_delimiter = Self::is_delimiter_row(&cells);
556 result.push(Self::format_table_row(
557 &cells,
558 &column_widths,
559 &column_alignments,
560 is_delimiter,
561 ));
562 }
563 }
564 }
565 _ => {
566 return TableFormatResult {
567 lines: table_lines.iter().map(|s| s.to_string()).collect(),
568 auto_compacted: false,
569 aligned_width: None,
570 };
571 }
572 }
573
574 TableFormatResult {
575 lines: result,
576 auto_compacted,
577 aligned_width,
578 }
579 }
580}
581
582impl Rule for MD060TableFormat {
583 fn name(&self) -> &'static str {
584 "MD060"
585 }
586
587 fn description(&self) -> &'static str {
588 "Table columns should be consistently aligned"
589 }
590
591 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
592 !self.config.enabled || !ctx.likely_has_tables()
593 }
594
595 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
596 if !self.config.enabled {
597 return Ok(Vec::new());
598 }
599
600 let content = ctx.content;
601 let line_index = &ctx.line_index;
602 let mut warnings = Vec::new();
603
604 let lines: Vec<&str> = content.lines().collect();
605 let table_blocks = &ctx.table_blocks;
606
607 for table_block in table_blocks {
608 let format_result = self.fix_table_block(&lines, table_block);
609
610 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
611 .chain(std::iter::once(table_block.delimiter_line))
612 .chain(table_block.content_lines.iter().copied())
613 .collect();
614
615 for (i, &line_idx) in table_line_indices.iter().enumerate() {
616 let original = lines[line_idx];
617 let fixed = &format_result.lines[i];
618
619 if original != fixed {
620 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
621
622 let message = if format_result.auto_compacted {
623 if let Some(width) = format_result.aligned_width {
624 format!(
625 "Table too wide for aligned formatting ({} chars > max-width: {})",
626 width,
627 self.effective_max_width()
628 )
629 } else {
630 "Table too wide for aligned formatting".to_string()
631 }
632 } else {
633 "Table columns should be aligned".to_string()
634 };
635
636 warnings.push(LintWarning {
637 rule_name: Some(self.name().to_string()),
638 severity: Severity::Warning,
639 message,
640 line: start_line,
641 column: start_col,
642 end_line,
643 end_column: end_col,
644 fix: Some(crate::rule::Fix {
645 range: line_index.whole_line_range(line_idx + 1),
646 replacement: if line_idx < lines.len() - 1 {
647 format!("{fixed}\n")
648 } else {
649 fixed.clone()
650 },
651 }),
652 });
653 }
654 }
655 }
656
657 Ok(warnings)
658 }
659
660 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
661 if !self.config.enabled {
662 return Ok(ctx.content.to_string());
663 }
664
665 let content = ctx.content;
666 let lines: Vec<&str> = content.lines().collect();
667 let table_blocks = &ctx.table_blocks;
668
669 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
670
671 for table_block in table_blocks {
672 let format_result = self.fix_table_block(&lines, table_block);
673
674 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
675 .chain(std::iter::once(table_block.delimiter_line))
676 .chain(table_block.content_lines.iter().copied())
677 .collect();
678
679 for (i, &line_idx) in table_line_indices.iter().enumerate() {
680 result_lines[line_idx] = format_result.lines[i].clone();
681 }
682 }
683
684 let mut fixed = result_lines.join("\n");
685 if content.ends_with('\n') && !fixed.ends_with('\n') {
686 fixed.push('\n');
687 }
688 Ok(fixed)
689 }
690
691 fn as_any(&self) -> &dyn std::any::Any {
692 self
693 }
694
695 fn default_config_section(&self) -> Option<(String, toml::Value)> {
696 let json_value = serde_json::to_value(&self.config).ok()?;
697 Some((
698 self.name().to_string(),
699 crate::rule_config_serde::json_to_toml_value(&json_value)?,
700 ))
701 }
702
703 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
704 where
705 Self: Sized,
706 {
707 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
708 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
709 Box::new(Self::from_config_struct(rule_config, md013_config.line_length.get()))
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::lint_context::LintContext;
717 use crate::types::LineLength;
718
719 #[test]
720 fn test_md060_disabled_by_default() {
721 let rule = MD060TableFormat::default();
722 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724
725 let warnings = rule.check(&ctx).unwrap();
726 assert_eq!(warnings.len(), 0);
727
728 let fixed = rule.fix(&ctx).unwrap();
729 assert_eq!(fixed, content);
730 }
731
732 #[test]
733 fn test_md060_align_simple_ascii_table() {
734 let rule = MD060TableFormat::new(true, "aligned".to_string());
735
736 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
738
739 let fixed = rule.fix(&ctx).unwrap();
740 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
741 assert_eq!(fixed, expected);
742
743 let lines: Vec<&str> = fixed.lines().collect();
745 assert_eq!(lines[0].len(), lines[1].len());
746 assert_eq!(lines[1].len(), lines[2].len());
747 }
748
749 #[test]
750 fn test_md060_cjk_characters_aligned_correctly() {
751 let rule = MD060TableFormat::new(true, "aligned".to_string());
752
753 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
755
756 let fixed = rule.fix(&ctx).unwrap();
757
758 let lines: Vec<&str> = fixed.lines().collect();
759 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
760 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
761
762 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
763 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
764
765 assert_eq!(width1, width3);
766 }
767
768 #[test]
769 fn test_md060_basic_emoji() {
770 let rule = MD060TableFormat::new(true, "aligned".to_string());
771
772 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
774
775 let fixed = rule.fix(&ctx).unwrap();
776 assert!(fixed.contains("Status"));
777 }
778
779 #[test]
780 fn test_md060_zwj_emoji_skipped() {
781 let rule = MD060TableFormat::new(true, "aligned".to_string());
782
783 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785
786 let fixed = rule.fix(&ctx).unwrap();
787 assert_eq!(fixed, content);
788 }
789
790 #[test]
791 fn test_md060_inline_code_with_pipes() {
792 let rule = MD060TableFormat::new(true, "aligned".to_string());
793
794 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]|[0-9]` |";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
796
797 let fixed = rule.fix(&ctx).unwrap();
798 assert!(fixed.contains("`[0-9]|[0-9]`"));
799 }
800
801 #[test]
802 fn test_md060_compact_style() {
803 let rule = MD060TableFormat::new(true, "compact".to_string());
804
805 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
807
808 let fixed = rule.fix(&ctx).unwrap();
809 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
810 assert_eq!(fixed, expected);
811 }
812
813 #[test]
814 fn test_md060_tight_style() {
815 let rule = MD060TableFormat::new(true, "tight".to_string());
816
817 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
819
820 let fixed = rule.fix(&ctx).unwrap();
821 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
822 assert_eq!(fixed, expected);
823 }
824
825 #[test]
826 fn test_md060_any_style_consistency() {
827 let rule = MD060TableFormat::new(true, "any".to_string());
828
829 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
832
833 let fixed = rule.fix(&ctx).unwrap();
834 assert_eq!(fixed, content);
835
836 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
838 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard);
839
840 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
841 assert_eq!(fixed_aligned, content_aligned);
842 }
843
844 #[test]
845 fn test_md060_empty_cells() {
846 let rule = MD060TableFormat::new(true, "aligned".to_string());
847
848 let content = "| A | B |\n|---|---|\n| | X |";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
850
851 let fixed = rule.fix(&ctx).unwrap();
852 assert!(fixed.contains("|"));
853 }
854
855 #[test]
856 fn test_md060_mixed_content() {
857 let rule = MD060TableFormat::new(true, "aligned".to_string());
858
859 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
861
862 let fixed = rule.fix(&ctx).unwrap();
863 assert!(fixed.contains("δΈζ"));
864 assert!(fixed.contains("NYC"));
865 }
866
867 #[test]
868 fn test_md060_preserve_alignment_indicators() {
869 let rule = MD060TableFormat::new(true, "aligned".to_string());
870
871 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
873
874 let fixed = rule.fix(&ctx).unwrap();
875
876 assert!(fixed.contains(":---"), "Should contain left alignment");
877 assert!(fixed.contains(":----:"), "Should contain center alignment");
878 assert!(fixed.contains("----:"), "Should contain right alignment");
879 }
880
881 #[test]
882 fn test_md060_minimum_column_width() {
883 let rule = MD060TableFormat::new(true, "aligned".to_string());
884
885 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889
890 let fixed = rule.fix(&ctx).unwrap();
891
892 let lines: Vec<&str> = fixed.lines().collect();
893 assert_eq!(lines[0].len(), lines[1].len());
894 assert_eq!(lines[1].len(), lines[2].len());
895
896 assert!(fixed.contains("ID "), "Short content should be padded");
898 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
899 }
900
901 #[test]
902 fn test_md060_auto_compact_exceeds_default_threshold() {
903 let config = MD060Config {
905 enabled: true,
906 style: "aligned".to_string(),
907 max_width: LineLength::from_const(0),
908 };
909 let rule = MD060TableFormat::from_config_struct(config, 80);
910
911 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
916
917 let fixed = rule.fix(&ctx).unwrap();
918
919 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
921 assert!(fixed.contains("| --- | --- | --- |"));
922 assert!(fixed.contains("| Short | Data | Here |"));
923
924 let lines: Vec<&str> = fixed.lines().collect();
926 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
928 }
929
930 #[test]
931 fn test_md060_auto_compact_exceeds_explicit_threshold() {
932 let config = MD060Config {
934 enabled: true,
935 style: "aligned".to_string(),
936 max_width: LineLength::from_const(50),
937 };
938 let rule = MD060TableFormat::from_config_struct(config, 80); let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
945
946 let fixed = rule.fix(&ctx).unwrap();
947
948 assert!(
950 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
951 );
952 assert!(fixed.contains("| --- | --- | --- |"));
953 assert!(fixed.contains("| Data | Data | Data |"));
954
955 let lines: Vec<&str> = fixed.lines().collect();
957 assert!(lines[0].len() != lines[2].len());
958 }
959
960 #[test]
961 fn test_md060_stays_aligned_under_threshold() {
962 let config = MD060Config {
964 enabled: true,
965 style: "aligned".to_string(),
966 max_width: LineLength::from_const(100),
967 };
968 let rule = MD060TableFormat::from_config_struct(config, 80);
969
970 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
973
974 let fixed = rule.fix(&ctx).unwrap();
975
976 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
978 assert_eq!(fixed, expected);
979
980 let lines: Vec<&str> = fixed.lines().collect();
981 assert_eq!(lines[0].len(), lines[1].len());
982 assert_eq!(lines[1].len(), lines[2].len());
983 }
984
985 #[test]
986 fn test_md060_width_calculation_formula() {
987 let config = MD060Config {
989 enabled: true,
990 style: "aligned".to_string(),
991 max_width: LineLength::from_const(0),
992 };
993 let rule = MD060TableFormat::from_config_struct(config, 30);
994
995 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1000
1001 let fixed = rule.fix(&ctx).unwrap();
1002
1003 let lines: Vec<&str> = fixed.lines().collect();
1005 assert_eq!(lines[0].len(), lines[1].len());
1006 assert_eq!(lines[1].len(), lines[2].len());
1007 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1011 enabled: true,
1012 style: "aligned".to_string(),
1013 max_width: LineLength::from_const(24),
1014 };
1015 let rule_tight = MD060TableFormat::from_config_struct(config_tight, 80);
1016
1017 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1018
1019 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1021 assert!(fixed_compact.contains("| --- | --- | --- |"));
1022 }
1023
1024 #[test]
1025 fn test_md060_very_wide_table_auto_compacts() {
1026 let config = MD060Config {
1027 enabled: true,
1028 style: "aligned".to_string(),
1029 max_width: LineLength::from_const(0),
1030 };
1031 let rule = MD060TableFormat::from_config_struct(config, 80);
1032
1033 let content = "| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |\n|---|---|---|---|---|---|---|---|\n| A | B | C | D | E | F | G | H |";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1038
1039 let fixed = rule.fix(&ctx).unwrap();
1040
1041 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1043 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1044 }
1045
1046 #[test]
1047 fn test_md060_inherit_from_md013_line_length() {
1048 let config = MD060Config {
1050 enabled: true,
1051 style: "aligned".to_string(),
1052 max_width: LineLength::from_const(0), };
1054
1055 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), 80);
1057 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), 120);
1058
1059 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062
1063 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1065
1066 let fixed_120 = rule_120.fix(&ctx).unwrap();
1068
1069 let lines_120: Vec<&str> = fixed_120.lines().collect();
1071 assert_eq!(lines_120[0].len(), lines_120[1].len());
1072 assert_eq!(lines_120[1].len(), lines_120[2].len());
1073 }
1074
1075 #[test]
1076 fn test_md060_edge_case_exactly_at_threshold() {
1077 let config = MD060Config {
1081 enabled: true,
1082 style: "aligned".to_string(),
1083 max_width: LineLength::from_const(17),
1084 };
1085 let rule = MD060TableFormat::from_config_struct(config, 80);
1086
1087 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1089
1090 let fixed = rule.fix(&ctx).unwrap();
1091
1092 let lines: Vec<&str> = fixed.lines().collect();
1094 assert_eq!(lines[0].len(), 17);
1095 assert_eq!(lines[0].len(), lines[1].len());
1096 assert_eq!(lines[1].len(), lines[2].len());
1097
1098 let config_under = MD060Config {
1100 enabled: true,
1101 style: "aligned".to_string(),
1102 max_width: LineLength::from_const(16),
1103 };
1104 let rule_under = MD060TableFormat::from_config_struct(config_under, 80);
1105
1106 let fixed_compact = rule_under.fix(&ctx).unwrap();
1107
1108 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1110 assert!(fixed_compact.contains("| --- | --- |"));
1111 }
1112
1113 #[test]
1114 fn test_md060_auto_compact_warning_message() {
1115 let config = MD060Config {
1117 enabled: true,
1118 style: "aligned".to_string(),
1119 max_width: LineLength::from_const(50),
1120 };
1121 let rule = MD060TableFormat::from_config_struct(config, 80);
1122
1123 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1126
1127 let warnings = rule.check(&ctx).unwrap();
1128
1129 assert!(!warnings.is_empty(), "Should generate warnings");
1131
1132 let auto_compact_warnings: Vec<_> = warnings
1133 .iter()
1134 .filter(|w| w.message.contains("too wide for aligned formatting"))
1135 .collect();
1136
1137 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1138
1139 let first_warning = auto_compact_warnings[0];
1141 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1142 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1143 }
1144
1145 #[test]
1146 fn test_md060_issue_129_detect_style_from_all_rows() {
1147 let rule = MD060TableFormat::new(true, "any".to_string());
1151
1152 let content = "| a long heading | another long heading |\n\
1154 | -------------- | -------------------- |\n\
1155 | a | 1 |\n\
1156 | b b | 2 |\n\
1157 | c c c | 3 |";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1159
1160 let fixed = rule.fix(&ctx).unwrap();
1161
1162 assert!(
1164 fixed.contains("| a | 1 |"),
1165 "Should preserve aligned padding in first content row"
1166 );
1167 assert!(
1168 fixed.contains("| b b | 2 |"),
1169 "Should preserve aligned padding in second content row"
1170 );
1171 assert!(
1172 fixed.contains("| c c c | 3 |"),
1173 "Should preserve aligned padding in third content row"
1174 );
1175
1176 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1178 }
1179
1180 #[test]
1181 fn test_md060_regular_alignment_warning_message() {
1182 let config = MD060Config {
1184 enabled: true,
1185 style: "aligned".to_string(),
1186 max_width: LineLength::from_const(100), };
1188 let rule = MD060TableFormat::from_config_struct(config, 80);
1189
1190 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1193
1194 let warnings = rule.check(&ctx).unwrap();
1195
1196 assert!(!warnings.is_empty(), "Should generate warnings");
1198
1199 assert!(warnings[0].message.contains("Table columns should be aligned"));
1201 assert!(!warnings[0].message.contains("too wide"));
1202 assert!(!warnings[0].message.contains("max-width"));
1203 }
1204}