1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mdg;
3use crate::utils::range_utils::calculate_line_range;
4use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
5use crate::utils::table_utils::TableUtils;
6use unicode_width::UnicodeWidthStr;
7
8mod md060_config;
9use crate::md013_line_length::MD013Config;
10pub use md060_config::ColumnAlign;
11pub use md060_config::MD060Config;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15enum RowType {
16 Header,
18 Delimiter,
20 Body,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25enum ColumnAlignment {
26 Left,
27 Center,
28 Right,
29}
30
31#[derive(Debug, Clone)]
32struct TableFormatResult {
33 lines: Vec<String>,
34 auto_compacted: bool,
35 aligned_width: Option<usize>,
36}
37
38#[derive(Debug, Clone, Copy)]
40struct RowFormatOptions {
41 row_type: RowType,
43 compact_delimiter: bool,
45 pad_width: usize,
47 column_align: ColumnAlign,
49 column_align_header: Option<ColumnAlign>,
51 column_align_body: Option<ColumnAlign>,
53}
54
55#[derive(Debug, Clone, Default)]
189pub struct MD060TableFormat {
190 config: MD060Config,
191 md013_config: MD013Config,
192 md013_disabled: bool,
193}
194
195impl MD060TableFormat {
196 pub fn new(enabled: bool, style: String) -> Self {
197 use crate::types::LineLength;
198 Self {
199 config: MD060Config {
200 enabled,
201 style,
202 max_width: LineLength::from_const(0),
203 column_align: ColumnAlign::Auto,
204 column_align_header: None,
205 column_align_body: None,
206 loose_last_column: false,
207 aligned_delimiter: false,
208 pad_width: None,
209 },
210 md013_config: MD013Config::default(),
211 md013_disabled: false,
212 }
213 }
214
215 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
216 Self {
217 config,
218 md013_config,
219 md013_disabled,
220 }
221 }
222
223 fn default_pad_width(style: &str) -> usize {
227 usize::from(style != "tight")
228 }
229
230 fn pad_width_for(&self, target_style: &str) -> usize {
239 self.config
240 .pad_width
241 .unwrap_or_else(|| Self::default_pad_width(target_style))
242 }
243
244 fn effective_max_width(&self) -> usize {
254 if !self.config.max_width.is_unlimited() {
256 return self.config.max_width.get();
257 }
258
259 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
264 return usize::MAX; }
266
267 self.md013_config.line_length.get()
269 }
270
271 fn contains_problematic_chars(text: &str) -> bool {
282 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
287
288 fn calculate_cell_display_width(cell_content: &str) -> usize {
289 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
290 masked.trim().width()
291 }
292
293 #[cfg(test)]
296 fn parse_table_row(line: &str) -> Vec<String> {
297 TableUtils::split_table_row(line)
298 }
299
300 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
304 TableUtils::split_table_row_with_flavor(line, flavor)
305 }
306
307 fn is_delimiter_row(row: &[String]) -> bool {
308 if row.is_empty() {
309 return false;
310 }
311 row.iter().all(|cell| {
312 let trimmed = cell.trim();
313 !trimmed.is_empty()
316 && trimmed.contains('-')
317 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
318 })
319 }
320
321 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
324 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
325 (&line[..m.end()], &line[m.end()..])
326 } else {
327 ("", line)
328 }
329 }
330
331 fn mdg_table_indent(
340 table_lines: &[&str],
341 list_content_indent: Option<usize>,
342 flavor: crate::config::MarkdownFlavor,
343 ) -> Option<String> {
344 if flavor != crate::config::MarkdownFlavor::MDG || !table_lines.iter().all(|line| mdg::is_table_row(line)) {
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 table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
873 return TableFormatResult {
874 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
875 auto_compacted: false,
876 aligned_width: None,
877 };
878 }
879
880 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
883
884 let list_context = &table_block.list_context;
886 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
887 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
888 } else {
889 ("", String::new())
890 };
891 let mdg_indent = Self::mdg_table_indent(
892 &table_lines,
893 list_context.as_ref().map(|ctx| ctx.content_indent),
894 flavor,
895 );
896
897 let stripped_lines: Vec<&str> = table_lines
899 .iter()
900 .enumerate()
901 .map(|(i, line)| {
902 let after_blockquote = Self::extract_blockquote_prefix(line).1;
903 if mdg_indent.is_some() {
904 after_blockquote.trim_start_matches([' ', '\t'])
905 } else if list_context.is_some() {
906 if i == 0 {
907 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
909 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
910 })
911 } else {
912 after_blockquote
914 .strip_prefix(&continuation_indent)
915 .unwrap_or(after_blockquote.trim_start())
916 }
917 } else {
918 after_blockquote
919 }
920 })
921 .collect();
922
923 let style = self.config.style.as_str();
924
925 match style {
926 "any" => {
927 let detected_style = Self::detect_table_style(&stripped_lines, flavor, self.config.pad_width);
928 if detected_style.is_none() {
929 return TableFormatResult {
930 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
931 auto_compacted: false,
932 aligned_width: None,
933 };
934 }
935
936 let target_style = detected_style.unwrap();
937 let pad_width = self.pad_width_for(&target_style);
938
939 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
941 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
942
943 for (row_idx, line) in stripped_lines.iter().enumerate() {
944 let cells = Self::parse_table_row_with_flavor(line, flavor);
945 match target_style.as_str() {
946 "tight" | "compact" => result.push(Self::format_table_unaligned(&cells, pad_width)),
947 _ => {
948 let column_widths =
949 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
950 let row_type = match row_idx {
951 0 => RowType::Header,
952 1 => RowType::Delimiter,
953 _ => RowType::Body,
954 };
955 let options = RowFormatOptions {
956 row_type,
957 compact_delimiter: false,
958 pad_width,
959 column_align: self.config.column_align,
960 column_align_header: self.config.column_align_header,
961 column_align_body: self.config.column_align_body,
962 };
963 result.push(Self::format_table_row(
964 &cells,
965 &column_widths,
966 &column_alignments,
967 &options,
968 ));
969 }
970 }
971 }
972 }
973 "compact" | "tight" => {
974 let pad_width = self.pad_width_for(style);
975 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
976 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
977 Some(Self::header_cell_widths(&header_cells))
978 } else {
979 None
980 };
981
982 for (row_idx, line) in stripped_lines.iter().enumerate() {
983 let cells = Self::parse_table_row_with_flavor(line, flavor);
984 if row_idx == 1
985 && let Some(widths) = &header_widths
986 {
987 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, pad_width));
988 continue;
989 }
990 result.push(Self::format_table_unaligned(&cells, pad_width));
991 }
992 }
993 "aligned" | "aligned-no-space" => {
994 let compact_delimiter = style == "aligned-no-space";
995 let pad_width = self.pad_width_for(style);
996
997 let needs_reformat = self.config.column_align != ColumnAlign::Auto
1000 || self.config.column_align_header.is_some()
1001 || self.config.column_align_body.is_some()
1002 || self.config.loose_last_column;
1003
1004 if !needs_reformat
1005 && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter, self.config.pad_width)
1006 {
1007 return TableFormatResult {
1008 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
1009 auto_compacted: false,
1010 aligned_width: None,
1011 };
1012 }
1013
1014 let column_widths =
1015 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
1016
1017 let num_columns = column_widths.len();
1020 let calc_aligned_width = 1 + num_columns * (2 * pad_width + 1) + column_widths.iter().sum::<usize>();
1021 aligned_width = Some(calc_aligned_width);
1022
1023 if calc_aligned_width > self.effective_max_width() {
1028 auto_compacted = true;
1029 let compact_pad_width = self.pad_width_for("compact");
1032 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
1033 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
1034 Some(Self::header_cell_widths(&header_cells))
1035 } else {
1036 None
1037 };
1038 for (row_idx, line) in stripped_lines.iter().enumerate() {
1039 let cells = Self::parse_table_row_with_flavor(line, flavor);
1040 if row_idx == 1
1041 && let Some(widths) = &header_widths
1042 {
1043 result.push(Self::format_delimiter_aligned_to_header(
1044 &cells,
1045 widths,
1046 compact_pad_width,
1047 ));
1048 continue;
1049 }
1050 result.push(Self::format_table_unaligned(&cells, compact_pad_width));
1051 }
1052 } else {
1053 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
1055 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
1056
1057 for (row_idx, line) in stripped_lines.iter().enumerate() {
1058 let cells = Self::parse_table_row_with_flavor(line, flavor);
1059 let row_type = match row_idx {
1060 0 => RowType::Header,
1061 1 => RowType::Delimiter,
1062 _ => RowType::Body,
1063 };
1064 let options = RowFormatOptions {
1065 row_type,
1066 compact_delimiter,
1067 pad_width,
1068 column_align: self.config.column_align,
1069 column_align_header: self.config.column_align_header,
1070 column_align_body: self.config.column_align_body,
1071 };
1072 result.push(Self::format_table_row(
1073 &cells,
1074 &column_widths,
1075 &column_alignments,
1076 &options,
1077 ));
1078 }
1079 }
1080 }
1081 _ => {
1082 return TableFormatResult {
1083 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
1084 auto_compacted: false,
1085 aligned_width: None,
1086 };
1087 }
1088 }
1089
1090 let prefixed_result: Vec<String> = result
1092 .into_iter()
1093 .enumerate()
1094 .map(|(i, line)| {
1095 if let Some(mdg_indent) = &mdg_indent {
1096 format!("{blockquote_prefix}{mdg_indent}{line}")
1097 } else if list_context.is_some() {
1098 if i == 0 {
1099 format!("{blockquote_prefix}{list_prefix}{line}")
1101 } else {
1102 format!("{blockquote_prefix}{continuation_indent}{line}")
1104 }
1105 } else {
1106 format!("{blockquote_prefix}{line}")
1107 }
1108 })
1109 .collect();
1110
1111 TableFormatResult {
1112 lines: prefixed_result,
1113 auto_compacted,
1114 aligned_width,
1115 }
1116 }
1117}
1118
1119impl Rule for MD060TableFormat {
1120 fn name(&self) -> &'static str {
1121 "MD060"
1122 }
1123
1124 fn description(&self) -> &'static str {
1125 "Table columns should be consistently aligned"
1126 }
1127
1128 fn category(&self) -> RuleCategory {
1129 RuleCategory::Table
1130 }
1131
1132 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1133 !ctx.likely_has_tables()
1134 }
1135
1136 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1137 let mut warnings = Vec::new();
1138
1139 let lines = ctx.raw_lines();
1140 let table_blocks = &ctx.table_blocks;
1141
1142 for table_block in table_blocks {
1143 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1144
1145 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1146 .chain(std::iter::once(table_block.delimiter_line))
1147 .chain(table_block.content_lines.iter().copied())
1148 .collect();
1149
1150 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());
1157 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1158 let fixed_line = &format_result.lines[i];
1159 if line_idx < lines.len() - 1 {
1161 fixed_table_lines.push(format!("{fixed_line}\n"));
1162 } else {
1163 fixed_table_lines.push(fixed_line.clone());
1164 }
1165 }
1166 let table_replacement = fixed_table_lines.concat();
1167 let table_range = ctx.line_span_byte_range(table_start_line, table_end_line);
1168
1169 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1170 let original = lines[line_idx];
1171 let fixed = &format_result.lines[i];
1172
1173 if original != fixed {
1174 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1175
1176 let message = if format_result.auto_compacted {
1177 if let Some(width) = format_result.aligned_width {
1178 format!(
1179 "Table too wide for aligned formatting ({} chars > max-width: {})",
1180 width,
1181 self.effective_max_width()
1182 )
1183 } else {
1184 "Table too wide for aligned formatting".to_string()
1185 }
1186 } else {
1187 "Table columns should be aligned".to_string()
1188 };
1189
1190 warnings.push(LintWarning {
1193 rule_name: Some(self.name().to_string()),
1194 severity: Severity::Warning,
1195 message,
1196 line: start_line,
1197 column: start_col,
1198 end_line,
1199 end_column: end_col,
1200 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1201 });
1202 }
1203 }
1204 }
1205
1206 Ok(warnings)
1207 }
1208
1209 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1210 let content = ctx.content;
1211 let lines = ctx.raw_lines();
1212 let table_blocks = &ctx.table_blocks;
1213
1214 if table_blocks.is_empty() {
1217 return Ok(content.to_string());
1218 }
1219
1220 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1221
1222 for table_block in table_blocks {
1223 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1224
1225 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1226 .chain(std::iter::once(table_block.delimiter_line))
1227 .chain(table_block.content_lines.iter().copied())
1228 .collect();
1229
1230 let any_disabled = table_line_indices
1233 .iter()
1234 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1235
1236 if any_disabled {
1237 continue;
1238 }
1239
1240 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1241 result_lines[line_idx].clone_from(&format_result.lines[i]);
1242 }
1243 }
1244
1245 let mut fixed = result_lines.join("\n");
1246 let original_trailing_newlines = content.len() - content.trim_end_matches('\n').len();
1251 fixed.truncate(fixed.trim_end_matches('\n').len());
1252 fixed.push_str(&"\n".repeat(original_trailing_newlines));
1253 Ok(fixed)
1254 }
1255
1256 fn as_any(&self) -> &dyn std::any::Any {
1257 self
1258 }
1259
1260 crate::impl_rule_config_sections!(MD060Config);
1261
1262 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1263 where
1264 Self: Sized,
1265 {
1266 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1267 let md013_config = MD013Config::from_document_config(config);
1268
1269 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1271
1272 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use crate::lint_context::LintContext;
1280 use crate::types::LineLength;
1281
1282 fn md013_with_line_length(line_length: usize) -> MD013Config {
1284 MD013Config {
1285 line_length: LineLength::from_const(line_length),
1286 tables: true, ..Default::default()
1288 }
1289 }
1290
1291 #[test]
1292 fn test_md060_align_simple_ascii_table() {
1293 let rule = MD060TableFormat::new(true, "aligned".to_string());
1294
1295 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297
1298 let fixed = rule.fix(&ctx).unwrap();
1299 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1300 assert_eq!(fixed, expected);
1301
1302 let lines: Vec<&str> = fixed.lines().collect();
1304 assert_eq!(lines[0].len(), lines[1].len());
1305 assert_eq!(lines[1].len(), lines[2].len());
1306 }
1307
1308 #[test]
1309 fn test_md060_cjk_characters_aligned_correctly() {
1310 let rule = MD060TableFormat::new(true, "aligned".to_string());
1311
1312 let content = "| Name | Age |\n|---|---|\n| 中文 | 30 |";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314
1315 let fixed = rule.fix(&ctx).unwrap();
1316
1317 let lines: Vec<&str> = fixed.lines().collect();
1318 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1319 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1320
1321 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1322 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1323
1324 assert_eq!(width1, width3);
1325 }
1326
1327 #[test]
1328 fn test_md060_basic_emoji() {
1329 let rule = MD060TableFormat::new(true, "aligned".to_string());
1330
1331 let content = "| Status | Name |\n|---|---|\n| ✅ | Test |";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333
1334 let fixed = rule.fix(&ctx).unwrap();
1335 assert!(fixed.contains("Status"));
1336 }
1337
1338 #[test]
1339 fn test_md060_zwj_emoji_skipped() {
1340 let rule = MD060TableFormat::new(true, "aligned".to_string());
1341
1342 let content = "| Emoji | Name |\n|---|---|\n| 👨👩👧👦 | Family |";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344
1345 let fixed = rule.fix(&ctx).unwrap();
1346 assert_eq!(fixed, content);
1347 }
1348
1349 #[test]
1350 fn test_md060_inline_code_with_escaped_pipes() {
1351 let rule = MD060TableFormat::new(true, "aligned".to_string());
1354
1355 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358
1359 let fixed = rule.fix(&ctx).unwrap();
1360 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1361 }
1362
1363 #[test]
1364 fn test_md060_compact_style() {
1365 let rule = MD060TableFormat::new(true, "compact".to_string());
1366
1367 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369
1370 let fixed = rule.fix(&ctx).unwrap();
1371 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1372 assert_eq!(fixed, expected);
1373 }
1374
1375 #[test]
1376 fn test_md060_tight_style() {
1377 let rule = MD060TableFormat::new(true, "tight".to_string());
1378
1379 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381
1382 let fixed = rule.fix(&ctx).unwrap();
1383 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1384 assert_eq!(fixed, expected);
1385 }
1386
1387 #[test]
1388 fn test_md060_aligned_no_space_style() {
1389 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1391
1392 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394
1395 let fixed = rule.fix(&ctx).unwrap();
1396
1397 let lines: Vec<&str> = fixed.lines().collect();
1399 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1400 assert_eq!(
1401 lines[1], "|-------|-----|",
1402 "Delimiter should have NO spaces around dashes"
1403 );
1404 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1405
1406 assert_eq!(lines[0].len(), lines[1].len());
1408 assert_eq!(lines[1].len(), lines[2].len());
1409 }
1410
1411 #[test]
1412 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1413 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1415
1416 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1418
1419 let fixed = rule.fix(&ctx).unwrap();
1420 let lines: Vec<&str> = fixed.lines().collect();
1421
1422 assert!(
1424 fixed.contains("|:"),
1425 "Should have left alignment indicator adjacent to pipe"
1426 );
1427 assert!(
1428 fixed.contains(":|"),
1429 "Should have right alignment indicator adjacent to pipe"
1430 );
1431 assert!(
1433 lines[1].contains(":---") && lines[1].contains("---:"),
1434 "Should have center alignment colons"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_md060_aligned_no_space_three_column_table() {
1440 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1442
1443 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 |";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445
1446 let fixed = rule.fix(&ctx).unwrap();
1447 let lines: Vec<&str> = fixed.lines().collect();
1448
1449 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1451 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1452 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1453 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1454 }
1455
1456 #[test]
1457 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1458 let config = MD060Config {
1460 enabled: true,
1461 style: "aligned-no-space".to_string(),
1462 max_width: LineLength::from_const(50),
1463 column_align: ColumnAlign::Auto,
1464 column_align_header: None,
1465 column_align_body: None,
1466 loose_last_column: false,
1467 aligned_delimiter: false,
1468 pad_width: None,
1469 };
1470 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1471
1472 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475
1476 let fixed = rule.fix(&ctx).unwrap();
1477
1478 assert!(
1480 fixed.contains("| --- |"),
1481 "Should be compact format when exceeding max-width"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_md060_aligned_no_space_cjk_characters() {
1487 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1489
1490 let content = "| Name | City |\n|---|---|\n| 中文 | 東京 |";
1491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492
1493 let fixed = rule.fix(&ctx).unwrap();
1494 let lines: Vec<&str> = fixed.lines().collect();
1495
1496 use unicode_width::UnicodeWidthStr;
1499 assert_eq!(
1500 lines[0].width(),
1501 lines[1].width(),
1502 "Header and delimiter should have same display width"
1503 );
1504 assert_eq!(
1505 lines[1].width(),
1506 lines[2].width(),
1507 "Delimiter and content should have same display width"
1508 );
1509
1510 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1512 }
1513
1514 #[test]
1515 fn test_md060_aligned_no_space_minimum_width() {
1516 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1518
1519 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521
1522 let fixed = rule.fix(&ctx).unwrap();
1523 let lines: Vec<&str> = fixed.lines().collect();
1524
1525 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1527 assert_eq!(lines[0].len(), lines[1].len());
1529 assert_eq!(lines[1].len(), lines[2].len());
1530 }
1531
1532 #[test]
1533 fn test_md060_any_style_consistency() {
1534 let rule = MD060TableFormat::new(true, "any".to_string());
1535
1536 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539
1540 let fixed = rule.fix(&ctx).unwrap();
1541 assert_eq!(fixed, content);
1542
1543 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1545 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1546
1547 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1548 assert_eq!(fixed_aligned, content_aligned);
1549 }
1550
1551 #[test]
1552 fn test_md060_empty_cells() {
1553 let rule = MD060TableFormat::new(true, "aligned".to_string());
1554
1555 let content = "| A | B |\n|---|---|\n| | X |";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557
1558 let fixed = rule.fix(&ctx).unwrap();
1559 assert!(fixed.contains('|'));
1560 }
1561
1562 #[test]
1563 fn test_md060_mixed_content() {
1564 let rule = MD060TableFormat::new(true, "aligned".to_string());
1565
1566 let content = "| Name | Age | City |\n|---|---|---|\n| 中文 | 30 | NYC |";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568
1569 let fixed = rule.fix(&ctx).unwrap();
1570 assert!(fixed.contains("中文"));
1571 assert!(fixed.contains("NYC"));
1572 }
1573
1574 #[test]
1575 fn test_md060_preserve_alignment_indicators() {
1576 let rule = MD060TableFormat::new(true, "aligned".to_string());
1577
1578 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580
1581 let fixed = rule.fix(&ctx).unwrap();
1582
1583 assert!(fixed.contains(":---"), "Should contain left alignment");
1584 assert!(fixed.contains(":----:"), "Should contain center alignment");
1585 assert!(fixed.contains("----:"), "Should contain right alignment");
1586 }
1587
1588 #[test]
1589 fn test_md060_minimum_column_width() {
1590 let rule = MD060TableFormat::new(true, "aligned".to_string());
1591
1592 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596
1597 let fixed = rule.fix(&ctx).unwrap();
1598
1599 let lines: Vec<&str> = fixed.lines().collect();
1600 assert_eq!(lines[0].len(), lines[1].len());
1601 assert_eq!(lines[1].len(), lines[2].len());
1602
1603 assert!(fixed.contains("ID "), "Short content should be padded");
1605 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1606 }
1607
1608 #[test]
1609 fn test_md060_auto_compact_exceeds_default_threshold() {
1610 let config = MD060Config {
1612 enabled: true,
1613 style: "aligned".to_string(),
1614 max_width: LineLength::from_const(0),
1615 column_align: ColumnAlign::Auto,
1616 column_align_header: None,
1617 column_align_body: None,
1618 loose_last_column: false,
1619 aligned_delimiter: false,
1620 pad_width: None,
1621 };
1622 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1623
1624 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629
1630 let fixed = rule.fix(&ctx).unwrap();
1631
1632 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1634 assert!(fixed.contains("| --- | --- | --- |"));
1635 assert!(fixed.contains("| Short | Data | Here |"));
1636
1637 let lines: Vec<&str> = fixed.lines().collect();
1639 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1641 }
1642
1643 #[test]
1644 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1645 let config = MD060Config {
1647 enabled: true,
1648 style: "aligned".to_string(),
1649 max_width: LineLength::from_const(50),
1650 column_align: ColumnAlign::Auto,
1651 column_align_header: None,
1652 column_align_body: None,
1653 loose_last_column: false,
1654 aligned_delimiter: false,
1655 pad_width: None,
1656 };
1657 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 |";
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664
1665 let fixed = rule.fix(&ctx).unwrap();
1666
1667 assert!(
1669 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1670 );
1671 assert!(fixed.contains("| --- | --- | --- |"));
1672 assert!(fixed.contains("| Data | Data | Data |"));
1673
1674 let lines: Vec<&str> = fixed.lines().collect();
1676 assert!(lines[0].len() != lines[2].len());
1677 }
1678
1679 #[test]
1680 fn test_md060_stays_aligned_under_threshold() {
1681 let config = MD060Config {
1683 enabled: true,
1684 style: "aligned".to_string(),
1685 max_width: LineLength::from_const(100),
1686 column_align: ColumnAlign::Auto,
1687 column_align_header: None,
1688 column_align_body: None,
1689 loose_last_column: false,
1690 aligned_delimiter: false,
1691 pad_width: None,
1692 };
1693 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1694
1695 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698
1699 let fixed = rule.fix(&ctx).unwrap();
1700
1701 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1703 assert_eq!(fixed, expected);
1704
1705 let lines: Vec<&str> = fixed.lines().collect();
1706 assert_eq!(lines[0].len(), lines[1].len());
1707 assert_eq!(lines[1].len(), lines[2].len());
1708 }
1709
1710 #[test]
1711 fn test_md060_width_calculation_formula() {
1712 let config = MD060Config {
1714 enabled: true,
1715 style: "aligned".to_string(),
1716 max_width: LineLength::from_const(0),
1717 column_align: ColumnAlign::Auto,
1718 column_align_header: None,
1719 column_align_body: None,
1720 loose_last_column: false,
1721 aligned_delimiter: false,
1722 pad_width: None,
1723 };
1724 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1725
1726 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1731
1732 let fixed = rule.fix(&ctx).unwrap();
1733
1734 let lines: Vec<&str> = fixed.lines().collect();
1736 assert_eq!(lines[0].len(), lines[1].len());
1737 assert_eq!(lines[1].len(), lines[2].len());
1738 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1742 enabled: true,
1743 style: "aligned".to_string(),
1744 max_width: LineLength::from_const(24),
1745 column_align: ColumnAlign::Auto,
1746 column_align_header: None,
1747 column_align_body: None,
1748 loose_last_column: false,
1749 aligned_delimiter: false,
1750 pad_width: None,
1751 };
1752 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1753
1754 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1755
1756 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1758 assert!(fixed_compact.contains("| --- | --- | --- |"));
1759 }
1760
1761 #[test]
1762 fn test_md060_very_wide_table_auto_compacts() {
1763 let config = MD060Config {
1764 enabled: true,
1765 style: "aligned".to_string(),
1766 max_width: LineLength::from_const(0),
1767 column_align: ColumnAlign::Auto,
1768 column_align_header: None,
1769 column_align_body: None,
1770 loose_last_column: false,
1771 aligned_delimiter: false,
1772 pad_width: None,
1773 };
1774 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1775
1776 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 |";
1780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781
1782 let fixed = rule.fix(&ctx).unwrap();
1783
1784 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1786 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1787 }
1788
1789 #[test]
1790 fn test_md060_inherit_from_md013_line_length() {
1791 let config = MD060Config {
1793 enabled: true,
1794 style: "aligned".to_string(),
1795 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1797 column_align_header: None,
1798 column_align_body: None,
1799 loose_last_column: false,
1800 aligned_delimiter: false,
1801 pad_width: None,
1802 };
1803
1804 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1806 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1807
1808 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811
1812 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1814
1815 let fixed_120 = rule_120.fix(&ctx).unwrap();
1817
1818 let lines_120: Vec<&str> = fixed_120.lines().collect();
1820 assert_eq!(lines_120[0].len(), lines_120[1].len());
1821 assert_eq!(lines_120[1].len(), lines_120[2].len());
1822 }
1823
1824 #[test]
1825 fn test_md060_edge_case_exactly_at_threshold() {
1826 let config = MD060Config {
1830 enabled: true,
1831 style: "aligned".to_string(),
1832 max_width: LineLength::from_const(17),
1833 column_align: ColumnAlign::Auto,
1834 column_align_header: None,
1835 column_align_body: None,
1836 loose_last_column: false,
1837 aligned_delimiter: false,
1838 pad_width: None,
1839 };
1840 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1841
1842 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1844
1845 let fixed = rule.fix(&ctx).unwrap();
1846
1847 let lines: Vec<&str> = fixed.lines().collect();
1849 assert_eq!(lines[0].len(), 17);
1850 assert_eq!(lines[0].len(), lines[1].len());
1851 assert_eq!(lines[1].len(), lines[2].len());
1852
1853 let config_under = MD060Config {
1855 enabled: true,
1856 style: "aligned".to_string(),
1857 max_width: LineLength::from_const(16),
1858 column_align: ColumnAlign::Auto,
1859 column_align_header: None,
1860 column_align_body: None,
1861 loose_last_column: false,
1862 aligned_delimiter: false,
1863 pad_width: None,
1864 };
1865 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1866
1867 let fixed_compact = rule_under.fix(&ctx).unwrap();
1868
1869 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1871 assert!(fixed_compact.contains("| --- | --- |"));
1872 }
1873
1874 #[test]
1875 fn test_md060_auto_compact_warning_message() {
1876 let config = MD060Config {
1878 enabled: true,
1879 style: "aligned".to_string(),
1880 max_width: LineLength::from_const(50),
1881 column_align: ColumnAlign::Auto,
1882 column_align_header: None,
1883 column_align_body: None,
1884 loose_last_column: false,
1885 aligned_delimiter: false,
1886 pad_width: None,
1887 };
1888 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1889
1890 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893
1894 let warnings = rule.check(&ctx).unwrap();
1895
1896 assert!(!warnings.is_empty(), "Should generate warnings");
1898
1899 let auto_compact_warnings: Vec<_> = warnings
1900 .iter()
1901 .filter(|w| w.message.contains("too wide for aligned formatting"))
1902 .collect();
1903
1904 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1905
1906 let first_warning = auto_compact_warnings[0];
1908 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1909 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1910 }
1911
1912 #[test]
1913 fn test_md060_issue_129_detect_style_from_all_rows() {
1914 let rule = MD060TableFormat::new(true, "any".to_string());
1918
1919 let content = "| a long heading | another long heading |\n\
1921 | -------------- | -------------------- |\n\
1922 | a | 1 |\n\
1923 | b b | 2 |\n\
1924 | c c c | 3 |";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926
1927 let fixed = rule.fix(&ctx).unwrap();
1928
1929 assert!(
1931 fixed.contains("| a | 1 |"),
1932 "Should preserve aligned padding in first content row"
1933 );
1934 assert!(
1935 fixed.contains("| b b | 2 |"),
1936 "Should preserve aligned padding in second content row"
1937 );
1938 assert!(
1939 fixed.contains("| c c c | 3 |"),
1940 "Should preserve aligned padding in third content row"
1941 );
1942
1943 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1945 }
1946
1947 #[test]
1948 fn test_md060_regular_alignment_warning_message() {
1949 let config = MD060Config {
1951 enabled: true,
1952 style: "aligned".to_string(),
1953 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1955 column_align_header: None,
1956 column_align_body: None,
1957 loose_last_column: false,
1958 aligned_delimiter: false,
1959 pad_width: None,
1960 };
1961 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1962
1963 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1966
1967 let warnings = rule.check(&ctx).unwrap();
1968
1969 assert!(!warnings.is_empty(), "Should generate warnings");
1971
1972 assert!(warnings[0].message.contains("Table columns should be aligned"));
1974 assert!(!warnings[0].message.contains("too wide"));
1975 assert!(!warnings[0].message.contains("max-width"));
1976 }
1977
1978 #[test]
1981 fn test_md060_unlimited_when_md013_disabled() {
1982 let config = MD060Config {
1984 enabled: true,
1985 style: "aligned".to_string(),
1986 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1988 column_align_header: None,
1989 column_align_body: None,
1990 loose_last_column: false,
1991 aligned_delimiter: false,
1992 pad_width: None,
1993 };
1994 let md013_config = MD013Config::default();
1995 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1996
1997 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000 let fixed = rule.fix(&ctx).unwrap();
2001
2002 let lines: Vec<&str> = fixed.lines().collect();
2004 assert_eq!(
2006 lines[0].len(),
2007 lines[1].len(),
2008 "Table should be aligned when MD013 is disabled"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_md060_unlimited_when_md013_tables_false() {
2014 let config = MD060Config {
2016 enabled: true,
2017 style: "aligned".to_string(),
2018 max_width: LineLength::from_const(0),
2019 column_align: ColumnAlign::Auto,
2020 column_align_header: None,
2021 column_align_body: None,
2022 loose_last_column: false,
2023 aligned_delimiter: false,
2024 pad_width: None,
2025 };
2026 let md013_config = MD013Config {
2027 tables: false, line_length: LineLength::from_const(80),
2029 ..Default::default()
2030 };
2031 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2032
2033 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
2035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2036 let fixed = rule.fix(&ctx).unwrap();
2037
2038 let lines: Vec<&str> = fixed.lines().collect();
2040 assert_eq!(
2041 lines[0].len(),
2042 lines[1].len(),
2043 "Table should be aligned when MD013.tables=false"
2044 );
2045 }
2046
2047 #[test]
2048 fn test_md060_unlimited_when_md013_line_length_zero() {
2049 let config = MD060Config {
2051 enabled: true,
2052 style: "aligned".to_string(),
2053 max_width: LineLength::from_const(0),
2054 column_align: ColumnAlign::Auto,
2055 column_align_header: None,
2056 column_align_body: None,
2057 loose_last_column: false,
2058 aligned_delimiter: false,
2059 pad_width: None,
2060 };
2061 let md013_config = MD013Config {
2062 tables: true,
2063 line_length: LineLength::from_const(0), ..Default::default()
2065 };
2066 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2067
2068 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
2070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2071 let fixed = rule.fix(&ctx).unwrap();
2072
2073 let lines: Vec<&str> = fixed.lines().collect();
2075 assert_eq!(
2076 lines[0].len(),
2077 lines[1].len(),
2078 "Table should be aligned when MD013.line_length=0"
2079 );
2080 }
2081
2082 #[test]
2083 fn test_md060_explicit_max_width_overrides_md013_settings() {
2084 let config = MD060Config {
2086 enabled: true,
2087 style: "aligned".to_string(),
2088 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
2090 column_align_header: None,
2091 column_align_body: None,
2092 loose_last_column: false,
2093 aligned_delimiter: false,
2094 pad_width: None,
2095 };
2096 let md013_config = MD013Config {
2097 tables: false, line_length: LineLength::from_const(0), ..Default::default()
2100 };
2101 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2102
2103 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106 let fixed = rule.fix(&ctx).unwrap();
2107
2108 assert!(
2110 fixed.contains("| --- |"),
2111 "Should be compact format due to explicit max_width"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
2117 let config = MD060Config {
2119 enabled: true,
2120 style: "aligned".to_string(),
2121 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
2123 column_align_header: None,
2124 column_align_body: None,
2125 loose_last_column: false,
2126 aligned_delimiter: false,
2127 pad_width: None,
2128 };
2129 let md013_config = MD013Config {
2130 tables: true,
2131 line_length: LineLength::from_const(50), ..Default::default()
2133 };
2134 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2135
2136 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2139 let fixed = rule.fix(&ctx).unwrap();
2140
2141 assert!(
2143 fixed.contains("| --- |"),
2144 "Should be compact format when inheriting MD013 limit"
2145 );
2146 }
2147
2148 #[test]
2151 fn test_aligned_no_space_reformats_spaced_delimiter() {
2152 let config = MD060Config {
2155 enabled: true,
2156 style: "aligned-no-space".to_string(),
2157 max_width: LineLength::from_const(0),
2158 column_align: ColumnAlign::Auto,
2159 column_align_header: None,
2160 column_align_body: None,
2161 loose_last_column: false,
2162 aligned_delimiter: false,
2163 pad_width: None,
2164 };
2165 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2166
2167 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2170 let fixed = rule.fix(&ctx).unwrap();
2171
2172 assert!(
2175 !fixed.contains("| ----"),
2176 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2177 );
2178 assert!(
2179 !fixed.contains("---- |"),
2180 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2181 );
2182 assert!(
2184 fixed.contains("|----"),
2185 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_aligned_reformats_compact_delimiter() {
2191 let config = MD060Config {
2194 enabled: true,
2195 style: "aligned".to_string(),
2196 max_width: LineLength::from_const(0),
2197 column_align: ColumnAlign::Auto,
2198 column_align_header: None,
2199 column_align_body: None,
2200 loose_last_column: false,
2201 aligned_delimiter: false,
2202 pad_width: None,
2203 };
2204 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2205
2206 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209 let fixed = rule.fix(&ctx).unwrap();
2210
2211 assert!(
2213 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2214 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2215 );
2216 }
2217
2218 #[test]
2219 fn test_aligned_no_space_preserves_matching_table() {
2220 let config = MD060Config {
2222 enabled: true,
2223 style: "aligned-no-space".to_string(),
2224 max_width: LineLength::from_const(0),
2225 column_align: ColumnAlign::Auto,
2226 column_align_header: None,
2227 column_align_body: None,
2228 loose_last_column: false,
2229 aligned_delimiter: false,
2230 pad_width: None,
2231 };
2232 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2233
2234 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2237 let fixed = rule.fix(&ctx).unwrap();
2238
2239 assert_eq!(
2241 fixed, content,
2242 "Table already in aligned-no-space style should be preserved"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_aligned_preserves_matching_table() {
2248 let config = MD060Config {
2250 enabled: true,
2251 style: "aligned".to_string(),
2252 max_width: LineLength::from_const(0),
2253 column_align: ColumnAlign::Auto,
2254 column_align_header: None,
2255 column_align_body: None,
2256 loose_last_column: false,
2257 aligned_delimiter: false,
2258 pad_width: None,
2259 };
2260 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2261
2262 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2265 let fixed = rule.fix(&ctx).unwrap();
2266
2267 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2269 }
2270
2271 #[test]
2272 fn test_cjk_table_display_width_consistency() {
2273 let table_lines = vec!["| 名前 | Age |", "|------|-----|", "| 田中 | 25 |"];
2279
2280 let is_aligned = MD060TableFormat::is_table_already_aligned(
2282 &table_lines,
2283 crate::config::MarkdownFlavor::Standard,
2284 false,
2285 None,
2286 );
2287 assert!(
2288 !is_aligned,
2289 "Table with uneven raw line lengths should NOT be considered aligned"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_cjk_width_calculation_in_aligned_check() {
2295 let cjk_width = MD060TableFormat::calculate_cell_display_width("名前");
2298 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2299
2300 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2301 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2302
2303 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" 名前 ");
2305 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2306
2307 let mixed = MD060TableFormat::calculate_cell_display_width(" 日本語ABC ");
2309 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2311 }
2312
2313 #[test]
2316 fn test_md060_column_align_left() {
2317 let config = MD060Config {
2319 enabled: true,
2320 style: "aligned".to_string(),
2321 max_width: LineLength::from_const(0),
2322 column_align: ColumnAlign::Left,
2323 column_align_header: None,
2324 column_align_body: None,
2325 loose_last_column: false,
2326 aligned_delimiter: false,
2327 pad_width: None,
2328 };
2329 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2330
2331 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2333
2334 let fixed = rule.fix(&ctx).unwrap();
2335 let lines: Vec<&str> = fixed.lines().collect();
2336
2337 assert!(
2339 lines[2].contains("| Alice "),
2340 "Content should be left-aligned (Alice should have trailing padding)"
2341 );
2342 assert!(
2343 lines[3].contains("| Bob "),
2344 "Content should be left-aligned (Bob should have trailing padding)"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_md060_column_align_center() {
2350 let config = MD060Config {
2352 enabled: true,
2353 style: "aligned".to_string(),
2354 max_width: LineLength::from_const(0),
2355 column_align: ColumnAlign::Center,
2356 column_align_header: None,
2357 column_align_body: None,
2358 loose_last_column: false,
2359 aligned_delimiter: false,
2360 pad_width: None,
2361 };
2362 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2363
2364 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2366
2367 let fixed = rule.fix(&ctx).unwrap();
2368 let lines: Vec<&str> = fixed.lines().collect();
2369
2370 assert!(
2373 lines[3].contains("| Bob |"),
2374 "Bob should be centered with padding on both sides. Got: {}",
2375 lines[3]
2376 );
2377 }
2378
2379 #[test]
2380 fn test_md060_column_align_right() {
2381 let config = MD060Config {
2383 enabled: true,
2384 style: "aligned".to_string(),
2385 max_width: LineLength::from_const(0),
2386 column_align: ColumnAlign::Right,
2387 column_align_header: None,
2388 column_align_body: None,
2389 loose_last_column: false,
2390 aligned_delimiter: false,
2391 pad_width: None,
2392 };
2393 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2394
2395 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397
2398 let fixed = rule.fix(&ctx).unwrap();
2399 let lines: Vec<&str> = fixed.lines().collect();
2400
2401 assert!(
2403 lines[3].contains("| Bob |"),
2404 "Bob should be right-aligned with padding on left. Got: {}",
2405 lines[3]
2406 );
2407 }
2408
2409 #[test]
2410 fn test_md060_column_align_auto_respects_delimiter() {
2411 let config = MD060Config {
2413 enabled: true,
2414 style: "aligned".to_string(),
2415 max_width: LineLength::from_const(0),
2416 column_align: ColumnAlign::Auto,
2417 column_align_header: None,
2418 column_align_body: None,
2419 loose_last_column: false,
2420 aligned_delimiter: false,
2421 pad_width: None,
2422 };
2423 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2424
2425 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2428
2429 let fixed = rule.fix(&ctx).unwrap();
2430
2431 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2433 let lines: Vec<&str> = fixed.lines().collect();
2435 assert!(
2439 lines[2].contains(" C |"),
2440 "Right column should be right-aligned. Got: {}",
2441 lines[2]
2442 );
2443 }
2444
2445 #[test]
2446 fn test_md060_column_align_overrides_delimiter_indicators() {
2447 let config = MD060Config {
2449 enabled: true,
2450 style: "aligned".to_string(),
2451 max_width: LineLength::from_const(0),
2452 column_align: ColumnAlign::Right, column_align_header: None,
2454 column_align_body: None,
2455 loose_last_column: false,
2456 aligned_delimiter: false,
2457 pad_width: None,
2458 };
2459 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2460
2461 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2464
2465 let fixed = rule.fix(&ctx).unwrap();
2466 let lines: Vec<&str> = fixed.lines().collect();
2467
2468 assert!(
2471 lines[2].contains(" A |") || lines[2].contains(" A |"),
2472 "Even left-indicated column should be right-aligned. Got: {}",
2473 lines[2]
2474 );
2475 }
2476
2477 #[test]
2478 fn test_md060_column_align_with_aligned_no_space() {
2479 let config = MD060Config {
2481 enabled: true,
2482 style: "aligned-no-space".to_string(),
2483 max_width: LineLength::from_const(0),
2484 column_align: ColumnAlign::Center,
2485 column_align_header: None,
2486 column_align_body: None,
2487 loose_last_column: false,
2488 aligned_delimiter: false,
2489 pad_width: None,
2490 };
2491 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2492
2493 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2495
2496 let fixed = rule.fix(&ctx).unwrap();
2497 let lines: Vec<&str> = fixed.lines().collect();
2498
2499 assert!(
2501 lines[1].contains("|---"),
2502 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2503 lines[1]
2504 );
2505 assert!(
2507 lines[3].contains("| Bob |"),
2508 "Content should be centered. Got: {}",
2509 lines[3]
2510 );
2511 }
2512
2513 #[test]
2514 fn test_md060_column_align_config_parsing() {
2515 let toml_str = r#"
2517enabled = true
2518style = "aligned"
2519column-align = "center"
2520"#;
2521 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2522 assert_eq!(config.column_align, ColumnAlign::Center);
2523
2524 let toml_str = r#"
2525enabled = true
2526style = "aligned"
2527column-align = "right"
2528"#;
2529 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2530 assert_eq!(config.column_align, ColumnAlign::Right);
2531
2532 let toml_str = r#"
2533enabled = true
2534style = "aligned"
2535column-align = "left"
2536"#;
2537 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2538 assert_eq!(config.column_align, ColumnAlign::Left);
2539
2540 let toml_str = r#"
2541enabled = true
2542style = "aligned"
2543column-align = "auto"
2544"#;
2545 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2546 assert_eq!(config.column_align, ColumnAlign::Auto);
2547 }
2548
2549 #[test]
2550 fn test_md060_column_align_default_is_auto() {
2551 let toml_str = r#"
2553enabled = true
2554style = "aligned"
2555"#;
2556 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2557 assert_eq!(config.column_align, ColumnAlign::Auto);
2558 }
2559
2560 #[test]
2561 fn test_md060_column_align_reformats_already_aligned_table() {
2562 let config = MD060Config {
2564 enabled: true,
2565 style: "aligned".to_string(),
2566 max_width: LineLength::from_const(0),
2567 column_align: ColumnAlign::Right,
2568 column_align_header: None,
2569 column_align_body: None,
2570 loose_last_column: false,
2571 aligned_delimiter: false,
2572 pad_width: None,
2573 };
2574 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2575
2576 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2579
2580 let fixed = rule.fix(&ctx).unwrap();
2581 let lines: Vec<&str> = fixed.lines().collect();
2582
2583 assert!(
2585 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2586 "Already aligned table should be reformatted with right alignment. Got: {}",
2587 lines[2]
2588 );
2589 assert!(
2590 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2591 "Bob should be right-aligned. Got: {}",
2592 lines[3]
2593 );
2594 }
2595
2596 #[test]
2597 fn test_md060_column_align_with_cjk_characters() {
2598 let config = MD060Config {
2600 enabled: true,
2601 style: "aligned".to_string(),
2602 max_width: LineLength::from_const(0),
2603 column_align: ColumnAlign::Center,
2604 column_align_header: None,
2605 column_align_body: None,
2606 loose_last_column: false,
2607 aligned_delimiter: false,
2608 pad_width: None,
2609 };
2610 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2611
2612 let content = "| Name | City |\n|---|---|\n| Alice | 東京 |\n| Bob | LA |";
2613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614
2615 let fixed = rule.fix(&ctx).unwrap();
2616
2617 assert!(fixed.contains("Bob"), "Table should contain Bob");
2620 assert!(fixed.contains("東京"), "Table should contain 東京");
2621 }
2622
2623 #[test]
2624 fn test_md060_column_align_ignored_for_compact_style() {
2625 let config = MD060Config {
2627 enabled: true,
2628 style: "compact".to_string(),
2629 max_width: LineLength::from_const(0),
2630 column_align: ColumnAlign::Right, column_align_header: None,
2632 column_align_body: None,
2633 loose_last_column: false,
2634 aligned_delimiter: false,
2635 pad_width: None,
2636 };
2637 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2638
2639 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2641
2642 let fixed = rule.fix(&ctx).unwrap();
2643
2644 assert!(
2646 fixed.contains("| Alice |"),
2647 "Compact style should have single space padding, not alignment. Got: {fixed}"
2648 );
2649 }
2650
2651 #[test]
2652 fn test_md060_column_align_ignored_for_tight_style() {
2653 let config = MD060Config {
2655 enabled: true,
2656 style: "tight".to_string(),
2657 max_width: LineLength::from_const(0),
2658 column_align: ColumnAlign::Center, column_align_header: None,
2660 column_align_body: None,
2661 loose_last_column: false,
2662 aligned_delimiter: false,
2663 pad_width: None,
2664 };
2665 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2666
2667 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2669
2670 let fixed = rule.fix(&ctx).unwrap();
2671
2672 assert!(
2674 fixed.contains("|Alice|"),
2675 "Tight style should have no spaces. Got: {fixed}"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_md060_column_align_with_empty_cells() {
2681 let config = MD060Config {
2683 enabled: true,
2684 style: "aligned".to_string(),
2685 max_width: LineLength::from_const(0),
2686 column_align: ColumnAlign::Center,
2687 column_align_header: None,
2688 column_align_body: None,
2689 loose_last_column: false,
2690 aligned_delimiter: false,
2691 pad_width: None,
2692 };
2693 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2694
2695 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2697
2698 let fixed = rule.fix(&ctx).unwrap();
2699 let lines: Vec<&str> = fixed.lines().collect();
2700
2701 assert!(
2703 lines[3].contains("| |") || lines[3].contains("| |"),
2704 "Empty cell should be padded correctly. Got: {}",
2705 lines[3]
2706 );
2707 }
2708
2709 #[test]
2710 fn test_md060_column_align_auto_preserves_already_aligned() {
2711 let config = MD060Config {
2713 enabled: true,
2714 style: "aligned".to_string(),
2715 max_width: LineLength::from_const(0),
2716 column_align: ColumnAlign::Auto,
2717 column_align_header: None,
2718 column_align_body: None,
2719 loose_last_column: false,
2720 aligned_delimiter: false,
2721 pad_width: None,
2722 };
2723 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2724
2725 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_eq!(
2733 fixed, content,
2734 "Already aligned table should be preserved with column-align=auto"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_cjk_table_display_aligned_not_flagged() {
2740 use crate::config::MarkdownFlavor;
2744
2745 let table_lines: Vec<&str> = vec![
2747 "| Header | Name |",
2748 "| ------ | ---- |",
2749 "| Hello | Test |",
2750 "| 你好 | Test |",
2751 ];
2752
2753 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false, None);
2754 assert!(
2755 result,
2756 "Table with CJK characters that is display-aligned should be recognized as aligned"
2757 );
2758 }
2759
2760 #[test]
2761 fn test_cjk_table_not_reformatted_when_aligned() {
2762 let rule = MD060TableFormat::new(true, "aligned".to_string());
2764 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| 你好 | Test |\n";
2766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767
2768 let fixed = rule.fix(&ctx).unwrap();
2770 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2771 }
2772
2773 #[test]
2789 fn md060_pandoc_grid_tables_not_flagged() {
2790 let rule = MD060TableFormat::new(true, "aligned".to_string());
2791 let content = "\
2792+---+---+
2793| a | b |
2794+===+===+
2795| 1 | 2 |
2796+---+---+
2797";
2798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2801 let result = rule.check(&ctx).unwrap();
2802 assert!(
2803 result.is_empty(),
2804 "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2805 );
2806
2807 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808 let result_std = rule.check(&ctx_std).unwrap();
2809 assert!(
2810 result_std.is_empty(),
2811 "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2812 );
2813 }
2814
2815 #[test]
2816 fn md060_pandoc_multi_line_tables_not_flagged() {
2817 let rule = MD060TableFormat::new(true, "aligned".to_string());
2818 let content = "\
2819--------- -----------
2820Header 1 Header 2
2821--------- -----------
2822Cell 1 Cell 2
2823--------- -----------
2824";
2825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2826 let result = rule.check(&ctx).unwrap();
2827 assert!(
2828 result.is_empty(),
2829 "MD060 should not flag Pandoc multi-line tables: {result:?}"
2830 );
2831
2832 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2833 let result_std = rule.check(&ctx_std).unwrap();
2834 assert!(
2835 result_std.is_empty(),
2836 "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2837 );
2838 }
2839
2840 #[test]
2841 fn md060_pandoc_line_blocks_not_flagged() {
2842 let rule = MD060TableFormat::new(true, "aligned".to_string());
2843 let content = "| First line\n| Second line\n";
2845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2846 let result = rule.check(&ctx).unwrap();
2847 assert!(
2848 result.is_empty(),
2849 "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2850 );
2851
2852 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2853 let result_std = rule.check(&ctx_std).unwrap();
2854 assert!(
2855 result_std.is_empty(),
2856 "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2857 );
2858 }
2859
2860 #[test]
2861 fn md060_pandoc_pipe_table_captions_not_flagged() {
2862 let rule = MD060TableFormat::new(true, "aligned".to_string());
2863 let content = "\
2866| H1 | H2 |
2867| -- | -- |
2868| a | b |
2869
2870: My table caption
2871";
2872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2873 let result = rule.check(&ctx).unwrap();
2874 assert!(
2875 result.is_empty(),
2876 "MD060 should not flag the pipe-table caption line: {result:?}"
2877 );
2878
2879 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2880 let result_std = rule.check(&ctx_std).unwrap();
2881 assert!(
2882 result_std.is_empty(),
2883 "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2884 );
2885 }
2886
2887 #[test]
2888 fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2889 let rule = MD060TableFormat::new(true, "aligned".to_string());
2893
2894 for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
2896 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2897 assert_eq!(
2898 rule.fix(&ctx).unwrap(),
2899 input,
2900 "MD060 must not alter table-free content: {input:?}"
2901 );
2902 }
2903
2904 let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
2907 let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
2908 let once = rule.fix(&ctx).unwrap();
2909 assert!(
2910 once.ends_with("\n\n\n"),
2911 "trailing blank lines must be preserved, got: {once:?}"
2912 );
2913 let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
2914 let twice = rule.fix(&ctx2).unwrap();
2915 assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
2916 }
2917
2918 fn indent_block(block: &str, indent: usize) -> String {
2920 let spaces = " ".repeat(indent);
2921 block.lines().fold(String::new(), |mut indented, line| {
2922 indented.push_str(&spaces);
2923 indented.push_str(line);
2924 indented.push('\n');
2925 indented
2926 })
2927 }
2928
2929 #[test]
2930 fn test_mdg_table_indent_stays_within_enclosing_list_item() {
2931 let rule = MD060TableFormat::new(true, "aligned".to_string());
2936 let written = "| name | price |\n|---|---|\n| a | b |\n";
2937 let formatted = "| name | price |\n| ---- | ----- |\n| a | b |\n";
2938
2939 let cases = [
2941 ("* Given", 2, 2),
2942 ("* Given", 5, 2),
2943 ("1. Given", 3, 3),
2944 ("1. Given", 5, 3),
2945 ("* Given\n * Nested", 4, 4),
2946 ("* Given\n * Nested", 5, 4),
2947 ];
2948
2949 for (item, indent, expected_indent) in cases {
2950 let input = format!("# Feature: F\n\n{item}\n\n{}", indent_block(written, indent));
2951 let expected = format!("# Feature: F\n\n{item}\n\n{}", indent_block(formatted, expected_indent));
2952 let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::MDG, None);
2953
2954 assert_eq!(
2955 rule.fix(&ctx).unwrap(),
2956 expected,
2957 "a {indent}-space table under {item:?} belongs at {expected_indent} spaces"
2958 );
2959
2960 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDG, None);
2961 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2962 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected, "MD060 fix must be idempotent");
2963 }
2964 }
2965
2966 #[test]
2967 fn test_mdg_table_below_a_deep_list_item_keeps_list_indentation() {
2968 let rule = MD060TableFormat::new(true, "aligned".to_string());
2972 let input = "# Feature: F\n\n* A\n * B\n * C\n\n | name | price |\n |---|---|\n | a | b |\n";
2973 let expected = "# Feature: F\n\n* A\n * B\n * C\n\n | name | price |\n | ---- | ----- |\n | a | b |\n";
2974
2975 let mdg = rule
2976 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
2977 .unwrap();
2978 assert_eq!(mdg, expected);
2979 assert_eq!(
2980 mdg,
2981 rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
2982 .unwrap()
2983 );
2984 }
2985
2986 #[test]
2987 fn test_mdg_tab_indented_table_is_normalized_not_flattened() {
2988 let rule = MD060TableFormat::new(true, "aligned".to_string());
2992 let input = "# Feature: F\n\n#### Examples:\n\n\t\t| a | bb |\n\t\t|---|---|\n\t\t| 1 | 2 |\n";
2993 let expected = "# Feature: F\n\n#### Examples:\n\n | a | bb |\n | --- | --- |\n | 1 | 2 |\n";
2994
2995 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::MDG, None);
2996 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2997
2998 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::MDG, None);
2999 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
3000
3001 let standard = rule
3002 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3003 .unwrap();
3004 assert!(
3005 standard.lines().any(|line| line.starts_with("| a")),
3006 "tab handling is Gherkin-only, got: {standard:?}"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_mdg_table_row_needs_a_pipe_behind_its_indent() {
3012 let rule = MD060TableFormat::new(true, "aligned".to_string());
3017
3018 for input in [
3019 "# Feature: F\n\n > | a | b |\n > |---|---|\n > | 1 | 2 |\n",
3020 "# Feature: F\n\n - | a | b |\n |---|---|\n | 1 | 2 |\n",
3021 ] {
3022 let mdg = rule
3023 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
3024 .unwrap();
3025 assert_eq!(
3026 mdg,
3027 rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
3028 .unwrap(),
3029 "{input:?} is not a Gherkin table, so both flavors must agree"
3030 );
3031
3032 let fixed_ctx = LintContext::new(&mdg, crate::config::MarkdownFlavor::MDG, None);
3033 assert_eq!(rule.fix(&fixed_ctx).unwrap(), mdg, "MD060 fix must converge");
3034 }
3035 }
3036}