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 mut warnings = Vec::new();
1007
1008 let lines = ctx.raw_lines();
1009 let table_blocks = &ctx.table_blocks;
1010
1011 for table_block in table_blocks {
1012 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1013
1014 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1015 .chain(std::iter::once(table_block.delimiter_line))
1016 .chain(table_block.content_lines.iter().copied())
1017 .collect();
1018
1019 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());
1026 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1027 let fixed_line = &format_result.lines[i];
1028 if line_idx < lines.len() - 1 {
1030 fixed_table_lines.push(format!("{fixed_line}\n"));
1031 } else {
1032 fixed_table_lines.push(fixed_line.clone());
1033 }
1034 }
1035 let table_replacement = fixed_table_lines.concat();
1036 let table_range = ctx.line_span_byte_range(table_start_line, table_end_line);
1037
1038 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1039 let original = lines[line_idx];
1040 let fixed = &format_result.lines[i];
1041
1042 if original != fixed {
1043 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1044
1045 let message = if format_result.auto_compacted {
1046 if let Some(width) = format_result.aligned_width {
1047 format!(
1048 "Table too wide for aligned formatting ({} chars > max-width: {})",
1049 width,
1050 self.effective_max_width()
1051 )
1052 } else {
1053 "Table too wide for aligned formatting".to_string()
1054 }
1055 } else {
1056 "Table columns should be aligned".to_string()
1057 };
1058
1059 warnings.push(LintWarning {
1062 rule_name: Some(self.name().to_string()),
1063 severity: Severity::Warning,
1064 message,
1065 line: start_line,
1066 column: start_col,
1067 end_line,
1068 end_column: end_col,
1069 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1070 });
1071 }
1072 }
1073 }
1074
1075 Ok(warnings)
1076 }
1077
1078 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1079 let content = ctx.content;
1080 let lines = ctx.raw_lines();
1081 let table_blocks = &ctx.table_blocks;
1082
1083 if table_blocks.is_empty() {
1086 return Ok(content.to_string());
1087 }
1088
1089 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1090
1091 for table_block in table_blocks {
1092 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1093
1094 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1095 .chain(std::iter::once(table_block.delimiter_line))
1096 .chain(table_block.content_lines.iter().copied())
1097 .collect();
1098
1099 let any_disabled = table_line_indices
1102 .iter()
1103 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1104
1105 if any_disabled {
1106 continue;
1107 }
1108
1109 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1110 result_lines[line_idx].clone_from(&format_result.lines[i]);
1111 }
1112 }
1113
1114 let mut fixed = result_lines.join("\n");
1115 let original_trailing_newlines = content.len() - content.trim_end_matches('\n').len();
1120 fixed.truncate(fixed.trim_end_matches('\n').len());
1121 fixed.push_str(&"\n".repeat(original_trailing_newlines));
1122 Ok(fixed)
1123 }
1124
1125 fn as_any(&self) -> &dyn std::any::Any {
1126 self
1127 }
1128
1129 crate::impl_rule_config_sections!(MD060Config);
1130
1131 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1132 where
1133 Self: Sized,
1134 {
1135 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1136 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1137
1138 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1140
1141 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1142 }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148 use crate::lint_context::LintContext;
1149 use crate::types::LineLength;
1150
1151 fn md013_with_line_length(line_length: usize) -> MD013Config {
1153 MD013Config {
1154 line_length: LineLength::from_const(line_length),
1155 tables: true, ..Default::default()
1157 }
1158 }
1159
1160 #[test]
1161 fn test_md060_align_simple_ascii_table() {
1162 let rule = MD060TableFormat::new(true, "aligned".to_string());
1163
1164 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166
1167 let fixed = rule.fix(&ctx).unwrap();
1168 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1169 assert_eq!(fixed, expected);
1170
1171 let lines: Vec<&str> = fixed.lines().collect();
1173 assert_eq!(lines[0].len(), lines[1].len());
1174 assert_eq!(lines[1].len(), lines[2].len());
1175 }
1176
1177 #[test]
1178 fn test_md060_cjk_characters_aligned_correctly() {
1179 let rule = MD060TableFormat::new(true, "aligned".to_string());
1180
1181 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183
1184 let fixed = rule.fix(&ctx).unwrap();
1185
1186 let lines: Vec<&str> = fixed.lines().collect();
1187 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1188 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1189
1190 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1191 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1192
1193 assert_eq!(width1, width3);
1194 }
1195
1196 #[test]
1197 fn test_md060_basic_emoji() {
1198 let rule = MD060TableFormat::new(true, "aligned".to_string());
1199
1200 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202
1203 let fixed = rule.fix(&ctx).unwrap();
1204 assert!(fixed.contains("Status"));
1205 }
1206
1207 #[test]
1208 fn test_md060_zwj_emoji_skipped() {
1209 let rule = MD060TableFormat::new(true, "aligned".to_string());
1210
1211 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213
1214 let fixed = rule.fix(&ctx).unwrap();
1215 assert_eq!(fixed, content);
1216 }
1217
1218 #[test]
1219 fn test_md060_inline_code_with_escaped_pipes() {
1220 let rule = MD060TableFormat::new(true, "aligned".to_string());
1223
1224 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227
1228 let fixed = rule.fix(&ctx).unwrap();
1229 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1230 }
1231
1232 #[test]
1233 fn test_md060_compact_style() {
1234 let rule = MD060TableFormat::new(true, "compact".to_string());
1235
1236 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238
1239 let fixed = rule.fix(&ctx).unwrap();
1240 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1241 assert_eq!(fixed, expected);
1242 }
1243
1244 #[test]
1245 fn test_md060_tight_style() {
1246 let rule = MD060TableFormat::new(true, "tight".to_string());
1247
1248 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250
1251 let fixed = rule.fix(&ctx).unwrap();
1252 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1253 assert_eq!(fixed, expected);
1254 }
1255
1256 #[test]
1257 fn test_md060_aligned_no_space_style() {
1258 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1260
1261 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263
1264 let fixed = rule.fix(&ctx).unwrap();
1265
1266 let lines: Vec<&str> = fixed.lines().collect();
1268 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1269 assert_eq!(
1270 lines[1], "|-------|-----|",
1271 "Delimiter should have NO spaces around dashes"
1272 );
1273 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1274
1275 assert_eq!(lines[0].len(), lines[1].len());
1277 assert_eq!(lines[1].len(), lines[2].len());
1278 }
1279
1280 #[test]
1281 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1282 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1284
1285 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287
1288 let fixed = rule.fix(&ctx).unwrap();
1289 let lines: Vec<&str> = fixed.lines().collect();
1290
1291 assert!(
1293 fixed.contains("|:"),
1294 "Should have left alignment indicator adjacent to pipe"
1295 );
1296 assert!(
1297 fixed.contains(":|"),
1298 "Should have right alignment indicator adjacent to pipe"
1299 );
1300 assert!(
1302 lines[1].contains(":---") && lines[1].contains("---:"),
1303 "Should have center alignment colons"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_md060_aligned_no_space_three_column_table() {
1309 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1311
1312 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 |";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314
1315 let fixed = rule.fix(&ctx).unwrap();
1316 let lines: Vec<&str> = fixed.lines().collect();
1317
1318 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1320 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1321 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1322 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1323 }
1324
1325 #[test]
1326 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1327 let config = MD060Config {
1329 enabled: true,
1330 style: "aligned-no-space".to_string(),
1331 max_width: LineLength::from_const(50),
1332 column_align: ColumnAlign::Auto,
1333 column_align_header: None,
1334 column_align_body: None,
1335 loose_last_column: false,
1336 aligned_delimiter: false,
1337 };
1338 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1339
1340 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343
1344 let fixed = rule.fix(&ctx).unwrap();
1345
1346 assert!(
1348 fixed.contains("| --- |"),
1349 "Should be compact format when exceeding max-width"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_md060_aligned_no_space_cjk_characters() {
1355 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1357
1358 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1360
1361 let fixed = rule.fix(&ctx).unwrap();
1362 let lines: Vec<&str> = fixed.lines().collect();
1363
1364 use unicode_width::UnicodeWidthStr;
1367 assert_eq!(
1368 lines[0].width(),
1369 lines[1].width(),
1370 "Header and delimiter should have same display width"
1371 );
1372 assert_eq!(
1373 lines[1].width(),
1374 lines[2].width(),
1375 "Delimiter and content should have same display width"
1376 );
1377
1378 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1380 }
1381
1382 #[test]
1383 fn test_md060_aligned_no_space_minimum_width() {
1384 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1386
1387 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389
1390 let fixed = rule.fix(&ctx).unwrap();
1391 let lines: Vec<&str> = fixed.lines().collect();
1392
1393 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1395 assert_eq!(lines[0].len(), lines[1].len());
1397 assert_eq!(lines[1].len(), lines[2].len());
1398 }
1399
1400 #[test]
1401 fn test_md060_any_style_consistency() {
1402 let rule = MD060TableFormat::new(true, "any".to_string());
1403
1404 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407
1408 let fixed = rule.fix(&ctx).unwrap();
1409 assert_eq!(fixed, content);
1410
1411 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1413 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1414
1415 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1416 assert_eq!(fixed_aligned, content_aligned);
1417 }
1418
1419 #[test]
1420 fn test_md060_empty_cells() {
1421 let rule = MD060TableFormat::new(true, "aligned".to_string());
1422
1423 let content = "| A | B |\n|---|---|\n| | X |";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425
1426 let fixed = rule.fix(&ctx).unwrap();
1427 assert!(fixed.contains('|'));
1428 }
1429
1430 #[test]
1431 fn test_md060_mixed_content() {
1432 let rule = MD060TableFormat::new(true, "aligned".to_string());
1433
1434 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436
1437 let fixed = rule.fix(&ctx).unwrap();
1438 assert!(fixed.contains("δΈζ"));
1439 assert!(fixed.contains("NYC"));
1440 }
1441
1442 #[test]
1443 fn test_md060_preserve_alignment_indicators() {
1444 let rule = MD060TableFormat::new(true, "aligned".to_string());
1445
1446 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448
1449 let fixed = rule.fix(&ctx).unwrap();
1450
1451 assert!(fixed.contains(":---"), "Should contain left alignment");
1452 assert!(fixed.contains(":----:"), "Should contain center alignment");
1453 assert!(fixed.contains("----:"), "Should contain right alignment");
1454 }
1455
1456 #[test]
1457 fn test_md060_minimum_column_width() {
1458 let rule = MD060TableFormat::new(true, "aligned".to_string());
1459
1460 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1464
1465 let fixed = rule.fix(&ctx).unwrap();
1466
1467 let lines: Vec<&str> = fixed.lines().collect();
1468 assert_eq!(lines[0].len(), lines[1].len());
1469 assert_eq!(lines[1].len(), lines[2].len());
1470
1471 assert!(fixed.contains("ID "), "Short content should be padded");
1473 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1474 }
1475
1476 #[test]
1477 fn test_md060_auto_compact_exceeds_default_threshold() {
1478 let config = MD060Config {
1480 enabled: true,
1481 style: "aligned".to_string(),
1482 max_width: LineLength::from_const(0),
1483 column_align: ColumnAlign::Auto,
1484 column_align_header: None,
1485 column_align_body: None,
1486 loose_last_column: false,
1487 aligned_delimiter: false,
1488 };
1489 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1490
1491 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496
1497 let fixed = rule.fix(&ctx).unwrap();
1498
1499 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1501 assert!(fixed.contains("| --- | --- | --- |"));
1502 assert!(fixed.contains("| Short | Data | Here |"));
1503
1504 let lines: Vec<&str> = fixed.lines().collect();
1506 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1508 }
1509
1510 #[test]
1511 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1512 let config = MD060Config {
1514 enabled: true,
1515 style: "aligned".to_string(),
1516 max_width: LineLength::from_const(50),
1517 column_align: ColumnAlign::Auto,
1518 column_align_header: None,
1519 column_align_body: None,
1520 loose_last_column: false,
1521 aligned_delimiter: false,
1522 };
1523 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 |";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530
1531 let fixed = rule.fix(&ctx).unwrap();
1532
1533 assert!(
1535 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1536 );
1537 assert!(fixed.contains("| --- | --- | --- |"));
1538 assert!(fixed.contains("| Data | Data | Data |"));
1539
1540 let lines: Vec<&str> = fixed.lines().collect();
1542 assert!(lines[0].len() != lines[2].len());
1543 }
1544
1545 #[test]
1546 fn test_md060_stays_aligned_under_threshold() {
1547 let config = MD060Config {
1549 enabled: true,
1550 style: "aligned".to_string(),
1551 max_width: LineLength::from_const(100),
1552 column_align: ColumnAlign::Auto,
1553 column_align_header: None,
1554 column_align_body: None,
1555 loose_last_column: false,
1556 aligned_delimiter: false,
1557 };
1558 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1559
1560 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563
1564 let fixed = rule.fix(&ctx).unwrap();
1565
1566 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1568 assert_eq!(fixed, expected);
1569
1570 let lines: Vec<&str> = fixed.lines().collect();
1571 assert_eq!(lines[0].len(), lines[1].len());
1572 assert_eq!(lines[1].len(), lines[2].len());
1573 }
1574
1575 #[test]
1576 fn test_md060_width_calculation_formula() {
1577 let config = MD060Config {
1579 enabled: true,
1580 style: "aligned".to_string(),
1581 max_width: LineLength::from_const(0),
1582 column_align: ColumnAlign::Auto,
1583 column_align_header: None,
1584 column_align_body: None,
1585 loose_last_column: false,
1586 aligned_delimiter: false,
1587 };
1588 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1589
1590 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595
1596 let fixed = rule.fix(&ctx).unwrap();
1597
1598 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 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1606 enabled: true,
1607 style: "aligned".to_string(),
1608 max_width: LineLength::from_const(24),
1609 column_align: ColumnAlign::Auto,
1610 column_align_header: None,
1611 column_align_body: None,
1612 loose_last_column: false,
1613 aligned_delimiter: false,
1614 };
1615 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1616
1617 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1618
1619 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1621 assert!(fixed_compact.contains("| --- | --- | --- |"));
1622 }
1623
1624 #[test]
1625 fn test_md060_very_wide_table_auto_compacts() {
1626 let config = MD060Config {
1627 enabled: true,
1628 style: "aligned".to_string(),
1629 max_width: LineLength::from_const(0),
1630 column_align: ColumnAlign::Auto,
1631 column_align_header: None,
1632 column_align_body: None,
1633 loose_last_column: false,
1634 aligned_delimiter: false,
1635 };
1636 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1637
1638 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 |";
1642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1643
1644 let fixed = rule.fix(&ctx).unwrap();
1645
1646 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1648 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1649 }
1650
1651 #[test]
1652 fn test_md060_inherit_from_md013_line_length() {
1653 let config = MD060Config {
1655 enabled: true,
1656 style: "aligned".to_string(),
1657 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1659 column_align_header: None,
1660 column_align_body: None,
1661 loose_last_column: false,
1662 aligned_delimiter: false,
1663 };
1664
1665 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1667 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1668
1669 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672
1673 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1675
1676 let fixed_120 = rule_120.fix(&ctx).unwrap();
1678
1679 let lines_120: Vec<&str> = fixed_120.lines().collect();
1681 assert_eq!(lines_120[0].len(), lines_120[1].len());
1682 assert_eq!(lines_120[1].len(), lines_120[2].len());
1683 }
1684
1685 #[test]
1686 fn test_md060_edge_case_exactly_at_threshold() {
1687 let config = MD060Config {
1691 enabled: true,
1692 style: "aligned".to_string(),
1693 max_width: LineLength::from_const(17),
1694 column_align: ColumnAlign::Auto,
1695 column_align_header: None,
1696 column_align_body: None,
1697 loose_last_column: false,
1698 aligned_delimiter: false,
1699 };
1700 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1701
1702 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704
1705 let fixed = rule.fix(&ctx).unwrap();
1706
1707 let lines: Vec<&str> = fixed.lines().collect();
1709 assert_eq!(lines[0].len(), 17);
1710 assert_eq!(lines[0].len(), lines[1].len());
1711 assert_eq!(lines[1].len(), lines[2].len());
1712
1713 let config_under = MD060Config {
1715 enabled: true,
1716 style: "aligned".to_string(),
1717 max_width: LineLength::from_const(16),
1718 column_align: ColumnAlign::Auto,
1719 column_align_header: None,
1720 column_align_body: None,
1721 loose_last_column: false,
1722 aligned_delimiter: false,
1723 };
1724 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1725
1726 let fixed_compact = rule_under.fix(&ctx).unwrap();
1727
1728 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1730 assert!(fixed_compact.contains("| --- | --- |"));
1731 }
1732
1733 #[test]
1734 fn test_md060_auto_compact_warning_message() {
1735 let config = MD060Config {
1737 enabled: true,
1738 style: "aligned".to_string(),
1739 max_width: LineLength::from_const(50),
1740 column_align: ColumnAlign::Auto,
1741 column_align_header: None,
1742 column_align_body: None,
1743 loose_last_column: false,
1744 aligned_delimiter: false,
1745 };
1746 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1747
1748 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1751
1752 let warnings = rule.check(&ctx).unwrap();
1753
1754 assert!(!warnings.is_empty(), "Should generate warnings");
1756
1757 let auto_compact_warnings: Vec<_> = warnings
1758 .iter()
1759 .filter(|w| w.message.contains("too wide for aligned formatting"))
1760 .collect();
1761
1762 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1763
1764 let first_warning = auto_compact_warnings[0];
1766 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1767 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1768 }
1769
1770 #[test]
1771 fn test_md060_issue_129_detect_style_from_all_rows() {
1772 let rule = MD060TableFormat::new(true, "any".to_string());
1776
1777 let content = "| a long heading | another long heading |\n\
1779 | -------------- | -------------------- |\n\
1780 | a | 1 |\n\
1781 | b b | 2 |\n\
1782 | c c c | 3 |";
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784
1785 let fixed = rule.fix(&ctx).unwrap();
1786
1787 assert!(
1789 fixed.contains("| a | 1 |"),
1790 "Should preserve aligned padding in first content row"
1791 );
1792 assert!(
1793 fixed.contains("| b b | 2 |"),
1794 "Should preserve aligned padding in second content row"
1795 );
1796 assert!(
1797 fixed.contains("| c c c | 3 |"),
1798 "Should preserve aligned padding in third content row"
1799 );
1800
1801 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1803 }
1804
1805 #[test]
1806 fn test_md060_regular_alignment_warning_message() {
1807 let config = MD060Config {
1809 enabled: true,
1810 style: "aligned".to_string(),
1811 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1813 column_align_header: None,
1814 column_align_body: None,
1815 loose_last_column: false,
1816 aligned_delimiter: false,
1817 };
1818 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1819
1820 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823
1824 let warnings = rule.check(&ctx).unwrap();
1825
1826 assert!(!warnings.is_empty(), "Should generate warnings");
1828
1829 assert!(warnings[0].message.contains("Table columns should be aligned"));
1831 assert!(!warnings[0].message.contains("too wide"));
1832 assert!(!warnings[0].message.contains("max-width"));
1833 }
1834
1835 #[test]
1838 fn test_md060_unlimited_when_md013_disabled() {
1839 let config = MD060Config {
1841 enabled: true,
1842 style: "aligned".to_string(),
1843 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1845 column_align_header: None,
1846 column_align_body: None,
1847 loose_last_column: false,
1848 aligned_delimiter: false,
1849 };
1850 let md013_config = MD013Config::default();
1851 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1852
1853 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 let fixed = rule.fix(&ctx).unwrap();
1857
1858 let lines: Vec<&str> = fixed.lines().collect();
1860 assert_eq!(
1862 lines[0].len(),
1863 lines[1].len(),
1864 "Table should be aligned when MD013 is disabled"
1865 );
1866 }
1867
1868 #[test]
1869 fn test_md060_unlimited_when_md013_tables_false() {
1870 let config = MD060Config {
1872 enabled: true,
1873 style: "aligned".to_string(),
1874 max_width: LineLength::from_const(0),
1875 column_align: ColumnAlign::Auto,
1876 column_align_header: None,
1877 column_align_body: None,
1878 loose_last_column: false,
1879 aligned_delimiter: false,
1880 };
1881 let md013_config = MD013Config {
1882 tables: false, line_length: LineLength::from_const(80),
1884 ..Default::default()
1885 };
1886 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1887
1888 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1891 let fixed = rule.fix(&ctx).unwrap();
1892
1893 let lines: Vec<&str> = fixed.lines().collect();
1895 assert_eq!(
1896 lines[0].len(),
1897 lines[1].len(),
1898 "Table should be aligned when MD013.tables=false"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_md060_unlimited_when_md013_line_length_zero() {
1904 let config = MD060Config {
1906 enabled: true,
1907 style: "aligned".to_string(),
1908 max_width: LineLength::from_const(0),
1909 column_align: ColumnAlign::Auto,
1910 column_align_header: None,
1911 column_align_body: None,
1912 loose_last_column: false,
1913 aligned_delimiter: false,
1914 };
1915 let md013_config = MD013Config {
1916 tables: true,
1917 line_length: LineLength::from_const(0), ..Default::default()
1919 };
1920 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1921
1922 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let fixed = rule.fix(&ctx).unwrap();
1926
1927 let lines: Vec<&str> = fixed.lines().collect();
1929 assert_eq!(
1930 lines[0].len(),
1931 lines[1].len(),
1932 "Table should be aligned when MD013.line_length=0"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_md060_explicit_max_width_overrides_md013_settings() {
1938 let config = MD060Config {
1940 enabled: true,
1941 style: "aligned".to_string(),
1942 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1944 column_align_header: None,
1945 column_align_body: None,
1946 loose_last_column: false,
1947 aligned_delimiter: false,
1948 };
1949 let md013_config = MD013Config {
1950 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1953 };
1954 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1955
1956 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959 let fixed = rule.fix(&ctx).unwrap();
1960
1961 assert!(
1963 fixed.contains("| --- |"),
1964 "Should be compact format due to explicit max_width"
1965 );
1966 }
1967
1968 #[test]
1969 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1970 let config = MD060Config {
1972 enabled: true,
1973 style: "aligned".to_string(),
1974 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1976 column_align_header: None,
1977 column_align_body: None,
1978 loose_last_column: false,
1979 aligned_delimiter: false,
1980 };
1981 let md013_config = MD013Config {
1982 tables: true,
1983 line_length: LineLength::from_const(50), ..Default::default()
1985 };
1986 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1987
1988 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991 let fixed = rule.fix(&ctx).unwrap();
1992
1993 assert!(
1995 fixed.contains("| --- |"),
1996 "Should be compact format when inheriting MD013 limit"
1997 );
1998 }
1999
2000 #[test]
2003 fn test_aligned_no_space_reformats_spaced_delimiter() {
2004 let config = MD060Config {
2007 enabled: true,
2008 style: "aligned-no-space".to_string(),
2009 max_width: LineLength::from_const(0),
2010 column_align: ColumnAlign::Auto,
2011 column_align_header: None,
2012 column_align_body: None,
2013 loose_last_column: false,
2014 aligned_delimiter: false,
2015 };
2016 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2017
2018 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let fixed = rule.fix(&ctx).unwrap();
2022
2023 assert!(
2026 !fixed.contains("| ----"),
2027 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2028 );
2029 assert!(
2030 !fixed.contains("---- |"),
2031 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2032 );
2033 assert!(
2035 fixed.contains("|----"),
2036 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_aligned_reformats_compact_delimiter() {
2042 let config = MD060Config {
2045 enabled: true,
2046 style: "aligned".to_string(),
2047 max_width: LineLength::from_const(0),
2048 column_align: ColumnAlign::Auto,
2049 column_align_header: None,
2050 column_align_body: None,
2051 loose_last_column: false,
2052 aligned_delimiter: false,
2053 };
2054 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2055
2056 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059 let fixed = rule.fix(&ctx).unwrap();
2060
2061 assert!(
2063 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2064 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_aligned_no_space_preserves_matching_table() {
2070 let config = MD060Config {
2072 enabled: true,
2073 style: "aligned-no-space".to_string(),
2074 max_width: LineLength::from_const(0),
2075 column_align: ColumnAlign::Auto,
2076 column_align_header: None,
2077 column_align_body: None,
2078 loose_last_column: false,
2079 aligned_delimiter: false,
2080 };
2081 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2082
2083 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2086 let fixed = rule.fix(&ctx).unwrap();
2087
2088 assert_eq!(
2090 fixed, content,
2091 "Table already in aligned-no-space style should be preserved"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_aligned_preserves_matching_table() {
2097 let config = MD060Config {
2099 enabled: true,
2100 style: "aligned".to_string(),
2101 max_width: LineLength::from_const(0),
2102 column_align: ColumnAlign::Auto,
2103 column_align_header: None,
2104 column_align_body: None,
2105 loose_last_column: false,
2106 aligned_delimiter: false,
2107 };
2108 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2109
2110 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 let fixed = rule.fix(&ctx).unwrap();
2114
2115 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2117 }
2118
2119 #[test]
2120 fn test_cjk_table_display_width_consistency() {
2121 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2127
2128 let is_aligned =
2130 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2131 assert!(
2132 !is_aligned,
2133 "Table with uneven raw line lengths should NOT be considered aligned"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_cjk_width_calculation_in_aligned_check() {
2139 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2142 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2143
2144 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2145 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2146
2147 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2149 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2150
2151 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2153 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2155 }
2156
2157 #[test]
2160 fn test_md060_column_align_left() {
2161 let config = MD060Config {
2163 enabled: true,
2164 style: "aligned".to_string(),
2165 max_width: LineLength::from_const(0),
2166 column_align: ColumnAlign::Left,
2167 column_align_header: None,
2168 column_align_body: None,
2169 loose_last_column: false,
2170 aligned_delimiter: false,
2171 };
2172 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2173
2174 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176
2177 let fixed = rule.fix(&ctx).unwrap();
2178 let lines: Vec<&str> = fixed.lines().collect();
2179
2180 assert!(
2182 lines[2].contains("| Alice "),
2183 "Content should be left-aligned (Alice should have trailing padding)"
2184 );
2185 assert!(
2186 lines[3].contains("| Bob "),
2187 "Content should be left-aligned (Bob should have trailing padding)"
2188 );
2189 }
2190
2191 #[test]
2192 fn test_md060_column_align_center() {
2193 let config = MD060Config {
2195 enabled: true,
2196 style: "aligned".to_string(),
2197 max_width: LineLength::from_const(0),
2198 column_align: ColumnAlign::Center,
2199 column_align_header: None,
2200 column_align_body: None,
2201 loose_last_column: false,
2202 aligned_delimiter: false,
2203 };
2204 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2205
2206 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2208
2209 let fixed = rule.fix(&ctx).unwrap();
2210 let lines: Vec<&str> = fixed.lines().collect();
2211
2212 assert!(
2215 lines[3].contains("| Bob |"),
2216 "Bob should be centered with padding on both sides. Got: {}",
2217 lines[3]
2218 );
2219 }
2220
2221 #[test]
2222 fn test_md060_column_align_right() {
2223 let config = MD060Config {
2225 enabled: true,
2226 style: "aligned".to_string(),
2227 max_width: LineLength::from_const(0),
2228 column_align: ColumnAlign::Right,
2229 column_align_header: None,
2230 column_align_body: None,
2231 loose_last_column: false,
2232 aligned_delimiter: false,
2233 };
2234 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2235
2236 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2238
2239 let fixed = rule.fix(&ctx).unwrap();
2240 let lines: Vec<&str> = fixed.lines().collect();
2241
2242 assert!(
2244 lines[3].contains("| Bob |"),
2245 "Bob should be right-aligned with padding on left. Got: {}",
2246 lines[3]
2247 );
2248 }
2249
2250 #[test]
2251 fn test_md060_column_align_auto_respects_delimiter() {
2252 let config = MD060Config {
2254 enabled: true,
2255 style: "aligned".to_string(),
2256 max_width: LineLength::from_const(0),
2257 column_align: ColumnAlign::Auto,
2258 column_align_header: None,
2259 column_align_body: None,
2260 loose_last_column: false,
2261 aligned_delimiter: false,
2262 };
2263 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2264
2265 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2268
2269 let fixed = rule.fix(&ctx).unwrap();
2270
2271 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2273 let lines: Vec<&str> = fixed.lines().collect();
2275 assert!(
2279 lines[2].contains(" C |"),
2280 "Right column should be right-aligned. Got: {}",
2281 lines[2]
2282 );
2283 }
2284
2285 #[test]
2286 fn test_md060_column_align_overrides_delimiter_indicators() {
2287 let config = MD060Config {
2289 enabled: true,
2290 style: "aligned".to_string(),
2291 max_width: LineLength::from_const(0),
2292 column_align: ColumnAlign::Right, column_align_header: None,
2294 column_align_body: None,
2295 loose_last_column: false,
2296 aligned_delimiter: false,
2297 };
2298 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2299
2300 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2303
2304 let fixed = rule.fix(&ctx).unwrap();
2305 let lines: Vec<&str> = fixed.lines().collect();
2306
2307 assert!(
2310 lines[2].contains(" A |") || lines[2].contains(" A |"),
2311 "Even left-indicated column should be right-aligned. Got: {}",
2312 lines[2]
2313 );
2314 }
2315
2316 #[test]
2317 fn test_md060_column_align_with_aligned_no_space() {
2318 let config = MD060Config {
2320 enabled: true,
2321 style: "aligned-no-space".to_string(),
2322 max_width: LineLength::from_const(0),
2323 column_align: ColumnAlign::Center,
2324 column_align_header: None,
2325 column_align_body: None,
2326 loose_last_column: false,
2327 aligned_delimiter: false,
2328 };
2329 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2330
2331 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
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[1].contains("|---"),
2340 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2341 lines[1]
2342 );
2343 assert!(
2345 lines[3].contains("| Bob |"),
2346 "Content should be centered. Got: {}",
2347 lines[3]
2348 );
2349 }
2350
2351 #[test]
2352 fn test_md060_column_align_config_parsing() {
2353 let toml_str = r#"
2355enabled = true
2356style = "aligned"
2357column-align = "center"
2358"#;
2359 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2360 assert_eq!(config.column_align, ColumnAlign::Center);
2361
2362 let toml_str = r#"
2363enabled = true
2364style = "aligned"
2365column-align = "right"
2366"#;
2367 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2368 assert_eq!(config.column_align, ColumnAlign::Right);
2369
2370 let toml_str = r#"
2371enabled = true
2372style = "aligned"
2373column-align = "left"
2374"#;
2375 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2376 assert_eq!(config.column_align, ColumnAlign::Left);
2377
2378 let toml_str = r#"
2379enabled = true
2380style = "aligned"
2381column-align = "auto"
2382"#;
2383 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2384 assert_eq!(config.column_align, ColumnAlign::Auto);
2385 }
2386
2387 #[test]
2388 fn test_md060_column_align_default_is_auto() {
2389 let toml_str = r#"
2391enabled = true
2392style = "aligned"
2393"#;
2394 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2395 assert_eq!(config.column_align, ColumnAlign::Auto);
2396 }
2397
2398 #[test]
2399 fn test_md060_column_align_reformats_already_aligned_table() {
2400 let config = MD060Config {
2402 enabled: true,
2403 style: "aligned".to_string(),
2404 max_width: LineLength::from_const(0),
2405 column_align: ColumnAlign::Right,
2406 column_align_header: None,
2407 column_align_body: None,
2408 loose_last_column: false,
2409 aligned_delimiter: false,
2410 };
2411 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2412
2413 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2416
2417 let fixed = rule.fix(&ctx).unwrap();
2418 let lines: Vec<&str> = fixed.lines().collect();
2419
2420 assert!(
2422 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2423 "Already aligned table should be reformatted with right alignment. Got: {}",
2424 lines[2]
2425 );
2426 assert!(
2427 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2428 "Bob should be right-aligned. Got: {}",
2429 lines[3]
2430 );
2431 }
2432
2433 #[test]
2434 fn test_md060_column_align_with_cjk_characters() {
2435 let config = MD060Config {
2437 enabled: true,
2438 style: "aligned".to_string(),
2439 max_width: LineLength::from_const(0),
2440 column_align: ColumnAlign::Center,
2441 column_align_header: None,
2442 column_align_body: None,
2443 loose_last_column: false,
2444 aligned_delimiter: false,
2445 };
2446 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2447
2448 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2450
2451 let fixed = rule.fix(&ctx).unwrap();
2452
2453 assert!(fixed.contains("Bob"), "Table should contain Bob");
2456 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2457 }
2458
2459 #[test]
2460 fn test_md060_column_align_ignored_for_compact_style() {
2461 let config = MD060Config {
2463 enabled: true,
2464 style: "compact".to_string(),
2465 max_width: LineLength::from_const(0),
2466 column_align: ColumnAlign::Right, column_align_header: None,
2468 column_align_body: None,
2469 loose_last_column: false,
2470 aligned_delimiter: false,
2471 };
2472 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2473
2474 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2476
2477 let fixed = rule.fix(&ctx).unwrap();
2478
2479 assert!(
2481 fixed.contains("| Alice |"),
2482 "Compact style should have single space padding, not alignment. Got: {fixed}"
2483 );
2484 }
2485
2486 #[test]
2487 fn test_md060_column_align_ignored_for_tight_style() {
2488 let config = MD060Config {
2490 enabled: true,
2491 style: "tight".to_string(),
2492 max_width: LineLength::from_const(0),
2493 column_align: ColumnAlign::Center, column_align_header: None,
2495 column_align_body: None,
2496 loose_last_column: false,
2497 aligned_delimiter: false,
2498 };
2499 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2500
2501 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2503
2504 let fixed = rule.fix(&ctx).unwrap();
2505
2506 assert!(
2508 fixed.contains("|Alice|"),
2509 "Tight style should have no spaces. Got: {fixed}"
2510 );
2511 }
2512
2513 #[test]
2514 fn test_md060_column_align_with_empty_cells() {
2515 let config = MD060Config {
2517 enabled: true,
2518 style: "aligned".to_string(),
2519 max_width: LineLength::from_const(0),
2520 column_align: ColumnAlign::Center,
2521 column_align_header: None,
2522 column_align_body: None,
2523 loose_last_column: false,
2524 aligned_delimiter: false,
2525 };
2526 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2527
2528 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2530
2531 let fixed = rule.fix(&ctx).unwrap();
2532 let lines: Vec<&str> = fixed.lines().collect();
2533
2534 assert!(
2536 lines[3].contains("| |") || lines[3].contains("| |"),
2537 "Empty cell should be padded correctly. Got: {}",
2538 lines[3]
2539 );
2540 }
2541
2542 #[test]
2543 fn test_md060_column_align_auto_preserves_already_aligned() {
2544 let config = MD060Config {
2546 enabled: true,
2547 style: "aligned".to_string(),
2548 max_width: LineLength::from_const(0),
2549 column_align: ColumnAlign::Auto,
2550 column_align_header: None,
2551 column_align_body: None,
2552 loose_last_column: false,
2553 aligned_delimiter: false,
2554 };
2555 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2556
2557 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2560
2561 let fixed = rule.fix(&ctx).unwrap();
2562
2563 assert_eq!(
2565 fixed, content,
2566 "Already aligned table should be preserved with column-align=auto"
2567 );
2568 }
2569
2570 #[test]
2571 fn test_cjk_table_display_aligned_not_flagged() {
2572 use crate::config::MarkdownFlavor;
2576
2577 let table_lines: Vec<&str> = vec![
2579 "| Header | Name |",
2580 "| ------ | ---- |",
2581 "| Hello | Test |",
2582 "| δ½ ε₯½ | Test |",
2583 ];
2584
2585 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2586 assert!(
2587 result,
2588 "Table with CJK characters that is display-aligned should be recognized as aligned"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_cjk_table_not_reformatted_when_aligned() {
2594 let rule = MD060TableFormat::new(true, "aligned".to_string());
2596 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2599
2600 let fixed = rule.fix(&ctx).unwrap();
2602 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2603 }
2604
2605 #[test]
2621 fn md060_pandoc_grid_tables_not_flagged() {
2622 let rule = MD060TableFormat::new(true, "aligned".to_string());
2623 let content = "\
2624+---+---+
2625| a | b |
2626+===+===+
2627| 1 | 2 |
2628+---+---+
2629";
2630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2633 let result = rule.check(&ctx).unwrap();
2634 assert!(
2635 result.is_empty(),
2636 "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2637 );
2638
2639 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2640 let result_std = rule.check(&ctx_std).unwrap();
2641 assert!(
2642 result_std.is_empty(),
2643 "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2644 );
2645 }
2646
2647 #[test]
2648 fn md060_pandoc_multi_line_tables_not_flagged() {
2649 let rule = MD060TableFormat::new(true, "aligned".to_string());
2650 let content = "\
2651--------- -----------
2652Header 1 Header 2
2653--------- -----------
2654Cell 1 Cell 2
2655--------- -----------
2656";
2657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2658 let result = rule.check(&ctx).unwrap();
2659 assert!(
2660 result.is_empty(),
2661 "MD060 should not flag Pandoc multi-line tables: {result:?}"
2662 );
2663
2664 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2665 let result_std = rule.check(&ctx_std).unwrap();
2666 assert!(
2667 result_std.is_empty(),
2668 "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2669 );
2670 }
2671
2672 #[test]
2673 fn md060_pandoc_line_blocks_not_flagged() {
2674 let rule = MD060TableFormat::new(true, "aligned".to_string());
2675 let content = "| First line\n| Second line\n";
2677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2678 let result = rule.check(&ctx).unwrap();
2679 assert!(
2680 result.is_empty(),
2681 "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2682 );
2683
2684 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2685 let result_std = rule.check(&ctx_std).unwrap();
2686 assert!(
2687 result_std.is_empty(),
2688 "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2689 );
2690 }
2691
2692 #[test]
2693 fn md060_pandoc_pipe_table_captions_not_flagged() {
2694 let rule = MD060TableFormat::new(true, "aligned".to_string());
2695 let content = "\
2698| H1 | H2 |
2699| -- | -- |
2700| a | b |
2701
2702: My table caption
2703";
2704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2705 let result = rule.check(&ctx).unwrap();
2706 assert!(
2707 result.is_empty(),
2708 "MD060 should not flag the pipe-table caption line: {result:?}"
2709 );
2710
2711 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2712 let result_std = rule.check(&ctx_std).unwrap();
2713 assert!(
2714 result_std.is_empty(),
2715 "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2716 );
2717 }
2718
2719 #[test]
2720 fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2721 let rule = MD060TableFormat::new(true, "aligned".to_string());
2725
2726 for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
2728 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2729 assert_eq!(
2730 rule.fix(&ctx).unwrap(),
2731 input,
2732 "MD060 must not alter table-free content: {input:?}"
2733 );
2734 }
2735
2736 let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
2739 let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
2740 let once = rule.fix(&ctx).unwrap();
2741 assert!(
2742 once.ends_with("\n\n\n"),
2743 "trailing blank lines must be preserved, got: {once:?}"
2744 );
2745 let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
2746 let twice = rule.fix(&ctx2).unwrap();
2747 assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
2748 }
2749}