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