1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mdg;
3use crate::utils::range_utils::calculate_line_range;
4use crate::utils::table_utils::TableUtils;
5use unicode_width::UnicodeWidthStr;
6
7mod md060_config;
8use crate::md013_line_length::MD013Config;
9pub use md060_config::ColumnAlign;
10pub use md060_config::MD060Config;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
14enum RowType {
15 Header,
17 Delimiter,
19 Body,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24enum ColumnAlignment {
25 Left,
26 Center,
27 Right,
28}
29
30#[derive(Debug, Clone)]
31struct TableFormatResult {
32 lines: Vec<String>,
33 auto_compacted: bool,
34 aligned_width: Option<usize>,
35}
36
37#[derive(Debug, Clone, Copy)]
39struct RowFormatOptions {
40 row_type: RowType,
42 compact_delimiter: bool,
44 pad_width: usize,
46 column_align: ColumnAlign,
48 column_align_header: Option<ColumnAlign>,
50 column_align_body: Option<ColumnAlign>,
52}
53
54#[derive(Debug, Clone, Default)]
188pub struct MD060TableFormat {
189 config: MD060Config,
190 md013_config: MD013Config,
191 md013_disabled: bool,
192}
193
194impl MD060TableFormat {
195 pub fn new(enabled: bool, style: String) -> Self {
196 use crate::types::LineLength;
197 Self {
198 config: MD060Config {
199 enabled,
200 style,
201 max_width: LineLength::from_const(0),
202 column_align: ColumnAlign::Auto,
203 column_align_header: None,
204 column_align_body: None,
205 loose_last_column: false,
206 aligned_delimiter: false,
207 pad_width: None,
208 },
209 md013_config: MD013Config::default(),
210 md013_disabled: false,
211 }
212 }
213
214 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
215 Self {
216 config,
217 md013_config,
218 md013_disabled,
219 }
220 }
221
222 fn default_pad_width(style: &str) -> usize {
226 usize::from(style != "tight")
227 }
228
229 fn pad_width_for(&self, target_style: &str) -> usize {
238 self.config
239 .pad_width
240 .unwrap_or_else(|| Self::default_pad_width(target_style))
241 }
242
243 fn effective_max_width(&self) -> usize {
253 if !self.config.max_width.is_unlimited() {
255 return self.config.max_width.get();
256 }
257
258 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
263 return usize::MAX; }
265
266 self.md013_config.line_length.get()
268 }
269
270 fn contains_problematic_chars(text: &str) -> bool {
281 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
286
287 fn calculate_cell_display_width(cell_content: &str) -> usize {
288 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
289 masked.trim().width()
290 }
291
292 #[cfg(test)]
295 fn parse_table_row(line: &str) -> Vec<String> {
296 TableUtils::split_table_row(line)
297 }
298
299 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
303 TableUtils::split_table_row_with_flavor(line, flavor)
304 }
305
306 fn is_delimiter_row(row: &[String]) -> bool {
307 if row.is_empty() {
308 return false;
309 }
310 row.iter().all(|cell| {
311 let trimmed = cell.trim();
312 !trimmed.is_empty()
315 && trimmed.contains('-')
316 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
317 })
318 }
319
320 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
323 TableUtils::extract_blockquote_prefix(line)
324 }
325
326 fn mdg_table_indent(
335 table_lines: &[&str],
336 list_content_indent: Option<usize>,
337 flavor: crate::config::MarkdownFlavor,
338 ) -> Option<String> {
339 if flavor != crate::config::MarkdownFlavor::MDG
340 || !table_lines
341 .iter()
342 .filter(|line| line.contains('|'))
343 .all(|line| mdg::is_table_row(line))
344 {
345 return None;
346 }
347
348 let indent = list_content_indent.unwrap_or(0).max(mdg::MIN_TABLE_INDENT);
349 (indent <= mdg::MAX_TABLE_INDENT).then(|| " ".repeat(indent))
350 }
351
352 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
353 delimiter_row
354 .iter()
355 .map(|cell| {
356 let trimmed = cell.trim();
357 let has_left_colon = trimmed.starts_with(':');
358 let has_right_colon = trimmed.ends_with(':');
359
360 match (has_left_colon, has_right_colon) {
361 (true, true) => ColumnAlignment::Center,
362 (false, true) => ColumnAlignment::Right,
363 _ => ColumnAlignment::Left,
364 }
365 })
366 .collect()
367 }
368
369 fn calculate_column_widths(
370 table_lines: &[&str],
371 flavor: crate::config::MarkdownFlavor,
372 loose_last_column: bool,
373 ) -> Vec<usize> {
374 let mut column_widths = Vec::new();
375 let mut delimiter_cells: Option<Vec<String>> = None;
376 let mut is_header = true;
377 let mut header_last_col_width: Option<usize> = None;
378
379 for line in table_lines {
380 let cells = Self::parse_table_row_with_flavor(line, flavor);
381
382 if Self::is_delimiter_row(&cells) {
384 delimiter_cells = Some(cells);
385 is_header = false;
386 continue;
387 }
388
389 for (i, cell) in cells.iter().enumerate() {
390 let width = Self::calculate_cell_display_width(cell);
391 if i >= column_widths.len() {
392 column_widths.push(width);
393 } else {
394 column_widths[i] = column_widths[i].max(width);
395 }
396 }
397
398 if is_header && !cells.is_empty() {
400 let last_idx = cells.len() - 1;
401 header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
402 is_header = false;
403 }
404 }
405
406 if loose_last_column
408 && let Some(header_width) = header_last_col_width
409 && let Some(last) = column_widths.last_mut()
410 {
411 *last = header_width;
412 }
413
414 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
417
418 if let Some(delimiter_cells) = delimiter_cells {
421 for (i, cell) in delimiter_cells.iter().enumerate() {
422 if i < final_widths.len() {
423 let trimmed = cell.trim();
424 let has_left_colon = trimmed.starts_with(':');
425 let has_right_colon = trimmed.ends_with(':');
426 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
427
428 let min_width_for_delimiter = 3 + colon_count;
430 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
431 }
432 }
433 }
434
435 final_widths
436 }
437
438 fn format_table_row(
439 cells: &[String],
440 column_widths: &[usize],
441 column_alignments: &[ColumnAlignment],
442 options: &RowFormatOptions,
443 ) -> String {
444 let pad = " ".repeat(options.pad_width);
445 let formatted_cells: Vec<String> = cells
446 .iter()
447 .enumerate()
448 .map(|(i, cell)| {
449 let target_width = column_widths.get(i).copied().unwrap_or(0);
450
451 match options.row_type {
452 RowType::Delimiter => {
453 let trimmed = cell.trim();
454 let has_left_colon = trimmed.starts_with(':');
455 let has_right_colon = trimmed.ends_with(':');
456
457 let extra_width = if options.compact_delimiter {
462 2 * options.pad_width
463 } else {
464 0
465 };
466 let dash_count = if has_left_colon && has_right_colon {
467 (target_width + extra_width).saturating_sub(2)
468 } else if has_left_colon || has_right_colon {
469 (target_width + extra_width).saturating_sub(1)
470 } else {
471 target_width + extra_width
472 };
473
474 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
476 format!(":{dashes}:")
477 } else if has_left_colon {
478 format!(":{dashes}")
479 } else if has_right_colon {
480 format!("{dashes}:")
481 } else {
482 dashes
483 };
484
485 if options.compact_delimiter {
487 delimiter_content
488 } else {
489 format!("{pad}{delimiter_content}{pad}")
490 }
491 }
492 RowType::Header | RowType::Body => {
493 let trimmed = cell.trim();
494 let current_width = Self::calculate_cell_display_width(cell);
495 let padding = target_width.saturating_sub(current_width);
496
497 let effective_align = match options.row_type {
499 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
500 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
501 RowType::Delimiter => unreachable!(),
502 };
503
504 let alignment = match effective_align {
506 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
507 ColumnAlign::Left => ColumnAlignment::Left,
508 ColumnAlign::Center => ColumnAlignment::Center,
509 ColumnAlign::Right => ColumnAlignment::Right,
510 };
511
512 match alignment {
513 ColumnAlignment::Left => {
514 format!("{pad}{trimmed}{}{pad}", " ".repeat(padding))
516 }
517 ColumnAlignment::Center => {
518 let left_padding = padding / 2;
520 let right_padding = padding - left_padding;
521 format!(
522 "{pad}{}{trimmed}{}{pad}",
523 " ".repeat(left_padding),
524 " ".repeat(right_padding)
525 )
526 }
527 ColumnAlignment::Right => {
528 format!("{pad}{}{trimmed}{pad}", " ".repeat(padding))
530 }
531 }
532 }
533 }
534 })
535 .collect();
536
537 format!("|{}|", formatted_cells.join("|"))
538 }
539
540 fn format_table_unaligned(cells: &[String], pad_width: usize) -> String {
545 if pad_width == 0 {
546 return Self::format_table_tight(cells);
547 }
548
549 let pad = " ".repeat(pad_width);
550 let formatted_cells: Vec<String> = cells
551 .iter()
552 .map(|cell| match cell.trim() {
553 "" if pad_width == 1 => " ".to_string(),
558 trimmed => format!("{pad}{trimmed}{pad}"),
559 })
560 .collect();
561 format!("|{}|", formatted_cells.join("|"))
562 }
563
564 fn format_table_tight(cells: &[String]) -> String {
565 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
566 format!("|{}|", formatted_cells.join("|"))
567 }
568
569 fn format_delimiter_aligned_to_header(delim_cells: &[String], header_widths: &[usize], pad_width: usize) -> String {
578 let pad = " ".repeat(pad_width);
579 let formatted_cells: Vec<String> = delim_cells
580 .iter()
581 .enumerate()
582 .map(|(i, cell)| {
583 let target_width = header_widths.get(i).copied().unwrap_or(0);
584 let trimmed = cell.trim();
585 let has_left_colon = trimmed.starts_with(':');
586 let has_right_colon = trimmed.ends_with(':');
587 let colon_count = usize::from(has_left_colon) + usize::from(has_right_colon);
588
589 let dash_count = target_width.saturating_sub(colon_count).max(1);
591 let dashes = "-".repeat(dash_count);
592 let delimiter_content = match (has_left_colon, has_right_colon) {
593 (true, true) => format!(":{dashes}:"),
594 (true, false) => format!(":{dashes}"),
595 (false, true) => format!("{dashes}:"),
596 (false, false) => dashes,
597 };
598 format!("{pad}{delimiter_content}{pad}")
599 })
600 .collect();
601
602 format!("|{}|", formatted_cells.join("|"))
603 }
604
605 fn header_cell_widths(header_cells: &[String]) -> Vec<usize> {
608 header_cells
609 .iter()
610 .map(|c| Self::calculate_cell_display_width(c))
611 .collect()
612 }
613
614 fn is_table_already_aligned(
626 table_lines: &[&str],
627 flavor: crate::config::MarkdownFlavor,
628 compact_delimiter: bool,
629 pad_width: Option<usize>,
630 ) -> bool {
631 if table_lines.len() < 2 {
632 return false;
633 }
634
635 let first_width = UnicodeWidthStr::width(table_lines[0]);
639 if !table_lines
640 .iter()
641 .all(|line| UnicodeWidthStr::width(*line) == first_width)
642 {
643 return false;
644 }
645
646 let parsed: Vec<Vec<String>> = table_lines
648 .iter()
649 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
650 .collect();
651
652 if parsed.is_empty() {
653 return false;
654 }
655
656 let num_columns = parsed[0].len();
657 if !parsed.iter().all(|row| row.len() == num_columns) {
658 return false;
659 }
660
661 if let Some(delimiter_row) = parsed.get(1) {
664 if !Self::is_delimiter_row(delimiter_row) {
665 return false;
666 }
667 for cell in delimiter_row {
669 let trimmed = cell.trim();
670 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
671 if dash_count < 1 {
672 return false;
673 }
674 }
675
676 let delimiter_padded_as_targeted = if let Some(pad_width) = pad_width {
679 let expected_padding = if compact_delimiter { 0 } else { pad_width };
680 delimiter_row.iter().all(|cell| {
681 if cell.trim_matches(' ') != cell.trim() {
682 return false;
683 }
684 let leading = cell.len() - cell.trim_start_matches(' ').len();
685 let trailing = cell.len() - cell.trim_end_matches(' ').len();
686 leading == expected_padding && trailing == expected_padding
687 })
688 } else {
689 let delimiter_has_spaces = delimiter_row
690 .iter()
691 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
692 delimiter_has_spaces != compact_delimiter
693 };
694 if !delimiter_padded_as_targeted {
695 return false;
696 }
697 }
698
699 for col_idx in 0..num_columns {
703 let mut widths = Vec::new();
704 for (row_idx, row) in parsed.iter().enumerate() {
705 if row_idx == 1 {
707 continue;
708 }
709 if let Some(cell) = row.get(col_idx) {
710 widths.push(cell.width());
711 }
712 }
713 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
715 return false;
716 }
717 }
718
719 if let Some(delimiter_row) = parsed.get(1) {
724 let alignments = Self::parse_column_alignments(delimiter_row);
725 for (col_idx, alignment) in alignments.iter().enumerate() {
726 if *alignment == ColumnAlignment::Left && pad_width.is_none() {
727 continue;
728 }
729 for (row_idx, row) in parsed.iter().enumerate() {
730 if row_idx == 1 {
732 continue;
733 }
734 if let Some(cell) = row.get(col_idx) {
735 if pad_width.is_some() && cell.trim_matches(' ') != cell.trim() {
736 return false;
737 }
738 if cell.trim().is_empty() {
739 continue;
740 }
741 let left_pad = cell.len() - cell.trim_start().len();
743 let right_pad = cell.len() - cell.trim_end().len();
744
745 if let Some(pad_width) = pad_width {
746 let left_pad = cell.len() - cell.trim_start_matches(' ').len();
747 let right_pad = cell.len() - cell.trim_end_matches(' ').len();
748 if left_pad < pad_width || right_pad < pad_width {
751 return false;
752 }
753 if (*alignment == ColumnAlignment::Left && left_pad != pad_width)
754 || (*alignment == ColumnAlignment::Right && right_pad != pad_width)
755 {
756 return false;
757 }
758 }
759
760 match alignment {
761 ColumnAlignment::Center => {
762 if left_pad.abs_diff(right_pad) > 1 {
764 return false;
765 }
766 }
767 ColumnAlignment::Right => {
768 if left_pad < right_pad {
770 return false;
771 }
772 }
773 ColumnAlignment::Left => {}
774 }
775 }
776 }
777 }
778 }
779
780 true
781 }
782
783 fn detect_table_style(
784 table_lines: &[&str],
785 flavor: crate::config::MarkdownFlavor,
786 pad_width: Option<usize>,
787 ) -> Option<String> {
788 if table_lines.is_empty() {
789 return None;
790 }
791
792 let mut is_tight = true;
795 let mut is_compact = true;
796 let configured_pad = pad_width.map(|width| " ".repeat(width));
797
798 for line in table_lines {
799 let cells = Self::parse_table_row_with_flavor(line, flavor);
800
801 if cells.is_empty() {
802 continue;
803 }
804
805 if Self::is_delimiter_row(&cells) {
807 continue;
808 }
809
810 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
812
813 let row_has_single_space = cells.iter().all(|cell| match cell.trim() {
817 "" => cell == " ",
818 trimmed => cell == &format!(" {trimmed} "),
819 });
820
821 let row_has_configured_padding = configured_pad.as_ref().is_some_and(|pad| {
825 cells.iter().all(|cell| match cell.trim() {
826 "" if pad.len() == 1 => cell == " ",
827 trimmed => cell == &format!("{pad}{trimmed}{pad}"),
828 })
829 });
830
831 if !row_has_no_padding {
833 is_tight = false;
834 }
835
836 if !row_has_single_space && !row_has_configured_padding {
838 is_compact = false;
839 }
840
841 if !is_tight && !is_compact {
843 return Some("aligned".to_string());
844 }
845 }
846
847 if is_tight {
849 Some("tight".to_string())
850 } else if is_compact {
851 Some("compact".to_string())
852 } else {
853 Some("aligned".to_string())
854 }
855 }
856
857 fn fix_table_block(
858 &self,
859 lines: &[&str],
860 table_block: &crate::utils::table_utils::TableBlock,
861 flavor: crate::config::MarkdownFlavor,
862 ) -> TableFormatResult {
863 let mut result = Vec::new();
864 let mut auto_compacted = false;
865 let mut aligned_width = None;
866
867 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
868 .chain(std::iter::once(lines[table_block.delimiter_line]))
869 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
870 .collect();
871
872 if flavor != crate::config::MarkdownFlavor::MDG
875 && TableUtils::header_is_indented_as_code(table_lines[0], table_lines[1], table_block)
876 {
877 return TableFormatResult {
878 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
879 auto_compacted: false,
880 aligned_width: None,
881 };
882 }
883
884 let (mut blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
887
888 let list_context = &table_block.list_context;
890 if list_context.is_some() {
891 let markers = blockquote_prefix.trim_end_matches([' ', '\t']);
894 blockquote_prefix = if blockquote_prefix.len() > markers.len() {
895 &blockquote_prefix[..=markers.len()]
896 } else {
897 markers
898 };
899 }
900 let header_prefix = if list_context.is_none() {
904 let after_quote = Self::extract_blockquote_prefix(table_lines[0]).1;
905 (!TableUtils::extract_list_prefix(after_quote).0.is_empty())
906 .then(|| TableUtils::row_prefix(table_lines[0], table_lines[0], table_block, 0))
907 } else {
908 None
909 };
910 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
911 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
912 } else {
913 ("", String::new())
914 };
915 let mdg_indent = Self::mdg_table_indent(
916 &table_lines,
917 list_context.as_ref().map(|ctx| ctx.content_indent),
918 flavor,
919 );
920
921 let container_indented = mdg_indent.is_none() && TableUtils::is_container_indented(table_lines[0], table_block);
922
923 let stripped_lines: Vec<&str> = table_lines
925 .iter()
926 .enumerate()
927 .map(|(i, line)| {
928 let after_blockquote = Self::extract_blockquote_prefix(line).1;
929 if mdg_indent.is_some() {
930 after_blockquote.trim_start_matches([' ', '\t'])
931 } else if list_context.is_some() {
932 if i == 0 {
933 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
935 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
936 })
937 } else {
938 after_blockquote
940 .strip_prefix(&continuation_indent)
941 .unwrap_or(after_blockquote.trim_start())
942 }
943 } else if i == 0 && header_prefix.is_some() {
944 crate::utils::table_utils::TableUtils::extract_table_row_content(line, table_block, 0)
945 } else {
946 after_blockquote
947 }
948 })
949 .collect();
950
951 let formatted_indices: Vec<usize> = stripped_lines
955 .iter()
956 .enumerate()
957 .filter_map(|(i, line)| (i < 2 || line.contains('|')).then_some(i))
958 .collect();
959 if formatted_indices
961 .iter()
962 .any(|&i| Self::contains_problematic_chars(table_lines[i]))
963 {
964 return TableFormatResult {
965 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
966 auto_compacted: false,
967 aligned_width: None,
968 };
969 }
970 let stripped_lines: Vec<&str> = formatted_indices.iter().map(|&i| stripped_lines[i]).collect();
971
972 let style = self.config.style.as_str();
973
974 match style {
975 "any" => {
976 let detected_style = Self::detect_table_style(&stripped_lines, flavor, self.config.pad_width);
977 if detected_style.is_none() {
978 return TableFormatResult {
979 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
980 auto_compacted: false,
981 aligned_width: None,
982 };
983 }
984
985 let target_style = detected_style.unwrap();
986 let pad_width = self.pad_width_for(&target_style);
987
988 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
990 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
991
992 for (row_idx, line) in stripped_lines.iter().enumerate() {
993 let cells = Self::parse_table_row_with_flavor(line, flavor);
994 match target_style.as_str() {
995 "tight" | "compact" => result.push(Self::format_table_unaligned(&cells, pad_width)),
996 _ => {
997 let column_widths =
998 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
999 let row_type = match row_idx {
1000 0 => RowType::Header,
1001 1 => RowType::Delimiter,
1002 _ => RowType::Body,
1003 };
1004 let options = RowFormatOptions {
1005 row_type,
1006 compact_delimiter: false,
1007 pad_width,
1008 column_align: self.config.column_align,
1009 column_align_header: self.config.column_align_header,
1010 column_align_body: self.config.column_align_body,
1011 };
1012 result.push(Self::format_table_row(
1013 &cells,
1014 &column_widths,
1015 &column_alignments,
1016 &options,
1017 ));
1018 }
1019 }
1020 }
1021 }
1022 "compact" | "tight" => {
1023 let pad_width = self.pad_width_for(style);
1024 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
1025 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
1026 Some(Self::header_cell_widths(&header_cells))
1027 } else {
1028 None
1029 };
1030
1031 for (row_idx, line) in stripped_lines.iter().enumerate() {
1032 let cells = Self::parse_table_row_with_flavor(line, flavor);
1033 if row_idx == 1
1034 && let Some(widths) = &header_widths
1035 {
1036 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, pad_width));
1037 continue;
1038 }
1039 result.push(Self::format_table_unaligned(&cells, pad_width));
1040 }
1041 }
1042 "aligned" | "aligned-no-space" => {
1043 let compact_delimiter = style == "aligned-no-space";
1044 let pad_width = self.pad_width_for(style);
1045
1046 let needs_reformat = self.config.column_align != ColumnAlign::Auto
1049 || self.config.column_align_header.is_some()
1050 || self.config.column_align_body.is_some()
1051 || self.config.loose_last_column;
1052
1053 if !needs_reformat
1054 && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter, self.config.pad_width)
1055 {
1056 return TableFormatResult {
1057 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
1058 auto_compacted: false,
1059 aligned_width: None,
1060 };
1061 }
1062
1063 let column_widths =
1064 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
1065
1066 let num_columns = column_widths.len();
1069 let calc_aligned_width = 1 + num_columns * (2 * pad_width + 1) + column_widths.iter().sum::<usize>();
1070 aligned_width = Some(calc_aligned_width);
1071
1072 if calc_aligned_width > self.effective_max_width() {
1077 auto_compacted = true;
1078 let compact_pad_width = self.pad_width_for("compact");
1081 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
1082 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
1083 Some(Self::header_cell_widths(&header_cells))
1084 } else {
1085 None
1086 };
1087 for (row_idx, line) in stripped_lines.iter().enumerate() {
1088 let cells = Self::parse_table_row_with_flavor(line, flavor);
1089 if row_idx == 1
1090 && let Some(widths) = &header_widths
1091 {
1092 result.push(Self::format_delimiter_aligned_to_header(
1093 &cells,
1094 widths,
1095 compact_pad_width,
1096 ));
1097 continue;
1098 }
1099 result.push(Self::format_table_unaligned(&cells, compact_pad_width));
1100 }
1101 } else {
1102 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
1104 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
1105
1106 for (row_idx, line) in stripped_lines.iter().enumerate() {
1107 let cells = Self::parse_table_row_with_flavor(line, flavor);
1108 let row_type = match row_idx {
1109 0 => RowType::Header,
1110 1 => RowType::Delimiter,
1111 _ => RowType::Body,
1112 };
1113 let options = RowFormatOptions {
1114 row_type,
1115 compact_delimiter,
1116 pad_width,
1117 column_align: self.config.column_align,
1118 column_align_header: self.config.column_align_header,
1119 column_align_body: self.config.column_align_body,
1120 };
1121 result.push(Self::format_table_row(
1122 &cells,
1123 &column_widths,
1124 &column_alignments,
1125 &options,
1126 ));
1127 }
1128 }
1129 }
1130 _ => {
1131 return TableFormatResult {
1132 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
1133 auto_compacted: false,
1134 aligned_width: None,
1135 };
1136 }
1137 }
1138
1139 for (row_idx, (original, formatted)) in stripped_lines.iter().zip(&result).enumerate() {
1144 if row_idx == 1 {
1145 continue; }
1147 let original_cells = Self::parse_table_row_with_flavor(original, flavor);
1148 let formatted_cells = Self::parse_table_row_with_flavor(formatted, flavor);
1149 if !original_cells
1150 .iter()
1151 .map(|cell| cell.trim())
1152 .eq(formatted_cells.iter().map(|cell| cell.trim()))
1153 {
1154 return TableFormatResult {
1155 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
1156 auto_compacted: false,
1157 aligned_width: None,
1158 };
1159 }
1160 }
1161
1162 let prefixed_result: Vec<String> = result
1164 .into_iter()
1165 .enumerate()
1166 .map(|(i, line)| {
1167 if let Some(mdg_indent) = &mdg_indent {
1168 format!("{blockquote_prefix}{mdg_indent}{line}")
1169 } else if list_context.is_some() {
1170 if i == 0 {
1171 format!("{blockquote_prefix}{list_prefix}{line}")
1173 } else {
1174 format!("{blockquote_prefix}{continuation_indent}{line}")
1176 }
1177 } else if let Some(header_prefix) = header_prefix.filter(|_| i == 0) {
1178 format!("{header_prefix}{line}")
1179 } else if header_prefix.is_some() {
1180 let own = Self::extract_blockquote_prefix(table_lines[i]).0;
1181 format!("{own}{line}")
1182 } else if container_indented && blockquote_prefix.is_empty() {
1183 let own = table_lines[formatted_indices[i]];
1185 let indent = &own[..own.len() - own.trim_start_matches([' ', '\t']).len()];
1186 format!("{indent}{line}")
1187 } else {
1188 format!("{blockquote_prefix}{line}")
1189 }
1190 })
1191 .collect();
1192
1193 let mut output: Vec<String> = table_lines.iter().map(std::string::ToString::to_string).collect();
1194 for (index, formatted) in formatted_indices.into_iter().zip(prefixed_result) {
1195 output[index] = formatted;
1196 }
1197
1198 TableFormatResult {
1199 lines: output,
1200 auto_compacted,
1201 aligned_width,
1202 }
1203 }
1204}
1205
1206impl Rule for MD060TableFormat {
1207 fn name(&self) -> &'static str {
1208 "MD060"
1209 }
1210
1211 fn description(&self) -> &'static str {
1212 "Table columns should be consistently aligned"
1213 }
1214
1215 fn category(&self) -> RuleCategory {
1216 RuleCategory::Table
1217 }
1218
1219 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1220 !ctx.likely_has_tables()
1221 }
1222
1223 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1224 let mut warnings = Vec::new();
1225
1226 let lines = ctx.raw_lines();
1227 let table_blocks = &ctx.table_blocks;
1228
1229 for table_block in table_blocks.iter().filter(|block| block.parser_confirmed) {
1230 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1231
1232 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1233 .chain(std::iter::once(table_block.delimiter_line))
1234 .chain(table_block.content_lines.iter().copied())
1235 .collect();
1236
1237 let table_start_line = table_block.start_line + 1; let table_end_line = table_block.end_line + 1; let mut fixed_table_lines: Vec<String> = Vec::with_capacity(table_line_indices.len());
1244 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1245 let fixed_line = &format_result.lines[i];
1246 if line_idx < lines.len() - 1 {
1248 fixed_table_lines.push(format!("{fixed_line}\n"));
1249 } else {
1250 fixed_table_lines.push(fixed_line.clone());
1251 }
1252 }
1253 let table_replacement = fixed_table_lines.concat();
1254 let table_range = ctx.line_span_byte_range(table_start_line, table_end_line);
1255
1256 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1257 let original = lines[line_idx];
1258 let fixed = &format_result.lines[i];
1259
1260 if original != fixed {
1261 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1262
1263 let message = if format_result.auto_compacted {
1264 if let Some(width) = format_result.aligned_width {
1265 format!(
1266 "Table too wide for aligned formatting ({} chars > max-width: {})",
1267 width,
1268 self.effective_max_width()
1269 )
1270 } else {
1271 "Table too wide for aligned formatting".to_string()
1272 }
1273 } else {
1274 "Table columns should be aligned".to_string()
1275 };
1276
1277 warnings.push(LintWarning {
1280 rule_name: Some(self.name().to_string()),
1281 severity: Severity::Warning,
1282 message,
1283 line: start_line,
1284 column: start_col,
1285 end_line,
1286 end_column: end_col,
1287 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1288 });
1289 }
1290 }
1291 }
1292
1293 Ok(warnings)
1294 }
1295
1296 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1297 let content = ctx.content;
1298 let lines = ctx.raw_lines();
1299 let table_blocks = &ctx.table_blocks;
1300
1301 if !table_blocks.iter().any(|block| block.parser_confirmed) {
1304 return Ok(content.to_string());
1305 }
1306
1307 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1308
1309 for table_block in table_blocks.iter().filter(|block| block.parser_confirmed) {
1310 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1311
1312 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1313 .chain(std::iter::once(table_block.delimiter_line))
1314 .chain(table_block.content_lines.iter().copied())
1315 .collect();
1316
1317 let any_disabled = table_line_indices
1320 .iter()
1321 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1322
1323 if any_disabled {
1324 continue;
1325 }
1326
1327 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1328 result_lines[line_idx].clone_from(&format_result.lines[i]);
1329 }
1330 }
1331
1332 let mut fixed = result_lines.join("\n");
1333 let original_trailing_newlines = content.len() - content.trim_end_matches('\n').len();
1338 fixed.truncate(fixed.trim_end_matches('\n').len());
1339 fixed.push_str(&"\n".repeat(original_trailing_newlines));
1340 Ok(fixed)
1341 }
1342
1343 fn as_any(&self) -> &dyn std::any::Any {
1344 self
1345 }
1346
1347 crate::impl_rule_config_sections!(MD060Config);
1348
1349 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1350 where
1351 Self: Sized,
1352 {
1353 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1354 let md013_config = MD013Config::from_document_config(config);
1355
1356 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1358
1359 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1360 }
1361}
1362
1363#[cfg(test)]
1364mod tests {
1365 use super::*;
1366 use crate::lint_context::LintContext;
1367 use crate::types::LineLength;
1368
1369 fn md013_with_line_length(line_length: usize) -> MD013Config {
1371 MD013Config {
1372 line_length: LineLength::from_const(line_length),
1373 tables: true, ..Default::default()
1375 }
1376 }
1377
1378 #[test]
1379 fn test_md060_align_simple_ascii_table() {
1380 let rule = MD060TableFormat::new(true, "aligned".to_string());
1381
1382 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384
1385 let fixed = rule.fix(&ctx).unwrap();
1386 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1387 assert_eq!(fixed, expected);
1388
1389 let lines: Vec<&str> = fixed.lines().collect();
1391 assert_eq!(lines[0].len(), lines[1].len());
1392 assert_eq!(lines[1].len(), lines[2].len());
1393 }
1394
1395 #[test]
1396 fn test_md060_cjk_characters_aligned_correctly() {
1397 let rule = MD060TableFormat::new(true, "aligned".to_string());
1398
1399 let content = "| Name | Age |\n|---|---|\n| 中文 | 30 |";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401
1402 let fixed = rule.fix(&ctx).unwrap();
1403
1404 let lines: Vec<&str> = fixed.lines().collect();
1405 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1406 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1407
1408 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1409 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1410
1411 assert_eq!(width1, width3);
1412 }
1413
1414 #[test]
1415 fn test_md060_basic_emoji() {
1416 let rule = MD060TableFormat::new(true, "aligned".to_string());
1417
1418 let content = "| Status | Name |\n|---|---|\n| ✅ | Test |";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420
1421 let fixed = rule.fix(&ctx).unwrap();
1422 assert!(fixed.contains("Status"));
1423 }
1424
1425 #[test]
1426 fn test_md060_zwj_emoji_skipped() {
1427 let rule = MD060TableFormat::new(true, "aligned".to_string());
1428
1429 let content = "| Emoji | Name |\n|---|---|\n| 👨👩👧👦 | Family |";
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431
1432 let fixed = rule.fix(&ctx).unwrap();
1433 assert_eq!(fixed, content);
1434 }
1435
1436 #[test]
1437 fn test_md060_inline_code_with_escaped_pipes() {
1438 let rule = MD060TableFormat::new(true, "aligned".to_string());
1441
1442 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445
1446 let fixed = rule.fix(&ctx).unwrap();
1447 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1448 }
1449
1450 #[test]
1451 fn test_md060_compact_style() {
1452 let rule = MD060TableFormat::new(true, "compact".to_string());
1453
1454 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456
1457 let fixed = rule.fix(&ctx).unwrap();
1458 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1459 assert_eq!(fixed, expected);
1460 }
1461
1462 #[test]
1463 fn test_md060_tight_style() {
1464 let rule = MD060TableFormat::new(true, "tight".to_string());
1465
1466 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468
1469 let fixed = rule.fix(&ctx).unwrap();
1470 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1471 assert_eq!(fixed, expected);
1472 }
1473
1474 #[test]
1475 fn test_md060_aligned_no_space_style() {
1476 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1478
1479 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481
1482 let fixed = rule.fix(&ctx).unwrap();
1483
1484 let lines: Vec<&str> = fixed.lines().collect();
1486 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1487 assert_eq!(
1488 lines[1], "|-------|-----|",
1489 "Delimiter should have NO spaces around dashes"
1490 );
1491 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1492
1493 assert_eq!(lines[0].len(), lines[1].len());
1495 assert_eq!(lines[1].len(), lines[2].len());
1496 }
1497
1498 #[test]
1499 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1500 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1502
1503 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505
1506 let fixed = rule.fix(&ctx).unwrap();
1507 let lines: Vec<&str> = fixed.lines().collect();
1508
1509 assert!(
1511 fixed.contains("|:"),
1512 "Should have left alignment indicator adjacent to pipe"
1513 );
1514 assert!(
1515 fixed.contains(":|"),
1516 "Should have right alignment indicator adjacent to pipe"
1517 );
1518 assert!(
1520 lines[1].contains(":---") && lines[1].contains("---:"),
1521 "Should have center alignment colons"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_md060_aligned_no_space_three_column_table() {
1527 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1529
1530 let content = "| Header 1 | Header 2 | Header 3 |\n|---|---|---|\n| Row 1, Col 1 | Row 1, Col 2 | Row 1, Col 3 |\n| Row 2, Col 1 | Row 2, Col 2 | Row 2, Col 3 |";
1531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532
1533 let fixed = rule.fix(&ctx).unwrap();
1534 let lines: Vec<&str> = fixed.lines().collect();
1535
1536 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1538 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1539 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1540 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1541 }
1542
1543 #[test]
1544 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1545 let config = MD060Config {
1547 enabled: true,
1548 style: "aligned-no-space".to_string(),
1549 max_width: LineLength::from_const(50),
1550 column_align: ColumnAlign::Auto,
1551 column_align_header: None,
1552 column_align_body: None,
1553 loose_last_column: false,
1554 aligned_delimiter: false,
1555 pad_width: None,
1556 };
1557 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1558
1559 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1562
1563 let fixed = rule.fix(&ctx).unwrap();
1564
1565 assert!(
1567 fixed.contains("| --- |"),
1568 "Should be compact format when exceeding max-width"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_md060_aligned_no_space_cjk_characters() {
1574 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1576
1577 let content = "| Name | City |\n|---|---|\n| 中文 | 東京 |";
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579
1580 let fixed = rule.fix(&ctx).unwrap();
1581 let lines: Vec<&str> = fixed.lines().collect();
1582
1583 use unicode_width::UnicodeWidthStr;
1586 assert_eq!(
1587 lines[0].width(),
1588 lines[1].width(),
1589 "Header and delimiter should have same display width"
1590 );
1591 assert_eq!(
1592 lines[1].width(),
1593 lines[2].width(),
1594 "Delimiter and content should have same display width"
1595 );
1596
1597 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1599 }
1600
1601 #[test]
1602 fn test_md060_aligned_no_space_minimum_width() {
1603 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1605
1606 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608
1609 let fixed = rule.fix(&ctx).unwrap();
1610 let lines: Vec<&str> = fixed.lines().collect();
1611
1612 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1614 assert_eq!(lines[0].len(), lines[1].len());
1616 assert_eq!(lines[1].len(), lines[2].len());
1617 }
1618
1619 #[test]
1620 fn test_md060_any_style_consistency() {
1621 let rule = MD060TableFormat::new(true, "any".to_string());
1622
1623 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626
1627 let fixed = rule.fix(&ctx).unwrap();
1628 assert_eq!(fixed, content);
1629
1630 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1632 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1633
1634 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1635 assert_eq!(fixed_aligned, content_aligned);
1636 }
1637
1638 #[test]
1639 fn test_md060_empty_cells() {
1640 let rule = MD060TableFormat::new(true, "aligned".to_string());
1641
1642 let content = "| A | B |\n|---|---|\n| | X |";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644
1645 let fixed = rule.fix(&ctx).unwrap();
1646 assert!(fixed.contains('|'));
1647 }
1648
1649 #[test]
1650 fn test_md060_mixed_content() {
1651 let rule = MD060TableFormat::new(true, "aligned".to_string());
1652
1653 let content = "| Name | Age | City |\n|---|---|---|\n| 中文 | 30 | NYC |";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655
1656 let fixed = rule.fix(&ctx).unwrap();
1657 assert!(fixed.contains("中文"));
1658 assert!(fixed.contains("NYC"));
1659 }
1660
1661 #[test]
1662 fn test_md060_preserve_alignment_indicators() {
1663 let rule = MD060TableFormat::new(true, "aligned".to_string());
1664
1665 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667
1668 let fixed = rule.fix(&ctx).unwrap();
1669
1670 assert!(fixed.contains(":---"), "Should contain left alignment");
1671 assert!(fixed.contains(":----:"), "Should contain center alignment");
1672 assert!(fixed.contains("----:"), "Should contain right alignment");
1673 }
1674
1675 #[test]
1676 fn test_md060_minimum_column_width() {
1677 let rule = MD060TableFormat::new(true, "aligned".to_string());
1678
1679 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683
1684 let fixed = rule.fix(&ctx).unwrap();
1685
1686 let lines: Vec<&str> = fixed.lines().collect();
1687 assert_eq!(lines[0].len(), lines[1].len());
1688 assert_eq!(lines[1].len(), lines[2].len());
1689
1690 assert!(fixed.contains("ID "), "Short content should be padded");
1692 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1693 }
1694
1695 #[test]
1696 fn test_md060_auto_compact_exceeds_default_threshold() {
1697 let config = MD060Config {
1699 enabled: true,
1700 style: "aligned".to_string(),
1701 max_width: LineLength::from_const(0),
1702 column_align: ColumnAlign::Auto,
1703 column_align_header: None,
1704 column_align_body: None,
1705 loose_last_column: false,
1706 aligned_delimiter: false,
1707 pad_width: None,
1708 };
1709 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1710
1711 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1716
1717 let fixed = rule.fix(&ctx).unwrap();
1718
1719 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1721 assert!(fixed.contains("| --- | --- | --- |"));
1722 assert!(fixed.contains("| Short | Data | Here |"));
1723
1724 let lines: Vec<&str> = fixed.lines().collect();
1726 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1728 }
1729
1730 #[test]
1731 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1732 let config = MD060Config {
1734 enabled: true,
1735 style: "aligned".to_string(),
1736 max_width: LineLength::from_const(50),
1737 column_align: ColumnAlign::Auto,
1738 column_align_header: None,
1739 column_align_body: None,
1740 loose_last_column: false,
1741 aligned_delimiter: false,
1742 pad_width: None,
1743 };
1744 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false); let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1751
1752 let fixed = rule.fix(&ctx).unwrap();
1753
1754 assert!(
1756 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1757 );
1758 assert!(fixed.contains("| --- | --- | --- |"));
1759 assert!(fixed.contains("| Data | Data | Data |"));
1760
1761 let lines: Vec<&str> = fixed.lines().collect();
1763 assert!(lines[0].len() != lines[2].len());
1764 }
1765
1766 #[test]
1767 fn test_md060_stays_aligned_under_threshold() {
1768 let config = MD060Config {
1770 enabled: true,
1771 style: "aligned".to_string(),
1772 max_width: LineLength::from_const(100),
1773 column_align: ColumnAlign::Auto,
1774 column_align_header: None,
1775 column_align_body: None,
1776 loose_last_column: false,
1777 aligned_delimiter: false,
1778 pad_width: None,
1779 };
1780 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1781
1782 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785
1786 let fixed = rule.fix(&ctx).unwrap();
1787
1788 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1790 assert_eq!(fixed, expected);
1791
1792 let lines: Vec<&str> = fixed.lines().collect();
1793 assert_eq!(lines[0].len(), lines[1].len());
1794 assert_eq!(lines[1].len(), lines[2].len());
1795 }
1796
1797 #[test]
1798 fn test_md060_width_calculation_formula() {
1799 let config = MD060Config {
1801 enabled: true,
1802 style: "aligned".to_string(),
1803 max_width: LineLength::from_const(0),
1804 column_align: ColumnAlign::Auto,
1805 column_align_header: None,
1806 column_align_body: None,
1807 loose_last_column: false,
1808 aligned_delimiter: false,
1809 pad_width: None,
1810 };
1811 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1812
1813 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818
1819 let fixed = rule.fix(&ctx).unwrap();
1820
1821 let lines: Vec<&str> = fixed.lines().collect();
1823 assert_eq!(lines[0].len(), lines[1].len());
1824 assert_eq!(lines[1].len(), lines[2].len());
1825 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1829 enabled: true,
1830 style: "aligned".to_string(),
1831 max_width: LineLength::from_const(24),
1832 column_align: ColumnAlign::Auto,
1833 column_align_header: None,
1834 column_align_body: None,
1835 loose_last_column: false,
1836 aligned_delimiter: false,
1837 pad_width: None,
1838 };
1839 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1840
1841 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1842
1843 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1845 assert!(fixed_compact.contains("| --- | --- | --- |"));
1846 }
1847
1848 #[test]
1849 fn test_md060_very_wide_table_auto_compacts() {
1850 let config = MD060Config {
1851 enabled: true,
1852 style: "aligned".to_string(),
1853 max_width: LineLength::from_const(0),
1854 column_align: ColumnAlign::Auto,
1855 column_align_header: None,
1856 column_align_body: None,
1857 loose_last_column: false,
1858 aligned_delimiter: false,
1859 pad_width: None,
1860 };
1861 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1862
1863 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 |";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868
1869 let fixed = rule.fix(&ctx).unwrap();
1870
1871 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1873 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1874 }
1875
1876 #[test]
1877 fn test_md060_inherit_from_md013_line_length() {
1878 let config = MD060Config {
1880 enabled: true,
1881 style: "aligned".to_string(),
1882 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1884 column_align_header: None,
1885 column_align_body: None,
1886 loose_last_column: false,
1887 aligned_delimiter: false,
1888 pad_width: None,
1889 };
1890
1891 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1893 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1894
1895 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1898
1899 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1901
1902 let fixed_120 = rule_120.fix(&ctx).unwrap();
1904
1905 let lines_120: Vec<&str> = fixed_120.lines().collect();
1907 assert_eq!(lines_120[0].len(), lines_120[1].len());
1908 assert_eq!(lines_120[1].len(), lines_120[2].len());
1909 }
1910
1911 #[test]
1912 fn test_md060_edge_case_exactly_at_threshold() {
1913 let config = MD060Config {
1917 enabled: true,
1918 style: "aligned".to_string(),
1919 max_width: LineLength::from_const(17),
1920 column_align: ColumnAlign::Auto,
1921 column_align_header: None,
1922 column_align_body: None,
1923 loose_last_column: false,
1924 aligned_delimiter: false,
1925 pad_width: None,
1926 };
1927 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1928
1929 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1931
1932 let fixed = rule.fix(&ctx).unwrap();
1933
1934 let lines: Vec<&str> = fixed.lines().collect();
1936 assert_eq!(lines[0].len(), 17);
1937 assert_eq!(lines[0].len(), lines[1].len());
1938 assert_eq!(lines[1].len(), lines[2].len());
1939
1940 let config_under = MD060Config {
1942 enabled: true,
1943 style: "aligned".to_string(),
1944 max_width: LineLength::from_const(16),
1945 column_align: ColumnAlign::Auto,
1946 column_align_header: None,
1947 column_align_body: None,
1948 loose_last_column: false,
1949 aligned_delimiter: false,
1950 pad_width: None,
1951 };
1952 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1953
1954 let fixed_compact = rule_under.fix(&ctx).unwrap();
1955
1956 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1958 assert!(fixed_compact.contains("| --- | --- |"));
1959 }
1960
1961 #[test]
1962 fn test_md060_auto_compact_warning_message() {
1963 let config = MD060Config {
1965 enabled: true,
1966 style: "aligned".to_string(),
1967 max_width: LineLength::from_const(50),
1968 column_align: ColumnAlign::Auto,
1969 column_align_header: None,
1970 column_align_body: None,
1971 loose_last_column: false,
1972 aligned_delimiter: false,
1973 pad_width: None,
1974 };
1975 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1976
1977 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1980
1981 let warnings = rule.check(&ctx).unwrap();
1982
1983 assert!(!warnings.is_empty(), "Should generate warnings");
1985
1986 let auto_compact_warnings: Vec<_> = warnings
1987 .iter()
1988 .filter(|w| w.message.contains("too wide for aligned formatting"))
1989 .collect();
1990
1991 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1992
1993 let first_warning = auto_compact_warnings[0];
1995 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1996 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1997 }
1998
1999 #[test]
2000 fn test_md060_issue_129_detect_style_from_all_rows() {
2001 let rule = MD060TableFormat::new(true, "any".to_string());
2005
2006 let content = "| a long heading | another long heading |\n\
2008 | -------------- | -------------------- |\n\
2009 | a | 1 |\n\
2010 | b b | 2 |\n\
2011 | c c c | 3 |";
2012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2013
2014 let fixed = rule.fix(&ctx).unwrap();
2015
2016 assert!(
2018 fixed.contains("| a | 1 |"),
2019 "Should preserve aligned padding in first content row"
2020 );
2021 assert!(
2022 fixed.contains("| b b | 2 |"),
2023 "Should preserve aligned padding in second content row"
2024 );
2025 assert!(
2026 fixed.contains("| c c c | 3 |"),
2027 "Should preserve aligned padding in third content row"
2028 );
2029
2030 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
2032 }
2033
2034 #[test]
2035 fn test_md060_regular_alignment_warning_message() {
2036 let config = MD060Config {
2038 enabled: true,
2039 style: "aligned".to_string(),
2040 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
2042 column_align_header: None,
2043 column_align_body: None,
2044 loose_last_column: false,
2045 aligned_delimiter: false,
2046 pad_width: None,
2047 };
2048 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
2049
2050 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
2052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2053
2054 let warnings = rule.check(&ctx).unwrap();
2055
2056 assert!(!warnings.is_empty(), "Should generate warnings");
2058
2059 assert!(warnings[0].message.contains("Table columns should be aligned"));
2061 assert!(!warnings[0].message.contains("too wide"));
2062 assert!(!warnings[0].message.contains("max-width"));
2063 }
2064
2065 #[test]
2068 fn test_md060_unlimited_when_md013_disabled() {
2069 let config = MD060Config {
2071 enabled: true,
2072 style: "aligned".to_string(),
2073 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
2075 column_align_header: None,
2076 column_align_body: None,
2077 loose_last_column: false,
2078 aligned_delimiter: false,
2079 pad_width: None,
2080 };
2081 let md013_config = MD013Config::default();
2082 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
2083
2084 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let fixed = rule.fix(&ctx).unwrap();
2088
2089 let lines: Vec<&str> = fixed.lines().collect();
2091 assert_eq!(
2093 lines[0].len(),
2094 lines[1].len(),
2095 "Table should be aligned when MD013 is disabled"
2096 );
2097 }
2098
2099 #[test]
2100 fn test_md060_unlimited_when_md013_tables_false() {
2101 let config = MD060Config {
2103 enabled: true,
2104 style: "aligned".to_string(),
2105 max_width: LineLength::from_const(0),
2106 column_align: ColumnAlign::Auto,
2107 column_align_header: None,
2108 column_align_body: None,
2109 loose_last_column: false,
2110 aligned_delimiter: false,
2111 pad_width: None,
2112 };
2113 let md013_config = MD013Config {
2114 tables: false, line_length: LineLength::from_const(80),
2116 ..Default::default()
2117 };
2118 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2119
2120 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
2122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2123 let fixed = rule.fix(&ctx).unwrap();
2124
2125 let lines: Vec<&str> = fixed.lines().collect();
2127 assert_eq!(
2128 lines[0].len(),
2129 lines[1].len(),
2130 "Table should be aligned when MD013.tables=false"
2131 );
2132 }
2133
2134 #[test]
2135 fn test_md060_unlimited_when_md013_line_length_zero() {
2136 let config = MD060Config {
2138 enabled: true,
2139 style: "aligned".to_string(),
2140 max_width: LineLength::from_const(0),
2141 column_align: ColumnAlign::Auto,
2142 column_align_header: None,
2143 column_align_body: None,
2144 loose_last_column: false,
2145 aligned_delimiter: false,
2146 pad_width: None,
2147 };
2148 let md013_config = MD013Config {
2149 tables: true,
2150 line_length: LineLength::from_const(0), ..Default::default()
2152 };
2153 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2154
2155 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
2157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2158 let fixed = rule.fix(&ctx).unwrap();
2159
2160 let lines: Vec<&str> = fixed.lines().collect();
2162 assert_eq!(
2163 lines[0].len(),
2164 lines[1].len(),
2165 "Table should be aligned when MD013.line_length=0"
2166 );
2167 }
2168
2169 #[test]
2170 fn test_md060_explicit_max_width_overrides_md013_settings() {
2171 let config = MD060Config {
2173 enabled: true,
2174 style: "aligned".to_string(),
2175 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
2177 column_align_header: None,
2178 column_align_body: None,
2179 loose_last_column: false,
2180 aligned_delimiter: false,
2181 pad_width: None,
2182 };
2183 let md013_config = MD013Config {
2184 tables: false, line_length: LineLength::from_const(0), ..Default::default()
2187 };
2188 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2189
2190 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2193 let fixed = rule.fix(&ctx).unwrap();
2194
2195 assert!(
2197 fixed.contains("| --- |"),
2198 "Should be compact format due to explicit max_width"
2199 );
2200 }
2201
2202 #[test]
2203 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
2204 let config = MD060Config {
2206 enabled: true,
2207 style: "aligned".to_string(),
2208 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
2210 column_align_header: None,
2211 column_align_body: None,
2212 loose_last_column: false,
2213 aligned_delimiter: false,
2214 pad_width: None,
2215 };
2216 let md013_config = MD013Config {
2217 tables: true,
2218 line_length: LineLength::from_const(50), ..Default::default()
2220 };
2221 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2222
2223 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2226 let fixed = rule.fix(&ctx).unwrap();
2227
2228 assert!(
2230 fixed.contains("| --- |"),
2231 "Should be compact format when inheriting MD013 limit"
2232 );
2233 }
2234
2235 #[test]
2238 fn test_aligned_no_space_reformats_spaced_delimiter() {
2239 let config = MD060Config {
2242 enabled: true,
2243 style: "aligned-no-space".to_string(),
2244 max_width: LineLength::from_const(0),
2245 column_align: ColumnAlign::Auto,
2246 column_align_header: None,
2247 column_align_body: None,
2248 loose_last_column: false,
2249 aligned_delimiter: false,
2250 pad_width: None,
2251 };
2252 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2253
2254 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2257 let fixed = rule.fix(&ctx).unwrap();
2258
2259 assert!(
2262 !fixed.contains("| ----"),
2263 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2264 );
2265 assert!(
2266 !fixed.contains("---- |"),
2267 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2268 );
2269 assert!(
2271 fixed.contains("|----"),
2272 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2273 );
2274 }
2275
2276 #[test]
2277 fn test_aligned_reformats_compact_delimiter() {
2278 let config = MD060Config {
2281 enabled: true,
2282 style: "aligned".to_string(),
2283 max_width: LineLength::from_const(0),
2284 column_align: ColumnAlign::Auto,
2285 column_align_header: None,
2286 column_align_body: None,
2287 loose_last_column: false,
2288 aligned_delimiter: false,
2289 pad_width: None,
2290 };
2291 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2292
2293 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2296 let fixed = rule.fix(&ctx).unwrap();
2297
2298 assert!(
2300 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2301 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_aligned_no_space_preserves_matching_table() {
2307 let config = MD060Config {
2309 enabled: true,
2310 style: "aligned-no-space".to_string(),
2311 max_width: LineLength::from_const(0),
2312 column_align: ColumnAlign::Auto,
2313 column_align_header: None,
2314 column_align_body: None,
2315 loose_last_column: false,
2316 aligned_delimiter: false,
2317 pad_width: None,
2318 };
2319 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2320
2321 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2324 let fixed = rule.fix(&ctx).unwrap();
2325
2326 assert_eq!(
2328 fixed, content,
2329 "Table already in aligned-no-space style should be preserved"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_aligned_preserves_matching_table() {
2335 let config = MD060Config {
2337 enabled: true,
2338 style: "aligned".to_string(),
2339 max_width: LineLength::from_const(0),
2340 column_align: ColumnAlign::Auto,
2341 column_align_header: None,
2342 column_align_body: None,
2343 loose_last_column: false,
2344 aligned_delimiter: false,
2345 pad_width: None,
2346 };
2347 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2348
2349 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352 let fixed = rule.fix(&ctx).unwrap();
2353
2354 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2356 }
2357
2358 #[test]
2359 fn test_cjk_table_display_width_consistency() {
2360 let table_lines = vec!["| 名前 | Age |", "|------|-----|", "| 田中 | 25 |"];
2366
2367 let is_aligned = MD060TableFormat::is_table_already_aligned(
2369 &table_lines,
2370 crate::config::MarkdownFlavor::Standard,
2371 false,
2372 None,
2373 );
2374 assert!(
2375 !is_aligned,
2376 "Table with uneven raw line lengths should NOT be considered aligned"
2377 );
2378 }
2379
2380 #[test]
2381 fn test_cjk_width_calculation_in_aligned_check() {
2382 let cjk_width = MD060TableFormat::calculate_cell_display_width("名前");
2385 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2386
2387 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2388 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2389
2390 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" 名前 ");
2392 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2393
2394 let mixed = MD060TableFormat::calculate_cell_display_width(" 日本語ABC ");
2396 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2398 }
2399
2400 #[test]
2403 fn test_md060_column_align_left() {
2404 let config = MD060Config {
2406 enabled: true,
2407 style: "aligned".to_string(),
2408 max_width: LineLength::from_const(0),
2409 column_align: ColumnAlign::Left,
2410 column_align_header: None,
2411 column_align_body: None,
2412 loose_last_column: false,
2413 aligned_delimiter: false,
2414 pad_width: None,
2415 };
2416 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2417
2418 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420
2421 let fixed = rule.fix(&ctx).unwrap();
2422 let lines: Vec<&str> = fixed.lines().collect();
2423
2424 assert!(
2426 lines[2].contains("| Alice "),
2427 "Content should be left-aligned (Alice should have trailing padding)"
2428 );
2429 assert!(
2430 lines[3].contains("| Bob "),
2431 "Content should be left-aligned (Bob should have trailing padding)"
2432 );
2433 }
2434
2435 #[test]
2436 fn test_md060_column_align_center() {
2437 let config = MD060Config {
2439 enabled: true,
2440 style: "aligned".to_string(),
2441 max_width: LineLength::from_const(0),
2442 column_align: ColumnAlign::Center,
2443 column_align_header: None,
2444 column_align_body: None,
2445 loose_last_column: false,
2446 aligned_delimiter: false,
2447 pad_width: None,
2448 };
2449 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2450
2451 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2453
2454 let fixed = rule.fix(&ctx).unwrap();
2455 let lines: Vec<&str> = fixed.lines().collect();
2456
2457 assert!(
2460 lines[3].contains("| Bob |"),
2461 "Bob should be centered with padding on both sides. Got: {}",
2462 lines[3]
2463 );
2464 }
2465
2466 #[test]
2467 fn test_md060_column_align_right() {
2468 let config = MD060Config {
2470 enabled: true,
2471 style: "aligned".to_string(),
2472 max_width: LineLength::from_const(0),
2473 column_align: ColumnAlign::Right,
2474 column_align_header: None,
2475 column_align_body: None,
2476 loose_last_column: false,
2477 aligned_delimiter: false,
2478 pad_width: None,
2479 };
2480 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2481
2482 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2484
2485 let fixed = rule.fix(&ctx).unwrap();
2486 let lines: Vec<&str> = fixed.lines().collect();
2487
2488 assert!(
2490 lines[3].contains("| Bob |"),
2491 "Bob should be right-aligned with padding on left. Got: {}",
2492 lines[3]
2493 );
2494 }
2495
2496 #[test]
2497 fn test_md060_column_align_auto_respects_delimiter() {
2498 let config = MD060Config {
2500 enabled: true,
2501 style: "aligned".to_string(),
2502 max_width: LineLength::from_const(0),
2503 column_align: ColumnAlign::Auto,
2504 column_align_header: None,
2505 column_align_body: None,
2506 loose_last_column: false,
2507 aligned_delimiter: false,
2508 pad_width: None,
2509 };
2510 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2511
2512 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2515
2516 let fixed = rule.fix(&ctx).unwrap();
2517
2518 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2520 let lines: Vec<&str> = fixed.lines().collect();
2522 assert!(
2526 lines[2].contains(" C |"),
2527 "Right column should be right-aligned. Got: {}",
2528 lines[2]
2529 );
2530 }
2531
2532 #[test]
2533 fn test_md060_column_align_overrides_delimiter_indicators() {
2534 let config = MD060Config {
2536 enabled: true,
2537 style: "aligned".to_string(),
2538 max_width: LineLength::from_const(0),
2539 column_align: ColumnAlign::Right, column_align_header: None,
2541 column_align_body: None,
2542 loose_last_column: false,
2543 aligned_delimiter: false,
2544 pad_width: None,
2545 };
2546 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2547
2548 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2551
2552 let fixed = rule.fix(&ctx).unwrap();
2553 let lines: Vec<&str> = fixed.lines().collect();
2554
2555 assert!(
2558 lines[2].contains(" A |") || lines[2].contains(" A |"),
2559 "Even left-indicated column should be right-aligned. Got: {}",
2560 lines[2]
2561 );
2562 }
2563
2564 #[test]
2565 fn test_md060_column_align_with_aligned_no_space() {
2566 let config = MD060Config {
2568 enabled: true,
2569 style: "aligned-no-space".to_string(),
2570 max_width: LineLength::from_const(0),
2571 column_align: ColumnAlign::Center,
2572 column_align_header: None,
2573 column_align_body: None,
2574 loose_last_column: false,
2575 aligned_delimiter: false,
2576 pad_width: None,
2577 };
2578 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2579
2580 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2582
2583 let fixed = rule.fix(&ctx).unwrap();
2584 let lines: Vec<&str> = fixed.lines().collect();
2585
2586 assert!(
2588 lines[1].contains("|---"),
2589 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2590 lines[1]
2591 );
2592 assert!(
2594 lines[3].contains("| Bob |"),
2595 "Content should be centered. Got: {}",
2596 lines[3]
2597 );
2598 }
2599
2600 #[test]
2601 fn test_md060_column_align_config_parsing() {
2602 let toml_str = r#"
2604enabled = true
2605style = "aligned"
2606column-align = "center"
2607"#;
2608 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2609 assert_eq!(config.column_align, ColumnAlign::Center);
2610
2611 let toml_str = r#"
2612enabled = true
2613style = "aligned"
2614column-align = "right"
2615"#;
2616 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2617 assert_eq!(config.column_align, ColumnAlign::Right);
2618
2619 let toml_str = r#"
2620enabled = true
2621style = "aligned"
2622column-align = "left"
2623"#;
2624 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2625 assert_eq!(config.column_align, ColumnAlign::Left);
2626
2627 let toml_str = r#"
2628enabled = true
2629style = "aligned"
2630column-align = "auto"
2631"#;
2632 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2633 assert_eq!(config.column_align, ColumnAlign::Auto);
2634 }
2635
2636 #[test]
2637 fn test_md060_column_align_default_is_auto() {
2638 let toml_str = r#"
2640enabled = true
2641style = "aligned"
2642"#;
2643 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2644 assert_eq!(config.column_align, ColumnAlign::Auto);
2645 }
2646
2647 #[test]
2648 fn test_md060_column_align_reformats_already_aligned_table() {
2649 let config = MD060Config {
2651 enabled: true,
2652 style: "aligned".to_string(),
2653 max_width: LineLength::from_const(0),
2654 column_align: ColumnAlign::Right,
2655 column_align_header: None,
2656 column_align_body: None,
2657 loose_last_column: false,
2658 aligned_delimiter: false,
2659 pad_width: None,
2660 };
2661 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2662
2663 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2666
2667 let fixed = rule.fix(&ctx).unwrap();
2668 let lines: Vec<&str> = fixed.lines().collect();
2669
2670 assert!(
2672 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2673 "Already aligned table should be reformatted with right alignment. Got: {}",
2674 lines[2]
2675 );
2676 assert!(
2677 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2678 "Bob should be right-aligned. Got: {}",
2679 lines[3]
2680 );
2681 }
2682
2683 #[test]
2684 fn test_md060_column_align_with_cjk_characters() {
2685 let config = MD060Config {
2687 enabled: true,
2688 style: "aligned".to_string(),
2689 max_width: LineLength::from_const(0),
2690 column_align: ColumnAlign::Center,
2691 column_align_header: None,
2692 column_align_body: None,
2693 loose_last_column: false,
2694 aligned_delimiter: false,
2695 pad_width: None,
2696 };
2697 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2698
2699 let content = "| Name | City |\n|---|---|\n| Alice | 東京 |\n| Bob | LA |";
2700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2701
2702 let fixed = rule.fix(&ctx).unwrap();
2703
2704 assert!(fixed.contains("Bob"), "Table should contain Bob");
2707 assert!(fixed.contains("東京"), "Table should contain 東京");
2708 }
2709
2710 #[test]
2711 fn test_md060_column_align_ignored_for_compact_style() {
2712 let config = MD060Config {
2714 enabled: true,
2715 style: "compact".to_string(),
2716 max_width: LineLength::from_const(0),
2717 column_align: ColumnAlign::Right, column_align_header: None,
2719 column_align_body: None,
2720 loose_last_column: false,
2721 aligned_delimiter: false,
2722 pad_width: None,
2723 };
2724 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2725
2726 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2728
2729 let fixed = rule.fix(&ctx).unwrap();
2730
2731 assert!(
2733 fixed.contains("| Alice |"),
2734 "Compact style should have single space padding, not alignment. Got: {fixed}"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_md060_column_align_ignored_for_tight_style() {
2740 let config = MD060Config {
2742 enabled: true,
2743 style: "tight".to_string(),
2744 max_width: LineLength::from_const(0),
2745 column_align: ColumnAlign::Center, column_align_header: None,
2747 column_align_body: None,
2748 loose_last_column: false,
2749 aligned_delimiter: false,
2750 pad_width: None,
2751 };
2752 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2753
2754 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2756
2757 let fixed = rule.fix(&ctx).unwrap();
2758
2759 assert!(
2761 fixed.contains("|Alice|"),
2762 "Tight style should have no spaces. Got: {fixed}"
2763 );
2764 }
2765
2766 #[test]
2767 fn test_md060_column_align_with_empty_cells() {
2768 let config = MD060Config {
2770 enabled: true,
2771 style: "aligned".to_string(),
2772 max_width: LineLength::from_const(0),
2773 column_align: ColumnAlign::Center,
2774 column_align_header: None,
2775 column_align_body: None,
2776 loose_last_column: false,
2777 aligned_delimiter: false,
2778 pad_width: None,
2779 };
2780 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2781
2782 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2784
2785 let fixed = rule.fix(&ctx).unwrap();
2786 let lines: Vec<&str> = fixed.lines().collect();
2787
2788 assert!(
2790 lines[3].contains("| |") || lines[3].contains("| |"),
2791 "Empty cell should be padded correctly. Got: {}",
2792 lines[3]
2793 );
2794 }
2795
2796 #[test]
2797 fn test_md060_column_align_auto_preserves_already_aligned() {
2798 let config = MD060Config {
2800 enabled: true,
2801 style: "aligned".to_string(),
2802 max_width: LineLength::from_const(0),
2803 column_align: ColumnAlign::Auto,
2804 column_align_header: None,
2805 column_align_body: None,
2806 loose_last_column: false,
2807 aligned_delimiter: false,
2808 pad_width: None,
2809 };
2810 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2811
2812 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2815
2816 let fixed = rule.fix(&ctx).unwrap();
2817
2818 assert_eq!(
2820 fixed, content,
2821 "Already aligned table should be preserved with column-align=auto"
2822 );
2823 }
2824
2825 #[test]
2826 fn test_cjk_table_display_aligned_not_flagged() {
2827 use crate::config::MarkdownFlavor;
2831
2832 let table_lines: Vec<&str> = vec![
2834 "| Header | Name |",
2835 "| ------ | ---- |",
2836 "| Hello | Test |",
2837 "| 你好 | Test |",
2838 ];
2839
2840 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false, None);
2841 assert!(
2842 result,
2843 "Table with CJK characters that is display-aligned should be recognized as aligned"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_cjk_table_not_reformatted_when_aligned() {
2849 let rule = MD060TableFormat::new(true, "aligned".to_string());
2851 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| 你好 | Test |\n";
2853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2854
2855 let fixed = rule.fix(&ctx).unwrap();
2857 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2858 }
2859
2860 #[test]
2876 fn md060_pandoc_grid_tables_not_flagged() {
2877 let rule = MD060TableFormat::new(true, "aligned".to_string());
2878 let content = "\
2879+---+---+
2880| a | b |
2881+===+===+
2882| 1 | 2 |
2883+---+---+
2884";
2885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2888 let result = rule.check(&ctx).unwrap();
2889 assert!(
2890 result.is_empty(),
2891 "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2892 );
2893
2894 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2895 let result_std = rule.check(&ctx_std).unwrap();
2896 assert!(
2897 result_std.is_empty(),
2898 "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2899 );
2900 }
2901
2902 #[test]
2903 fn md060_pandoc_multi_line_tables_not_flagged() {
2904 let rule = MD060TableFormat::new(true, "aligned".to_string());
2905 let content = "\
2906--------- -----------
2907Header 1 Header 2
2908--------- -----------
2909Cell 1 Cell 2
2910--------- -----------
2911";
2912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2913 let result = rule.check(&ctx).unwrap();
2914 assert!(
2915 result.is_empty(),
2916 "MD060 should not flag Pandoc multi-line tables: {result:?}"
2917 );
2918
2919 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2920 let result_std = rule.check(&ctx_std).unwrap();
2921 assert!(
2922 result_std.is_empty(),
2923 "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2924 );
2925 }
2926
2927 #[test]
2928 fn md060_pandoc_line_blocks_not_flagged() {
2929 let rule = MD060TableFormat::new(true, "aligned".to_string());
2930 let content = "| First line\n| Second line\n";
2932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2933 let result = rule.check(&ctx).unwrap();
2934 assert!(
2935 result.is_empty(),
2936 "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2937 );
2938
2939 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2940 let result_std = rule.check(&ctx_std).unwrap();
2941 assert!(
2942 result_std.is_empty(),
2943 "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2944 );
2945 }
2946
2947 #[test]
2948 fn md060_pandoc_pipe_table_captions_not_flagged() {
2949 let rule = MD060TableFormat::new(true, "aligned".to_string());
2950 let content = "\
2953| H1 | H2 |
2954| -- | -- |
2955| a | b |
2956
2957: My table caption
2958";
2959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2960 let result = rule.check(&ctx).unwrap();
2961 assert!(
2962 result.is_empty(),
2963 "MD060 should not flag the pipe-table caption line: {result:?}"
2964 );
2965
2966 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2967 let result_std = rule.check(&ctx_std).unwrap();
2968 assert!(
2969 result_std.is_empty(),
2970 "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2976 let rule = MD060TableFormat::new(true, "aligned".to_string());
2980
2981 for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
2983 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2984 assert_eq!(
2985 rule.fix(&ctx).unwrap(),
2986 input,
2987 "MD060 must not alter table-free content: {input:?}"
2988 );
2989 }
2990
2991 let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
2994 let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
2995 let once = rule.fix(&ctx).unwrap();
2996 assert!(
2997 once.ends_with("\n\n\n"),
2998 "trailing blank lines must be preserved, got: {once:?}"
2999 );
3000 let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
3001 let twice = rule.fix(&ctx2).unwrap();
3002 assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
3003 }
3004
3005 fn indent_block(block: &str, indent: usize) -> String {
3007 let spaces = " ".repeat(indent);
3008 block.lines().fold(String::new(), |mut indented, line| {
3009 indented.push_str(&spaces);
3010 indented.push_str(line);
3011 indented.push('\n');
3012 indented
3013 })
3014 }
3015
3016 #[test]
3017 fn test_mdg_table_indent_stays_within_enclosing_list_item() {
3018 let rule = MD060TableFormat::new(true, "aligned".to_string());
3023 let written = "| name | price |\n|---|---|\n| a | b |\n";
3024 let formatted = "| name | price |\n| ---- | ----- |\n| a | b |\n";
3025
3026 let cases = [
3028 ("* Given", 2, 2),
3029 ("* Given", 5, 2),
3030 ("1. Given", 3, 3),
3031 ("1. Given", 5, 3),
3032 ("* Given\n * Nested", 4, 4),
3033 ("* Given\n * Nested", 5, 4),
3034 ];
3035
3036 for (item, indent, expected_indent) in cases {
3037 let input = format!("# Feature: F\n\n{item}\n\n{}", indent_block(written, indent));
3038 let expected = format!("# Feature: F\n\n{item}\n\n{}", indent_block(formatted, expected_indent));
3039 let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::MDG, None);
3040
3041 assert_eq!(
3042 rule.fix(&ctx).unwrap(),
3043 expected,
3044 "a {indent}-space table under {item:?} belongs at {expected_indent} spaces"
3045 );
3046
3047 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDG, None);
3048 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3049 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected, "MD060 fix must be idempotent");
3050 }
3051 }
3052
3053 #[test]
3054 fn test_mdg_table_below_a_deep_list_item_keeps_list_indentation() {
3055 let rule = MD060TableFormat::new(true, "aligned".to_string());
3059 let input = "# Feature: F\n\n* A\n * B\n * C\n\n | name | price |\n |---|---|\n | a | b |\n";
3060 let expected = "# Feature: F\n\n* A\n * B\n * C\n\n | name | price |\n | ---- | ----- |\n | a | b |\n";
3061
3062 let mdg = rule
3063 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
3064 .unwrap();
3065 assert_eq!(mdg, expected);
3066 assert_eq!(
3067 mdg,
3068 rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3069 .unwrap()
3070 );
3071 }
3072
3073 #[test]
3074 fn test_mdg_tab_indented_table_is_normalized_not_flattened() {
3075 let rule = MD060TableFormat::new(true, "aligned".to_string());
3079 let input = "# Feature: F\n\n#### Examples:\n\n\t\t| a | bb |\n\t\t|---|---|\n\t\t| 1 | 2 |\n";
3080 let expected = "# Feature: F\n\n#### Examples:\n\n | a | bb |\n | --- | --- |\n | 1 | 2 |\n";
3081
3082 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::MDG, None);
3083 assert_eq!(rule.fix(&ctx).unwrap(), expected);
3084
3085 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::MDG, None);
3086 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3087
3088 let standard = rule
3089 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3090 .unwrap();
3091 assert_eq!(
3092 standard, input,
3093 "tab-indented rows are indented code outside MDG and stay untouched"
3094 );
3095 }
3096
3097 #[test]
3098 fn test_mdg_table_row_needs_a_pipe_behind_its_indent() {
3099 let rule = MD060TableFormat::new(true, "aligned".to_string());
3104
3105 for input in [
3106 "# Feature: F\n\n > | a | b |\n > |---|---|\n > | 1 | 2 |\n",
3107 "# Feature: F\n\n - | a | b |\n |---|---|\n | 1 | 2 |\n",
3108 ] {
3109 let mdg = rule
3110 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
3111 .unwrap();
3112 assert_eq!(
3113 mdg,
3114 rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3115 .unwrap(),
3116 "{input:?} is not a Gherkin table, so both flavors must agree"
3117 );
3118
3119 let fixed_ctx = LintContext::new(&mdg, crate::config::MarkdownFlavor::MDG, None);
3120 assert_eq!(rule.fix(&fixed_ctx).unwrap(), mdg, "MD060 fix must converge");
3121 }
3122 }
3123}