1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
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 column_align: ColumnAlign,
46 column_align_header: Option<ColumnAlign>,
48 column_align_body: Option<ColumnAlign>,
50}
51
52#[derive(Debug, Clone, Default)]
175pub struct MD060TableFormat {
176 config: MD060Config,
177 md013_config: MD013Config,
178 md013_disabled: bool,
179}
180
181impl MD060TableFormat {
182 pub fn new(enabled: bool, style: String) -> Self {
183 use crate::types::LineLength;
184 Self {
185 config: MD060Config {
186 enabled,
187 style,
188 max_width: LineLength::from_const(0),
189 column_align: ColumnAlign::Auto,
190 column_align_header: None,
191 column_align_body: None,
192 loose_last_column: false,
193 aligned_delimiter: false,
194 },
195 md013_config: MD013Config::default(),
196 md013_disabled: false,
197 }
198 }
199
200 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
201 Self {
202 config,
203 md013_config,
204 md013_disabled,
205 }
206 }
207
208 fn effective_max_width(&self) -> usize {
218 if !self.config.max_width.is_unlimited() {
220 return self.config.max_width.get();
221 }
222
223 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
228 return usize::MAX; }
230
231 self.md013_config.line_length.get()
233 }
234
235 fn contains_problematic_chars(text: &str) -> bool {
246 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
251
252 fn calculate_cell_display_width(cell_content: &str) -> usize {
253 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
254 masked.trim().width()
255 }
256
257 #[cfg(test)]
260 fn parse_table_row(line: &str) -> Vec<String> {
261 TableUtils::split_table_row(line)
262 }
263
264 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
268 TableUtils::split_table_row_with_flavor(line, flavor)
269 }
270
271 fn is_delimiter_row(row: &[String]) -> bool {
272 if row.is_empty() {
273 return false;
274 }
275 row.iter().all(|cell| {
276 let trimmed = cell.trim();
277 !trimmed.is_empty()
280 && trimmed.contains('-')
281 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
282 })
283 }
284
285 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
288 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
289 (&line[..m.end()], &line[m.end()..])
290 } else {
291 ("", line)
292 }
293 }
294
295 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
296 delimiter_row
297 .iter()
298 .map(|cell| {
299 let trimmed = cell.trim();
300 let has_left_colon = trimmed.starts_with(':');
301 let has_right_colon = trimmed.ends_with(':');
302
303 match (has_left_colon, has_right_colon) {
304 (true, true) => ColumnAlignment::Center,
305 (false, true) => ColumnAlignment::Right,
306 _ => ColumnAlignment::Left,
307 }
308 })
309 .collect()
310 }
311
312 fn calculate_column_widths(
313 table_lines: &[&str],
314 flavor: crate::config::MarkdownFlavor,
315 loose_last_column: bool,
316 ) -> Vec<usize> {
317 let mut column_widths = Vec::new();
318 let mut delimiter_cells: Option<Vec<String>> = None;
319 let mut is_header = true;
320 let mut header_last_col_width: Option<usize> = None;
321
322 for line in table_lines {
323 let cells = Self::parse_table_row_with_flavor(line, flavor);
324
325 if Self::is_delimiter_row(&cells) {
327 delimiter_cells = Some(cells);
328 is_header = false;
329 continue;
330 }
331
332 for (i, cell) in cells.iter().enumerate() {
333 let width = Self::calculate_cell_display_width(cell);
334 if i >= column_widths.len() {
335 column_widths.push(width);
336 } else {
337 column_widths[i] = column_widths[i].max(width);
338 }
339 }
340
341 if is_header && !cells.is_empty() {
343 let last_idx = cells.len() - 1;
344 header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
345 is_header = false;
346 }
347 }
348
349 if loose_last_column
351 && let Some(header_width) = header_last_col_width
352 && let Some(last) = column_widths.last_mut()
353 {
354 *last = header_width;
355 }
356
357 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
360
361 if let Some(delimiter_cells) = delimiter_cells {
364 for (i, cell) in delimiter_cells.iter().enumerate() {
365 if i < final_widths.len() {
366 let trimmed = cell.trim();
367 let has_left_colon = trimmed.starts_with(':');
368 let has_right_colon = trimmed.ends_with(':');
369 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
370
371 let min_width_for_delimiter = 3 + colon_count;
373 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
374 }
375 }
376 }
377
378 final_widths
379 }
380
381 fn format_table_row(
382 cells: &[String],
383 column_widths: &[usize],
384 column_alignments: &[ColumnAlignment],
385 options: &RowFormatOptions,
386 ) -> String {
387 let formatted_cells: Vec<String> = cells
388 .iter()
389 .enumerate()
390 .map(|(i, cell)| {
391 let target_width = column_widths.get(i).copied().unwrap_or(0);
392
393 match options.row_type {
394 RowType::Delimiter => {
395 let trimmed = cell.trim();
396 let has_left_colon = trimmed.starts_with(':');
397 let has_right_colon = trimmed.ends_with(':');
398
399 let extra_width = if options.compact_delimiter { 2 } else { 0 };
403 let dash_count = if has_left_colon && has_right_colon {
404 (target_width + extra_width).saturating_sub(2)
405 } else if has_left_colon || has_right_colon {
406 (target_width + extra_width).saturating_sub(1)
407 } else {
408 target_width + extra_width
409 };
410
411 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
413 format!(":{dashes}:")
414 } else if has_left_colon {
415 format!(":{dashes}")
416 } else if has_right_colon {
417 format!("{dashes}:")
418 } else {
419 dashes
420 };
421
422 if options.compact_delimiter {
424 delimiter_content
425 } else {
426 format!(" {delimiter_content} ")
427 }
428 }
429 RowType::Header | RowType::Body => {
430 let trimmed = cell.trim();
431 let current_width = Self::calculate_cell_display_width(cell);
432 let padding = target_width.saturating_sub(current_width);
433
434 let effective_align = match options.row_type {
436 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
437 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
438 RowType::Delimiter => unreachable!(),
439 };
440
441 let alignment = match effective_align {
443 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
444 ColumnAlign::Left => ColumnAlignment::Left,
445 ColumnAlign::Center => ColumnAlignment::Center,
446 ColumnAlign::Right => ColumnAlignment::Right,
447 };
448
449 match alignment {
450 ColumnAlignment::Left => {
451 format!(" {trimmed}{} ", " ".repeat(padding))
453 }
454 ColumnAlignment::Center => {
455 let left_padding = padding / 2;
457 let right_padding = padding - left_padding;
458 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
459 }
460 ColumnAlignment::Right => {
461 format!(" {}{trimmed} ", " ".repeat(padding))
463 }
464 }
465 }
466 }
467 })
468 .collect();
469
470 format!("|{}|", formatted_cells.join("|"))
471 }
472
473 fn format_table_compact(cells: &[String]) -> String {
474 let formatted_cells: Vec<String> = cells
478 .iter()
479 .map(|cell| match cell.trim() {
480 "" => " ".to_string(),
481 trimmed => format!(" {trimmed} "),
482 })
483 .collect();
484 format!("|{}|", formatted_cells.join("|"))
485 }
486
487 fn format_table_tight(cells: &[String]) -> String {
488 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
489 format!("|{}|", formatted_cells.join("|"))
490 }
491
492 fn format_delimiter_aligned_to_header(delim_cells: &[String], header_widths: &[usize], compact: bool) -> String {
501 let formatted_cells: Vec<String> = delim_cells
502 .iter()
503 .enumerate()
504 .map(|(i, cell)| {
505 let target_width = header_widths.get(i).copied().unwrap_or(0);
506 let trimmed = cell.trim();
507 let has_left_colon = trimmed.starts_with(':');
508 let has_right_colon = trimmed.ends_with(':');
509 let colon_count = usize::from(has_left_colon) + usize::from(has_right_colon);
510
511 let dash_count = target_width.saturating_sub(colon_count).max(1);
513 let dashes = "-".repeat(dash_count);
514 let delimiter_content = match (has_left_colon, has_right_colon) {
515 (true, true) => format!(":{dashes}:"),
516 (true, false) => format!(":{dashes}"),
517 (false, true) => format!("{dashes}:"),
518 (false, false) => dashes,
519 };
520 if compact {
521 format!(" {delimiter_content} ")
522 } else {
523 delimiter_content
524 }
525 })
526 .collect();
527
528 format!("|{}|", formatted_cells.join("|"))
529 }
530
531 fn header_cell_widths(header_cells: &[String]) -> Vec<usize> {
534 header_cells
535 .iter()
536 .map(|c| Self::calculate_cell_display_width(c))
537 .collect()
538 }
539
540 fn is_table_already_aligned(
552 table_lines: &[&str],
553 flavor: crate::config::MarkdownFlavor,
554 compact_delimiter: bool,
555 ) -> bool {
556 if table_lines.len() < 2 {
557 return false;
558 }
559
560 let first_width = UnicodeWidthStr::width(table_lines[0]);
564 if !table_lines
565 .iter()
566 .all(|line| UnicodeWidthStr::width(*line) == first_width)
567 {
568 return false;
569 }
570
571 let parsed: Vec<Vec<String>> = table_lines
573 .iter()
574 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
575 .collect();
576
577 if parsed.is_empty() {
578 return false;
579 }
580
581 let num_columns = parsed[0].len();
582 if !parsed.iter().all(|row| row.len() == num_columns) {
583 return false;
584 }
585
586 if let Some(delimiter_row) = parsed.get(1) {
589 if !Self::is_delimiter_row(delimiter_row) {
590 return false;
591 }
592 for cell in delimiter_row {
594 let trimmed = cell.trim();
595 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
596 if dash_count < 1 {
597 return false;
598 }
599 }
600
601 let delimiter_has_spaces = delimiter_row
605 .iter()
606 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
607
608 if compact_delimiter && delimiter_has_spaces {
611 return false;
612 }
613 if !compact_delimiter && !delimiter_has_spaces {
614 return false;
615 }
616 }
617
618 for col_idx in 0..num_columns {
622 let mut widths = Vec::new();
623 for (row_idx, row) in parsed.iter().enumerate() {
624 if row_idx == 1 {
626 continue;
627 }
628 if let Some(cell) = row.get(col_idx) {
629 widths.push(cell.width());
630 }
631 }
632 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
634 return false;
635 }
636 }
637
638 if let Some(delimiter_row) = parsed.get(1) {
643 let alignments = Self::parse_column_alignments(delimiter_row);
644 for (col_idx, alignment) in alignments.iter().enumerate() {
645 if *alignment == ColumnAlignment::Left {
646 continue;
647 }
648 for (row_idx, row) in parsed.iter().enumerate() {
649 if row_idx == 1 {
651 continue;
652 }
653 if let Some(cell) = row.get(col_idx) {
654 if cell.trim().is_empty() {
655 continue;
656 }
657 let left_pad = cell.len() - cell.trim_start().len();
659 let right_pad = cell.len() - cell.trim_end().len();
660
661 match alignment {
662 ColumnAlignment::Center => {
663 if left_pad.abs_diff(right_pad) > 1 {
665 return false;
666 }
667 }
668 ColumnAlignment::Right => {
669 if left_pad < right_pad {
671 return false;
672 }
673 }
674 ColumnAlignment::Left => unreachable!(),
675 }
676 }
677 }
678 }
679 }
680
681 true
682 }
683
684 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
685 if table_lines.is_empty() {
686 return None;
687 }
688
689 let mut is_tight = true;
692 let mut is_compact = true;
693
694 for line in table_lines {
695 let cells = Self::parse_table_row_with_flavor(line, flavor);
696
697 if cells.is_empty() {
698 continue;
699 }
700
701 if Self::is_delimiter_row(&cells) {
703 continue;
704 }
705
706 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
708
709 let row_has_single_space = cells.iter().all(|cell| match cell.trim() {
713 "" => cell == " ",
714 trimmed => cell == &format!(" {trimmed} "),
715 });
716
717 if !row_has_no_padding {
719 is_tight = false;
720 }
721
722 if !row_has_single_space {
724 is_compact = false;
725 }
726
727 if !is_tight && !is_compact {
729 return Some("aligned".to_string());
730 }
731 }
732
733 if is_tight {
735 Some("tight".to_string())
736 } else if is_compact {
737 Some("compact".to_string())
738 } else {
739 Some("aligned".to_string())
740 }
741 }
742
743 fn fix_table_block(
744 &self,
745 lines: &[&str],
746 table_block: &crate::utils::table_utils::TableBlock,
747 flavor: crate::config::MarkdownFlavor,
748 ) -> TableFormatResult {
749 let mut result = Vec::new();
750 let mut auto_compacted = false;
751 let mut aligned_width = None;
752
753 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
754 .chain(std::iter::once(lines[table_block.delimiter_line]))
755 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
756 .collect();
757
758 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
759 return TableFormatResult {
760 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
761 auto_compacted: false,
762 aligned_width: None,
763 };
764 }
765
766 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
769
770 let list_context = &table_block.list_context;
772 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
773 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
774 } else {
775 ("", String::new())
776 };
777
778 let stripped_lines: Vec<&str> = table_lines
780 .iter()
781 .enumerate()
782 .map(|(i, line)| {
783 let after_blockquote = Self::extract_blockquote_prefix(line).1;
784 if list_context.is_some() {
785 if i == 0 {
786 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
788 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
789 })
790 } else {
791 after_blockquote
793 .strip_prefix(&continuation_indent)
794 .unwrap_or(after_blockquote.trim_start())
795 }
796 } else {
797 after_blockquote
798 }
799 })
800 .collect();
801
802 let style = self.config.style.as_str();
803
804 match style {
805 "any" => {
806 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
807 if detected_style.is_none() {
808 return TableFormatResult {
809 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
810 auto_compacted: false,
811 aligned_width: None,
812 };
813 }
814
815 let target_style = detected_style.unwrap();
816
817 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
819 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
820
821 for (row_idx, line) in stripped_lines.iter().enumerate() {
822 let cells = Self::parse_table_row_with_flavor(line, flavor);
823 match target_style.as_str() {
824 "tight" => result.push(Self::format_table_tight(&cells)),
825 "compact" => result.push(Self::format_table_compact(&cells)),
826 _ => {
827 let column_widths =
828 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
829 let row_type = match row_idx {
830 0 => RowType::Header,
831 1 => RowType::Delimiter,
832 _ => RowType::Body,
833 };
834 let options = RowFormatOptions {
835 row_type,
836 compact_delimiter: false,
837 column_align: self.config.column_align,
838 column_align_header: self.config.column_align_header,
839 column_align_body: self.config.column_align_body,
840 };
841 result.push(Self::format_table_row(
842 &cells,
843 &column_widths,
844 &column_alignments,
845 &options,
846 ));
847 }
848 }
849 }
850 }
851 "compact" | "tight" => {
852 let compact = style == "compact";
853 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
854 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
855 Some(Self::header_cell_widths(&header_cells))
856 } else {
857 None
858 };
859
860 for (row_idx, line) in stripped_lines.iter().enumerate() {
861 let cells = Self::parse_table_row_with_flavor(line, flavor);
862 if row_idx == 1
863 && let Some(widths) = &header_widths
864 {
865 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, compact));
866 continue;
867 }
868 result.push(if compact {
869 Self::format_table_compact(&cells)
870 } else {
871 Self::format_table_tight(&cells)
872 });
873 }
874 }
875 "aligned" | "aligned-no-space" => {
876 let compact_delimiter = style == "aligned-no-space";
877
878 let needs_reformat = self.config.column_align != ColumnAlign::Auto
881 || self.config.column_align_header.is_some()
882 || self.config.column_align_body.is_some()
883 || self.config.loose_last_column;
884
885 if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
886 return TableFormatResult {
887 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
888 auto_compacted: false,
889 aligned_width: None,
890 };
891 }
892
893 let column_widths =
894 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
895
896 let num_columns = column_widths.len();
898 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
899 aligned_width = Some(calc_aligned_width);
900
901 if calc_aligned_width > self.effective_max_width() {
906 auto_compacted = true;
907 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
908 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
909 Some(Self::header_cell_widths(&header_cells))
910 } else {
911 None
912 };
913 for (row_idx, line) in stripped_lines.iter().enumerate() {
914 let cells = Self::parse_table_row_with_flavor(line, flavor);
915 if row_idx == 1
916 && let Some(widths) = &header_widths
917 {
918 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, true));
920 continue;
921 }
922 result.push(Self::format_table_compact(&cells));
923 }
924 } else {
925 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
927 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
928
929 for (row_idx, line) in stripped_lines.iter().enumerate() {
930 let cells = Self::parse_table_row_with_flavor(line, flavor);
931 let row_type = match row_idx {
932 0 => RowType::Header,
933 1 => RowType::Delimiter,
934 _ => RowType::Body,
935 };
936 let options = RowFormatOptions {
937 row_type,
938 compact_delimiter,
939 column_align: self.config.column_align,
940 column_align_header: self.config.column_align_header,
941 column_align_body: self.config.column_align_body,
942 };
943 result.push(Self::format_table_row(
944 &cells,
945 &column_widths,
946 &column_alignments,
947 &options,
948 ));
949 }
950 }
951 }
952 _ => {
953 return TableFormatResult {
954 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
955 auto_compacted: false,
956 aligned_width: None,
957 };
958 }
959 }
960
961 let prefixed_result: Vec<String> = result
963 .into_iter()
964 .enumerate()
965 .map(|(i, line)| {
966 if list_context.is_some() {
967 if i == 0 {
968 format!("{blockquote_prefix}{list_prefix}{line}")
970 } else {
971 format!("{blockquote_prefix}{continuation_indent}{line}")
973 }
974 } else {
975 format!("{blockquote_prefix}{line}")
976 }
977 })
978 .collect();
979
980 TableFormatResult {
981 lines: prefixed_result,
982 auto_compacted,
983 aligned_width,
984 }
985 }
986}
987
988impl Rule for MD060TableFormat {
989 fn name(&self) -> &'static str {
990 "MD060"
991 }
992
993 fn description(&self) -> &'static str {
994 "Table columns should be consistently aligned"
995 }
996
997 fn category(&self) -> RuleCategory {
998 RuleCategory::Table
999 }
1000
1001 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1002 !ctx.likely_has_tables()
1003 }
1004
1005 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1006 let line_index = &ctx.line_index;
1007 let mut warnings = Vec::new();
1008
1009 let lines = ctx.raw_lines();
1010 let table_blocks = &ctx.table_blocks;
1011
1012 for table_block in table_blocks {
1013 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1014
1015 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1016 .chain(std::iter::once(table_block.delimiter_line))
1017 .chain(table_block.content_lines.iter().copied())
1018 .collect();
1019
1020 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());
1027 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1028 let fixed_line = &format_result.lines[i];
1029 if line_idx < lines.len() - 1 {
1031 fixed_table_lines.push(format!("{fixed_line}\n"));
1032 } else {
1033 fixed_table_lines.push(fixed_line.clone());
1034 }
1035 }
1036 let table_replacement = fixed_table_lines.concat();
1037 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
1038
1039 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1040 let original = lines[line_idx];
1041 let fixed = &format_result.lines[i];
1042
1043 if original != fixed {
1044 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1045
1046 let message = if format_result.auto_compacted {
1047 if let Some(width) = format_result.aligned_width {
1048 format!(
1049 "Table too wide for aligned formatting ({} chars > max-width: {})",
1050 width,
1051 self.effective_max_width()
1052 )
1053 } else {
1054 "Table too wide for aligned formatting".to_string()
1055 }
1056 } else {
1057 "Table columns should be aligned".to_string()
1058 };
1059
1060 warnings.push(LintWarning {
1063 rule_name: Some(self.name().to_string()),
1064 severity: Severity::Warning,
1065 message,
1066 line: start_line,
1067 column: start_col,
1068 end_line,
1069 end_column: end_col,
1070 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1071 });
1072 }
1073 }
1074 }
1075
1076 Ok(warnings)
1077 }
1078
1079 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1080 let content = ctx.content;
1081 let lines = ctx.raw_lines();
1082 let table_blocks = &ctx.table_blocks;
1083
1084 if table_blocks.is_empty() {
1087 return Ok(content.to_string());
1088 }
1089
1090 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1091
1092 for table_block in table_blocks {
1093 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1094
1095 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1096 .chain(std::iter::once(table_block.delimiter_line))
1097 .chain(table_block.content_lines.iter().copied())
1098 .collect();
1099
1100 let any_disabled = table_line_indices
1103 .iter()
1104 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1105
1106 if any_disabled {
1107 continue;
1108 }
1109
1110 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1111 result_lines[line_idx].clone_from(&format_result.lines[i]);
1112 }
1113 }
1114
1115 let mut fixed = result_lines.join("\n");
1116 let original_trailing_newlines = content.len() - content.trim_end_matches('\n').len();
1121 fixed.truncate(fixed.trim_end_matches('\n').len());
1122 fixed.push_str(&"\n".repeat(original_trailing_newlines));
1123 Ok(fixed)
1124 }
1125
1126 fn as_any(&self) -> &dyn std::any::Any {
1127 self
1128 }
1129
1130 crate::impl_rule_config_sections!(MD060Config);
1131
1132 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1133 where
1134 Self: Sized,
1135 {
1136 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1137 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1138
1139 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1141
1142 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149 use crate::lint_context::LintContext;
1150 use crate::types::LineLength;
1151
1152 fn md013_with_line_length(line_length: usize) -> MD013Config {
1154 MD013Config {
1155 line_length: LineLength::from_const(line_length),
1156 tables: true, ..Default::default()
1158 }
1159 }
1160
1161 #[test]
1162 fn test_md060_align_simple_ascii_table() {
1163 let rule = MD060TableFormat::new(true, "aligned".to_string());
1164
1165 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167
1168 let fixed = rule.fix(&ctx).unwrap();
1169 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1170 assert_eq!(fixed, expected);
1171
1172 let lines: Vec<&str> = fixed.lines().collect();
1174 assert_eq!(lines[0].len(), lines[1].len());
1175 assert_eq!(lines[1].len(), lines[2].len());
1176 }
1177
1178 #[test]
1179 fn test_md060_cjk_characters_aligned_correctly() {
1180 let rule = MD060TableFormat::new(true, "aligned".to_string());
1181
1182 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184
1185 let fixed = rule.fix(&ctx).unwrap();
1186
1187 let lines: Vec<&str> = fixed.lines().collect();
1188 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1189 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1190
1191 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1192 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1193
1194 assert_eq!(width1, width3);
1195 }
1196
1197 #[test]
1198 fn test_md060_basic_emoji() {
1199 let rule = MD060TableFormat::new(true, "aligned".to_string());
1200
1201 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203
1204 let fixed = rule.fix(&ctx).unwrap();
1205 assert!(fixed.contains("Status"));
1206 }
1207
1208 #[test]
1209 fn test_md060_zwj_emoji_skipped() {
1210 let rule = MD060TableFormat::new(true, "aligned".to_string());
1211
1212 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214
1215 let fixed = rule.fix(&ctx).unwrap();
1216 assert_eq!(fixed, content);
1217 }
1218
1219 #[test]
1220 fn test_md060_inline_code_with_escaped_pipes() {
1221 let rule = MD060TableFormat::new(true, "aligned".to_string());
1224
1225 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228
1229 let fixed = rule.fix(&ctx).unwrap();
1230 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1231 }
1232
1233 #[test]
1234 fn test_md060_compact_style() {
1235 let rule = MD060TableFormat::new(true, "compact".to_string());
1236
1237 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239
1240 let fixed = rule.fix(&ctx).unwrap();
1241 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1242 assert_eq!(fixed, expected);
1243 }
1244
1245 #[test]
1246 fn test_md060_tight_style() {
1247 let rule = MD060TableFormat::new(true, "tight".to_string());
1248
1249 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251
1252 let fixed = rule.fix(&ctx).unwrap();
1253 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1254 assert_eq!(fixed, expected);
1255 }
1256
1257 #[test]
1258 fn test_md060_aligned_no_space_style() {
1259 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1261
1262 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264
1265 let fixed = rule.fix(&ctx).unwrap();
1266
1267 let lines: Vec<&str> = fixed.lines().collect();
1269 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1270 assert_eq!(
1271 lines[1], "|-------|-----|",
1272 "Delimiter should have NO spaces around dashes"
1273 );
1274 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1275
1276 assert_eq!(lines[0].len(), lines[1].len());
1278 assert_eq!(lines[1].len(), lines[2].len());
1279 }
1280
1281 #[test]
1282 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1283 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1285
1286 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288
1289 let fixed = rule.fix(&ctx).unwrap();
1290 let lines: Vec<&str> = fixed.lines().collect();
1291
1292 assert!(
1294 fixed.contains("|:"),
1295 "Should have left alignment indicator adjacent to pipe"
1296 );
1297 assert!(
1298 fixed.contains(":|"),
1299 "Should have right alignment indicator adjacent to pipe"
1300 );
1301 assert!(
1303 lines[1].contains(":---") && lines[1].contains("---:"),
1304 "Should have center alignment colons"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_md060_aligned_no_space_three_column_table() {
1310 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1312
1313 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 |";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315
1316 let fixed = rule.fix(&ctx).unwrap();
1317 let lines: Vec<&str> = fixed.lines().collect();
1318
1319 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1321 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1322 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1323 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1324 }
1325
1326 #[test]
1327 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1328 let config = MD060Config {
1330 enabled: true,
1331 style: "aligned-no-space".to_string(),
1332 max_width: LineLength::from_const(50),
1333 column_align: ColumnAlign::Auto,
1334 column_align_header: None,
1335 column_align_body: None,
1336 loose_last_column: false,
1337 aligned_delimiter: false,
1338 };
1339 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1340
1341 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344
1345 let fixed = rule.fix(&ctx).unwrap();
1346
1347 assert!(
1349 fixed.contains("| --- |"),
1350 "Should be compact format when exceeding max-width"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_md060_aligned_no_space_cjk_characters() {
1356 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1358
1359 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361
1362 let fixed = rule.fix(&ctx).unwrap();
1363 let lines: Vec<&str> = fixed.lines().collect();
1364
1365 use unicode_width::UnicodeWidthStr;
1368 assert_eq!(
1369 lines[0].width(),
1370 lines[1].width(),
1371 "Header and delimiter should have same display width"
1372 );
1373 assert_eq!(
1374 lines[1].width(),
1375 lines[2].width(),
1376 "Delimiter and content should have same display width"
1377 );
1378
1379 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1381 }
1382
1383 #[test]
1384 fn test_md060_aligned_no_space_minimum_width() {
1385 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1387
1388 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390
1391 let fixed = rule.fix(&ctx).unwrap();
1392 let lines: Vec<&str> = fixed.lines().collect();
1393
1394 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1396 assert_eq!(lines[0].len(), lines[1].len());
1398 assert_eq!(lines[1].len(), lines[2].len());
1399 }
1400
1401 #[test]
1402 fn test_md060_any_style_consistency() {
1403 let rule = MD060TableFormat::new(true, "any".to_string());
1404
1405 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408
1409 let fixed = rule.fix(&ctx).unwrap();
1410 assert_eq!(fixed, content);
1411
1412 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1414 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1415
1416 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1417 assert_eq!(fixed_aligned, content_aligned);
1418 }
1419
1420 #[test]
1421 fn test_md060_empty_cells() {
1422 let rule = MD060TableFormat::new(true, "aligned".to_string());
1423
1424 let content = "| A | B |\n|---|---|\n| | X |";
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426
1427 let fixed = rule.fix(&ctx).unwrap();
1428 assert!(fixed.contains('|'));
1429 }
1430
1431 #[test]
1432 fn test_md060_mixed_content() {
1433 let rule = MD060TableFormat::new(true, "aligned".to_string());
1434
1435 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437
1438 let fixed = rule.fix(&ctx).unwrap();
1439 assert!(fixed.contains("δΈζ"));
1440 assert!(fixed.contains("NYC"));
1441 }
1442
1443 #[test]
1444 fn test_md060_preserve_alignment_indicators() {
1445 let rule = MD060TableFormat::new(true, "aligned".to_string());
1446
1447 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1449
1450 let fixed = rule.fix(&ctx).unwrap();
1451
1452 assert!(fixed.contains(":---"), "Should contain left alignment");
1453 assert!(fixed.contains(":----:"), "Should contain center alignment");
1454 assert!(fixed.contains("----:"), "Should contain right alignment");
1455 }
1456
1457 #[test]
1458 fn test_md060_minimum_column_width() {
1459 let rule = MD060TableFormat::new(true, "aligned".to_string());
1460
1461 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465
1466 let fixed = rule.fix(&ctx).unwrap();
1467
1468 let lines: Vec<&str> = fixed.lines().collect();
1469 assert_eq!(lines[0].len(), lines[1].len());
1470 assert_eq!(lines[1].len(), lines[2].len());
1471
1472 assert!(fixed.contains("ID "), "Short content should be padded");
1474 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1475 }
1476
1477 #[test]
1478 fn test_md060_auto_compact_exceeds_default_threshold() {
1479 let config = MD060Config {
1481 enabled: true,
1482 style: "aligned".to_string(),
1483 max_width: LineLength::from_const(0),
1484 column_align: ColumnAlign::Auto,
1485 column_align_header: None,
1486 column_align_body: None,
1487 loose_last_column: false,
1488 aligned_delimiter: false,
1489 };
1490 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1491
1492 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497
1498 let fixed = rule.fix(&ctx).unwrap();
1499
1500 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1502 assert!(fixed.contains("| --- | --- | --- |"));
1503 assert!(fixed.contains("| Short | Data | Here |"));
1504
1505 let lines: Vec<&str> = fixed.lines().collect();
1507 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1509 }
1510
1511 #[test]
1512 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1513 let config = MD060Config {
1515 enabled: true,
1516 style: "aligned".to_string(),
1517 max_width: LineLength::from_const(50),
1518 column_align: ColumnAlign::Auto,
1519 column_align_header: None,
1520 column_align_body: None,
1521 loose_last_column: false,
1522 aligned_delimiter: false,
1523 };
1524 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 |";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531
1532 let fixed = rule.fix(&ctx).unwrap();
1533
1534 assert!(
1536 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1537 );
1538 assert!(fixed.contains("| --- | --- | --- |"));
1539 assert!(fixed.contains("| Data | Data | Data |"));
1540
1541 let lines: Vec<&str> = fixed.lines().collect();
1543 assert!(lines[0].len() != lines[2].len());
1544 }
1545
1546 #[test]
1547 fn test_md060_stays_aligned_under_threshold() {
1548 let config = MD060Config {
1550 enabled: true,
1551 style: "aligned".to_string(),
1552 max_width: LineLength::from_const(100),
1553 column_align: ColumnAlign::Auto,
1554 column_align_header: None,
1555 column_align_body: None,
1556 loose_last_column: false,
1557 aligned_delimiter: false,
1558 };
1559 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1560
1561 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564
1565 let fixed = rule.fix(&ctx).unwrap();
1566
1567 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1569 assert_eq!(fixed, expected);
1570
1571 let lines: Vec<&str> = fixed.lines().collect();
1572 assert_eq!(lines[0].len(), lines[1].len());
1573 assert_eq!(lines[1].len(), lines[2].len());
1574 }
1575
1576 #[test]
1577 fn test_md060_width_calculation_formula() {
1578 let config = MD060Config {
1580 enabled: true,
1581 style: "aligned".to_string(),
1582 max_width: LineLength::from_const(0),
1583 column_align: ColumnAlign::Auto,
1584 column_align_header: None,
1585 column_align_body: None,
1586 loose_last_column: false,
1587 aligned_delimiter: false,
1588 };
1589 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1590
1591 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
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();
1601 assert_eq!(lines[0].len(), lines[1].len());
1602 assert_eq!(lines[1].len(), lines[2].len());
1603 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1607 enabled: true,
1608 style: "aligned".to_string(),
1609 max_width: LineLength::from_const(24),
1610 column_align: ColumnAlign::Auto,
1611 column_align_header: None,
1612 column_align_body: None,
1613 loose_last_column: false,
1614 aligned_delimiter: false,
1615 };
1616 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1617
1618 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1619
1620 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1622 assert!(fixed_compact.contains("| --- | --- | --- |"));
1623 }
1624
1625 #[test]
1626 fn test_md060_very_wide_table_auto_compacts() {
1627 let config = MD060Config {
1628 enabled: true,
1629 style: "aligned".to_string(),
1630 max_width: LineLength::from_const(0),
1631 column_align: ColumnAlign::Auto,
1632 column_align_header: None,
1633 column_align_body: None,
1634 loose_last_column: false,
1635 aligned_delimiter: false,
1636 };
1637 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1638
1639 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 |";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644
1645 let fixed = rule.fix(&ctx).unwrap();
1646
1647 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1649 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1650 }
1651
1652 #[test]
1653 fn test_md060_inherit_from_md013_line_length() {
1654 let config = MD060Config {
1656 enabled: true,
1657 style: "aligned".to_string(),
1658 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1660 column_align_header: None,
1661 column_align_body: None,
1662 loose_last_column: false,
1663 aligned_delimiter: false,
1664 };
1665
1666 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1668 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1669
1670 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673
1674 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1676
1677 let fixed_120 = rule_120.fix(&ctx).unwrap();
1679
1680 let lines_120: Vec<&str> = fixed_120.lines().collect();
1682 assert_eq!(lines_120[0].len(), lines_120[1].len());
1683 assert_eq!(lines_120[1].len(), lines_120[2].len());
1684 }
1685
1686 #[test]
1687 fn test_md060_edge_case_exactly_at_threshold() {
1688 let config = MD060Config {
1692 enabled: true,
1693 style: "aligned".to_string(),
1694 max_width: LineLength::from_const(17),
1695 column_align: ColumnAlign::Auto,
1696 column_align_header: None,
1697 column_align_body: None,
1698 loose_last_column: false,
1699 aligned_delimiter: false,
1700 };
1701 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1702
1703 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705
1706 let fixed = rule.fix(&ctx).unwrap();
1707
1708 let lines: Vec<&str> = fixed.lines().collect();
1710 assert_eq!(lines[0].len(), 17);
1711 assert_eq!(lines[0].len(), lines[1].len());
1712 assert_eq!(lines[1].len(), lines[2].len());
1713
1714 let config_under = MD060Config {
1716 enabled: true,
1717 style: "aligned".to_string(),
1718 max_width: LineLength::from_const(16),
1719 column_align: ColumnAlign::Auto,
1720 column_align_header: None,
1721 column_align_body: None,
1722 loose_last_column: false,
1723 aligned_delimiter: false,
1724 };
1725 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1726
1727 let fixed_compact = rule_under.fix(&ctx).unwrap();
1728
1729 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1731 assert!(fixed_compact.contains("| --- | --- |"));
1732 }
1733
1734 #[test]
1735 fn test_md060_auto_compact_warning_message() {
1736 let config = MD060Config {
1738 enabled: true,
1739 style: "aligned".to_string(),
1740 max_width: LineLength::from_const(50),
1741 column_align: ColumnAlign::Auto,
1742 column_align_header: None,
1743 column_align_body: None,
1744 loose_last_column: false,
1745 aligned_delimiter: false,
1746 };
1747 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1748
1749 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1752
1753 let warnings = rule.check(&ctx).unwrap();
1754
1755 assert!(!warnings.is_empty(), "Should generate warnings");
1757
1758 let auto_compact_warnings: Vec<_> = warnings
1759 .iter()
1760 .filter(|w| w.message.contains("too wide for aligned formatting"))
1761 .collect();
1762
1763 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1764
1765 let first_warning = auto_compact_warnings[0];
1767 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1768 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1769 }
1770
1771 #[test]
1772 fn test_md060_issue_129_detect_style_from_all_rows() {
1773 let rule = MD060TableFormat::new(true, "any".to_string());
1777
1778 let content = "| a long heading | another long heading |\n\
1780 | -------------- | -------------------- |\n\
1781 | a | 1 |\n\
1782 | b b | 2 |\n\
1783 | c c c | 3 |";
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785
1786 let fixed = rule.fix(&ctx).unwrap();
1787
1788 assert!(
1790 fixed.contains("| a | 1 |"),
1791 "Should preserve aligned padding in first content row"
1792 );
1793 assert!(
1794 fixed.contains("| b b | 2 |"),
1795 "Should preserve aligned padding in second content row"
1796 );
1797 assert!(
1798 fixed.contains("| c c c | 3 |"),
1799 "Should preserve aligned padding in third content row"
1800 );
1801
1802 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1804 }
1805
1806 #[test]
1807 fn test_md060_regular_alignment_warning_message() {
1808 let config = MD060Config {
1810 enabled: true,
1811 style: "aligned".to_string(),
1812 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1814 column_align_header: None,
1815 column_align_body: None,
1816 loose_last_column: false,
1817 aligned_delimiter: false,
1818 };
1819 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1820
1821 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1824
1825 let warnings = rule.check(&ctx).unwrap();
1826
1827 assert!(!warnings.is_empty(), "Should generate warnings");
1829
1830 assert!(warnings[0].message.contains("Table columns should be aligned"));
1832 assert!(!warnings[0].message.contains("too wide"));
1833 assert!(!warnings[0].message.contains("max-width"));
1834 }
1835
1836 #[test]
1839 fn test_md060_unlimited_when_md013_disabled() {
1840 let config = MD060Config {
1842 enabled: true,
1843 style: "aligned".to_string(),
1844 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1846 column_align_header: None,
1847 column_align_body: None,
1848 loose_last_column: false,
1849 aligned_delimiter: false,
1850 };
1851 let md013_config = MD013Config::default();
1852 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1853
1854 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let fixed = rule.fix(&ctx).unwrap();
1858
1859 let lines: Vec<&str> = fixed.lines().collect();
1861 assert_eq!(
1863 lines[0].len(),
1864 lines[1].len(),
1865 "Table should be aligned when MD013 is disabled"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_md060_unlimited_when_md013_tables_false() {
1871 let config = MD060Config {
1873 enabled: true,
1874 style: "aligned".to_string(),
1875 max_width: LineLength::from_const(0),
1876 column_align: ColumnAlign::Auto,
1877 column_align_header: None,
1878 column_align_body: None,
1879 loose_last_column: false,
1880 aligned_delimiter: false,
1881 };
1882 let md013_config = MD013Config {
1883 tables: false, line_length: LineLength::from_const(80),
1885 ..Default::default()
1886 };
1887 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1888
1889 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let fixed = rule.fix(&ctx).unwrap();
1893
1894 let lines: Vec<&str> = fixed.lines().collect();
1896 assert_eq!(
1897 lines[0].len(),
1898 lines[1].len(),
1899 "Table should be aligned when MD013.tables=false"
1900 );
1901 }
1902
1903 #[test]
1904 fn test_md060_unlimited_when_md013_line_length_zero() {
1905 let config = MD060Config {
1907 enabled: true,
1908 style: "aligned".to_string(),
1909 max_width: LineLength::from_const(0),
1910 column_align: ColumnAlign::Auto,
1911 column_align_header: None,
1912 column_align_body: None,
1913 loose_last_column: false,
1914 aligned_delimiter: false,
1915 };
1916 let md013_config = MD013Config {
1917 tables: true,
1918 line_length: LineLength::from_const(0), ..Default::default()
1920 };
1921 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1922
1923 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let fixed = rule.fix(&ctx).unwrap();
1927
1928 let lines: Vec<&str> = fixed.lines().collect();
1930 assert_eq!(
1931 lines[0].len(),
1932 lines[1].len(),
1933 "Table should be aligned when MD013.line_length=0"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_md060_explicit_max_width_overrides_md013_settings() {
1939 let config = MD060Config {
1941 enabled: true,
1942 style: "aligned".to_string(),
1943 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1945 column_align_header: None,
1946 column_align_body: None,
1947 loose_last_column: false,
1948 aligned_delimiter: false,
1949 };
1950 let md013_config = MD013Config {
1951 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1954 };
1955 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1956
1957 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960 let fixed = rule.fix(&ctx).unwrap();
1961
1962 assert!(
1964 fixed.contains("| --- |"),
1965 "Should be compact format due to explicit max_width"
1966 );
1967 }
1968
1969 #[test]
1970 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1971 let config = MD060Config {
1973 enabled: true,
1974 style: "aligned".to_string(),
1975 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1977 column_align_header: None,
1978 column_align_body: None,
1979 loose_last_column: false,
1980 aligned_delimiter: false,
1981 };
1982 let md013_config = MD013Config {
1983 tables: true,
1984 line_length: LineLength::from_const(50), ..Default::default()
1986 };
1987 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1988
1989 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1992 let fixed = rule.fix(&ctx).unwrap();
1993
1994 assert!(
1996 fixed.contains("| --- |"),
1997 "Should be compact format when inheriting MD013 limit"
1998 );
1999 }
2000
2001 #[test]
2004 fn test_aligned_no_space_reformats_spaced_delimiter() {
2005 let config = MD060Config {
2008 enabled: true,
2009 style: "aligned-no-space".to_string(),
2010 max_width: LineLength::from_const(0),
2011 column_align: ColumnAlign::Auto,
2012 column_align_header: None,
2013 column_align_body: None,
2014 loose_last_column: false,
2015 aligned_delimiter: false,
2016 };
2017 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2018
2019 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022 let fixed = rule.fix(&ctx).unwrap();
2023
2024 assert!(
2027 !fixed.contains("| ----"),
2028 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2029 );
2030 assert!(
2031 !fixed.contains("---- |"),
2032 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2033 );
2034 assert!(
2036 fixed.contains("|----"),
2037 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_aligned_reformats_compact_delimiter() {
2043 let config = MD060Config {
2046 enabled: true,
2047 style: "aligned".to_string(),
2048 max_width: LineLength::from_const(0),
2049 column_align: ColumnAlign::Auto,
2050 column_align_header: None,
2051 column_align_body: None,
2052 loose_last_column: false,
2053 aligned_delimiter: false,
2054 };
2055 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2056
2057 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060 let fixed = rule.fix(&ctx).unwrap();
2061
2062 assert!(
2064 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2065 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_aligned_no_space_preserves_matching_table() {
2071 let config = MD060Config {
2073 enabled: true,
2074 style: "aligned-no-space".to_string(),
2075 max_width: LineLength::from_const(0),
2076 column_align: ColumnAlign::Auto,
2077 column_align_header: None,
2078 column_align_body: None,
2079 loose_last_column: false,
2080 aligned_delimiter: false,
2081 };
2082 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2083
2084 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let fixed = rule.fix(&ctx).unwrap();
2088
2089 assert_eq!(
2091 fixed, content,
2092 "Table already in aligned-no-space style should be preserved"
2093 );
2094 }
2095
2096 #[test]
2097 fn test_aligned_preserves_matching_table() {
2098 let config = MD060Config {
2100 enabled: true,
2101 style: "aligned".to_string(),
2102 max_width: LineLength::from_const(0),
2103 column_align: ColumnAlign::Auto,
2104 column_align_header: None,
2105 column_align_body: None,
2106 loose_last_column: false,
2107 aligned_delimiter: false,
2108 };
2109 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2110
2111 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2114 let fixed = rule.fix(&ctx).unwrap();
2115
2116 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2118 }
2119
2120 #[test]
2121 fn test_cjk_table_display_width_consistency() {
2122 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2128
2129 let is_aligned =
2131 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2132 assert!(
2133 !is_aligned,
2134 "Table with uneven raw line lengths should NOT be considered aligned"
2135 );
2136 }
2137
2138 #[test]
2139 fn test_cjk_width_calculation_in_aligned_check() {
2140 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2143 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2144
2145 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2146 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2147
2148 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2150 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2151
2152 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2154 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2156 }
2157
2158 #[test]
2161 fn test_md060_column_align_left() {
2162 let config = MD060Config {
2164 enabled: true,
2165 style: "aligned".to_string(),
2166 max_width: LineLength::from_const(0),
2167 column_align: ColumnAlign::Left,
2168 column_align_header: None,
2169 column_align_body: None,
2170 loose_last_column: false,
2171 aligned_delimiter: false,
2172 };
2173 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2174
2175 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2177
2178 let fixed = rule.fix(&ctx).unwrap();
2179 let lines: Vec<&str> = fixed.lines().collect();
2180
2181 assert!(
2183 lines[2].contains("| Alice "),
2184 "Content should be left-aligned (Alice should have trailing padding)"
2185 );
2186 assert!(
2187 lines[3].contains("| Bob "),
2188 "Content should be left-aligned (Bob should have trailing padding)"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_md060_column_align_center() {
2194 let config = MD060Config {
2196 enabled: true,
2197 style: "aligned".to_string(),
2198 max_width: LineLength::from_const(0),
2199 column_align: ColumnAlign::Center,
2200 column_align_header: None,
2201 column_align_body: None,
2202 loose_last_column: false,
2203 aligned_delimiter: false,
2204 };
2205 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2206
2207 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2209
2210 let fixed = rule.fix(&ctx).unwrap();
2211 let lines: Vec<&str> = fixed.lines().collect();
2212
2213 assert!(
2216 lines[3].contains("| Bob |"),
2217 "Bob should be centered with padding on both sides. Got: {}",
2218 lines[3]
2219 );
2220 }
2221
2222 #[test]
2223 fn test_md060_column_align_right() {
2224 let config = MD060Config {
2226 enabled: true,
2227 style: "aligned".to_string(),
2228 max_width: LineLength::from_const(0),
2229 column_align: ColumnAlign::Right,
2230 column_align_header: None,
2231 column_align_body: None,
2232 loose_last_column: false,
2233 aligned_delimiter: false,
2234 };
2235 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2236
2237 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239
2240 let fixed = rule.fix(&ctx).unwrap();
2241 let lines: Vec<&str> = fixed.lines().collect();
2242
2243 assert!(
2245 lines[3].contains("| Bob |"),
2246 "Bob should be right-aligned with padding on left. Got: {}",
2247 lines[3]
2248 );
2249 }
2250
2251 #[test]
2252 fn test_md060_column_align_auto_respects_delimiter() {
2253 let config = MD060Config {
2255 enabled: true,
2256 style: "aligned".to_string(),
2257 max_width: LineLength::from_const(0),
2258 column_align: ColumnAlign::Auto,
2259 column_align_header: None,
2260 column_align_body: None,
2261 loose_last_column: false,
2262 aligned_delimiter: false,
2263 };
2264 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2265
2266 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269
2270 let fixed = rule.fix(&ctx).unwrap();
2271
2272 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2274 let lines: Vec<&str> = fixed.lines().collect();
2276 assert!(
2280 lines[2].contains(" C |"),
2281 "Right column should be right-aligned. Got: {}",
2282 lines[2]
2283 );
2284 }
2285
2286 #[test]
2287 fn test_md060_column_align_overrides_delimiter_indicators() {
2288 let config = MD060Config {
2290 enabled: true,
2291 style: "aligned".to_string(),
2292 max_width: LineLength::from_const(0),
2293 column_align: ColumnAlign::Right, column_align_header: None,
2295 column_align_body: None,
2296 loose_last_column: false,
2297 aligned_delimiter: false,
2298 };
2299 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2300
2301 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2304
2305 let fixed = rule.fix(&ctx).unwrap();
2306 let lines: Vec<&str> = fixed.lines().collect();
2307
2308 assert!(
2311 lines[2].contains(" A |") || lines[2].contains(" A |"),
2312 "Even left-indicated column should be right-aligned. Got: {}",
2313 lines[2]
2314 );
2315 }
2316
2317 #[test]
2318 fn test_md060_column_align_with_aligned_no_space() {
2319 let config = MD060Config {
2321 enabled: true,
2322 style: "aligned-no-space".to_string(),
2323 max_width: LineLength::from_const(0),
2324 column_align: ColumnAlign::Center,
2325 column_align_header: None,
2326 column_align_body: None,
2327 loose_last_column: false,
2328 aligned_delimiter: false,
2329 };
2330 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2331
2332 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334
2335 let fixed = rule.fix(&ctx).unwrap();
2336 let lines: Vec<&str> = fixed.lines().collect();
2337
2338 assert!(
2340 lines[1].contains("|---"),
2341 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2342 lines[1]
2343 );
2344 assert!(
2346 lines[3].contains("| Bob |"),
2347 "Content should be centered. Got: {}",
2348 lines[3]
2349 );
2350 }
2351
2352 #[test]
2353 fn test_md060_column_align_config_parsing() {
2354 let toml_str = r#"
2356enabled = true
2357style = "aligned"
2358column-align = "center"
2359"#;
2360 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2361 assert_eq!(config.column_align, ColumnAlign::Center);
2362
2363 let toml_str = r#"
2364enabled = true
2365style = "aligned"
2366column-align = "right"
2367"#;
2368 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2369 assert_eq!(config.column_align, ColumnAlign::Right);
2370
2371 let toml_str = r#"
2372enabled = true
2373style = "aligned"
2374column-align = "left"
2375"#;
2376 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2377 assert_eq!(config.column_align, ColumnAlign::Left);
2378
2379 let toml_str = r#"
2380enabled = true
2381style = "aligned"
2382column-align = "auto"
2383"#;
2384 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2385 assert_eq!(config.column_align, ColumnAlign::Auto);
2386 }
2387
2388 #[test]
2389 fn test_md060_column_align_default_is_auto() {
2390 let toml_str = r#"
2392enabled = true
2393style = "aligned"
2394"#;
2395 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2396 assert_eq!(config.column_align, ColumnAlign::Auto);
2397 }
2398
2399 #[test]
2400 fn test_md060_column_align_reformats_already_aligned_table() {
2401 let config = MD060Config {
2403 enabled: true,
2404 style: "aligned".to_string(),
2405 max_width: LineLength::from_const(0),
2406 column_align: ColumnAlign::Right,
2407 column_align_header: None,
2408 column_align_body: None,
2409 loose_last_column: false,
2410 aligned_delimiter: false,
2411 };
2412 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2413
2414 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417
2418 let fixed = rule.fix(&ctx).unwrap();
2419 let lines: Vec<&str> = fixed.lines().collect();
2420
2421 assert!(
2423 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2424 "Already aligned table should be reformatted with right alignment. Got: {}",
2425 lines[2]
2426 );
2427 assert!(
2428 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2429 "Bob should be right-aligned. Got: {}",
2430 lines[3]
2431 );
2432 }
2433
2434 #[test]
2435 fn test_md060_column_align_with_cjk_characters() {
2436 let config = MD060Config {
2438 enabled: true,
2439 style: "aligned".to_string(),
2440 max_width: LineLength::from_const(0),
2441 column_align: ColumnAlign::Center,
2442 column_align_header: None,
2443 column_align_body: None,
2444 loose_last_column: false,
2445 aligned_delimiter: false,
2446 };
2447 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2448
2449 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2451
2452 let fixed = rule.fix(&ctx).unwrap();
2453
2454 assert!(fixed.contains("Bob"), "Table should contain Bob");
2457 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2458 }
2459
2460 #[test]
2461 fn test_md060_column_align_ignored_for_compact_style() {
2462 let config = MD060Config {
2464 enabled: true,
2465 style: "compact".to_string(),
2466 max_width: LineLength::from_const(0),
2467 column_align: ColumnAlign::Right, column_align_header: None,
2469 column_align_body: None,
2470 loose_last_column: false,
2471 aligned_delimiter: false,
2472 };
2473 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2474
2475 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2477
2478 let fixed = rule.fix(&ctx).unwrap();
2479
2480 assert!(
2482 fixed.contains("| Alice |"),
2483 "Compact style should have single space padding, not alignment. Got: {fixed}"
2484 );
2485 }
2486
2487 #[test]
2488 fn test_md060_column_align_ignored_for_tight_style() {
2489 let config = MD060Config {
2491 enabled: true,
2492 style: "tight".to_string(),
2493 max_width: LineLength::from_const(0),
2494 column_align: ColumnAlign::Center, column_align_header: None,
2496 column_align_body: None,
2497 loose_last_column: false,
2498 aligned_delimiter: false,
2499 };
2500 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2501
2502 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2504
2505 let fixed = rule.fix(&ctx).unwrap();
2506
2507 assert!(
2509 fixed.contains("|Alice|"),
2510 "Tight style should have no spaces. Got: {fixed}"
2511 );
2512 }
2513
2514 #[test]
2515 fn test_md060_column_align_with_empty_cells() {
2516 let config = MD060Config {
2518 enabled: true,
2519 style: "aligned".to_string(),
2520 max_width: LineLength::from_const(0),
2521 column_align: ColumnAlign::Center,
2522 column_align_header: None,
2523 column_align_body: None,
2524 loose_last_column: false,
2525 aligned_delimiter: false,
2526 };
2527 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2528
2529 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531
2532 let fixed = rule.fix(&ctx).unwrap();
2533 let lines: Vec<&str> = fixed.lines().collect();
2534
2535 assert!(
2537 lines[3].contains("| |") || lines[3].contains("| |"),
2538 "Empty cell should be padded correctly. Got: {}",
2539 lines[3]
2540 );
2541 }
2542
2543 #[test]
2544 fn test_md060_column_align_auto_preserves_already_aligned() {
2545 let config = MD060Config {
2547 enabled: true,
2548 style: "aligned".to_string(),
2549 max_width: LineLength::from_const(0),
2550 column_align: ColumnAlign::Auto,
2551 column_align_header: None,
2552 column_align_body: None,
2553 loose_last_column: false,
2554 aligned_delimiter: false,
2555 };
2556 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2557
2558 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2561
2562 let fixed = rule.fix(&ctx).unwrap();
2563
2564 assert_eq!(
2566 fixed, content,
2567 "Already aligned table should be preserved with column-align=auto"
2568 );
2569 }
2570
2571 #[test]
2572 fn test_cjk_table_display_aligned_not_flagged() {
2573 use crate::config::MarkdownFlavor;
2577
2578 let table_lines: Vec<&str> = vec![
2580 "| Header | Name |",
2581 "| ------ | ---- |",
2582 "| Hello | Test |",
2583 "| δ½ ε₯½ | Test |",
2584 ];
2585
2586 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2587 assert!(
2588 result,
2589 "Table with CJK characters that is display-aligned should be recognized as aligned"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_cjk_table_not_reformatted_when_aligned() {
2595 let rule = MD060TableFormat::new(true, "aligned".to_string());
2597 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2600
2601 let fixed = rule.fix(&ctx).unwrap();
2603 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2604 }
2605
2606 #[test]
2622 fn md060_pandoc_grid_tables_not_flagged() {
2623 let rule = MD060TableFormat::new(true, "aligned".to_string());
2624 let content = "\
2625+---+---+
2626| a | b |
2627+===+===+
2628| 1 | 2 |
2629+---+---+
2630";
2631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2634 let result = rule.check(&ctx).unwrap();
2635 assert!(
2636 result.is_empty(),
2637 "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2638 );
2639
2640 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2641 let result_std = rule.check(&ctx_std).unwrap();
2642 assert!(
2643 result_std.is_empty(),
2644 "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2645 );
2646 }
2647
2648 #[test]
2649 fn md060_pandoc_multi_line_tables_not_flagged() {
2650 let rule = MD060TableFormat::new(true, "aligned".to_string());
2651 let content = "\
2652--------- -----------
2653Header 1 Header 2
2654--------- -----------
2655Cell 1 Cell 2
2656--------- -----------
2657";
2658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2659 let result = rule.check(&ctx).unwrap();
2660 assert!(
2661 result.is_empty(),
2662 "MD060 should not flag Pandoc multi-line tables: {result:?}"
2663 );
2664
2665 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2666 let result_std = rule.check(&ctx_std).unwrap();
2667 assert!(
2668 result_std.is_empty(),
2669 "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2670 );
2671 }
2672
2673 #[test]
2674 fn md060_pandoc_line_blocks_not_flagged() {
2675 let rule = MD060TableFormat::new(true, "aligned".to_string());
2676 let content = "| First line\n| Second line\n";
2678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2679 let result = rule.check(&ctx).unwrap();
2680 assert!(
2681 result.is_empty(),
2682 "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2683 );
2684
2685 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2686 let result_std = rule.check(&ctx_std).unwrap();
2687 assert!(
2688 result_std.is_empty(),
2689 "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2690 );
2691 }
2692
2693 #[test]
2694 fn md060_pandoc_pipe_table_captions_not_flagged() {
2695 let rule = MD060TableFormat::new(true, "aligned".to_string());
2696 let content = "\
2699| H1 | H2 |
2700| -- | -- |
2701| a | b |
2702
2703: My table caption
2704";
2705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2706 let result = rule.check(&ctx).unwrap();
2707 assert!(
2708 result.is_empty(),
2709 "MD060 should not flag the pipe-table caption line: {result:?}"
2710 );
2711
2712 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2713 let result_std = rule.check(&ctx_std).unwrap();
2714 assert!(
2715 result_std.is_empty(),
2716 "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2722 let rule = MD060TableFormat::new(true, "aligned".to_string());
2726
2727 for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
2729 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2730 assert_eq!(
2731 rule.fix(&ctx).unwrap(),
2732 input,
2733 "MD060 must not alter table-free content: {input:?}"
2734 );
2735 }
2736
2737 let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
2740 let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
2741 let once = rule.fix(&ctx).unwrap();
2742 assert!(
2743 once.ends_with("\n\n\n"),
2744 "trailing blank lines must be preserved, got: {once:?}"
2745 );
2746 let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
2747 let twice = rule.fix(&ctx2).unwrap();
2748 assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
2749 }
2750}