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() {
904 auto_compacted = true;
905 for line in &stripped_lines {
906 let cells = Self::parse_table_row_with_flavor(line, flavor);
907 result.push(Self::format_table_compact(&cells));
908 }
909 } else {
910 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
912 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
913
914 for (row_idx, line) in stripped_lines.iter().enumerate() {
915 let cells = Self::parse_table_row_with_flavor(line, flavor);
916 let row_type = match row_idx {
917 0 => RowType::Header,
918 1 => RowType::Delimiter,
919 _ => RowType::Body,
920 };
921 let options = RowFormatOptions {
922 row_type,
923 compact_delimiter,
924 column_align: self.config.column_align,
925 column_align_header: self.config.column_align_header,
926 column_align_body: self.config.column_align_body,
927 };
928 result.push(Self::format_table_row(
929 &cells,
930 &column_widths,
931 &column_alignments,
932 &options,
933 ));
934 }
935 }
936 }
937 _ => {
938 return TableFormatResult {
939 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
940 auto_compacted: false,
941 aligned_width: None,
942 };
943 }
944 }
945
946 let prefixed_result: Vec<String> = result
948 .into_iter()
949 .enumerate()
950 .map(|(i, line)| {
951 if list_context.is_some() {
952 if i == 0 {
953 format!("{blockquote_prefix}{list_prefix}{line}")
955 } else {
956 format!("{blockquote_prefix}{continuation_indent}{line}")
958 }
959 } else {
960 format!("{blockquote_prefix}{line}")
961 }
962 })
963 .collect();
964
965 TableFormatResult {
966 lines: prefixed_result,
967 auto_compacted,
968 aligned_width,
969 }
970 }
971}
972
973impl Rule for MD060TableFormat {
974 fn name(&self) -> &'static str {
975 "MD060"
976 }
977
978 fn description(&self) -> &'static str {
979 "Table columns should be consistently aligned"
980 }
981
982 fn category(&self) -> RuleCategory {
983 RuleCategory::Table
984 }
985
986 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
987 !ctx.likely_has_tables()
988 }
989
990 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
991 let line_index = &ctx.line_index;
992 let mut warnings = Vec::new();
993
994 let lines = ctx.raw_lines();
995 let table_blocks = &ctx.table_blocks;
996
997 for table_block in table_blocks {
998 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
999
1000 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1001 .chain(std::iter::once(table_block.delimiter_line))
1002 .chain(table_block.content_lines.iter().copied())
1003 .collect();
1004
1005 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());
1012 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1013 let fixed_line = &format_result.lines[i];
1014 if line_idx < lines.len() - 1 {
1016 fixed_table_lines.push(format!("{fixed_line}\n"));
1017 } else {
1018 fixed_table_lines.push(fixed_line.clone());
1019 }
1020 }
1021 let table_replacement = fixed_table_lines.concat();
1022 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
1023
1024 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1025 let original = lines[line_idx];
1026 let fixed = &format_result.lines[i];
1027
1028 if original != fixed {
1029 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1030
1031 let message = if format_result.auto_compacted {
1032 if let Some(width) = format_result.aligned_width {
1033 format!(
1034 "Table too wide for aligned formatting ({} chars > max-width: {})",
1035 width,
1036 self.effective_max_width()
1037 )
1038 } else {
1039 "Table too wide for aligned formatting".to_string()
1040 }
1041 } else {
1042 "Table columns should be aligned".to_string()
1043 };
1044
1045 warnings.push(LintWarning {
1048 rule_name: Some(self.name().to_string()),
1049 severity: Severity::Warning,
1050 message,
1051 line: start_line,
1052 column: start_col,
1053 end_line,
1054 end_column: end_col,
1055 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1056 });
1057 }
1058 }
1059 }
1060
1061 Ok(warnings)
1062 }
1063
1064 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1065 let content = ctx.content;
1066 let lines = ctx.raw_lines();
1067 let table_blocks = &ctx.table_blocks;
1068
1069 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1070
1071 for table_block in table_blocks {
1072 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1073
1074 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1075 .chain(std::iter::once(table_block.delimiter_line))
1076 .chain(table_block.content_lines.iter().copied())
1077 .collect();
1078
1079 let any_disabled = table_line_indices
1082 .iter()
1083 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1084
1085 if any_disabled {
1086 continue;
1087 }
1088
1089 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1090 result_lines[line_idx].clone_from(&format_result.lines[i]);
1091 }
1092 }
1093
1094 let mut fixed = result_lines.join("\n");
1095 if content.ends_with('\n') && !fixed.ends_with('\n') {
1096 fixed.push('\n');
1097 }
1098 Ok(fixed)
1099 }
1100
1101 fn as_any(&self) -> &dyn std::any::Any {
1102 self
1103 }
1104
1105 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1106 let table = crate::rule_config_serde::config_schema_table(&MD060Config::default())?;
1107 Some((MD060Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1108 }
1109
1110 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1111 where
1112 Self: Sized,
1113 {
1114 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1115 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1116
1117 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1119
1120 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1121 }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127 use crate::lint_context::LintContext;
1128 use crate::types::LineLength;
1129
1130 fn md013_with_line_length(line_length: usize) -> MD013Config {
1132 MD013Config {
1133 line_length: LineLength::from_const(line_length),
1134 tables: true, ..Default::default()
1136 }
1137 }
1138
1139 #[test]
1140 fn test_md060_align_simple_ascii_table() {
1141 let rule = MD060TableFormat::new(true, "aligned".to_string());
1142
1143 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145
1146 let fixed = rule.fix(&ctx).unwrap();
1147 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1148 assert_eq!(fixed, expected);
1149
1150 let lines: Vec<&str> = fixed.lines().collect();
1152 assert_eq!(lines[0].len(), lines[1].len());
1153 assert_eq!(lines[1].len(), lines[2].len());
1154 }
1155
1156 #[test]
1157 fn test_md060_cjk_characters_aligned_correctly() {
1158 let rule = MD060TableFormat::new(true, "aligned".to_string());
1159
1160 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162
1163 let fixed = rule.fix(&ctx).unwrap();
1164
1165 let lines: Vec<&str> = fixed.lines().collect();
1166 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1167 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1168
1169 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1170 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1171
1172 assert_eq!(width1, width3);
1173 }
1174
1175 #[test]
1176 fn test_md060_basic_emoji() {
1177 let rule = MD060TableFormat::new(true, "aligned".to_string());
1178
1179 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181
1182 let fixed = rule.fix(&ctx).unwrap();
1183 assert!(fixed.contains("Status"));
1184 }
1185
1186 #[test]
1187 fn test_md060_zwj_emoji_skipped() {
1188 let rule = MD060TableFormat::new(true, "aligned".to_string());
1189
1190 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192
1193 let fixed = rule.fix(&ctx).unwrap();
1194 assert_eq!(fixed, content);
1195 }
1196
1197 #[test]
1198 fn test_md060_inline_code_with_escaped_pipes() {
1199 let rule = MD060TableFormat::new(true, "aligned".to_string());
1202
1203 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206
1207 let fixed = rule.fix(&ctx).unwrap();
1208 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1209 }
1210
1211 #[test]
1212 fn test_md060_compact_style() {
1213 let rule = MD060TableFormat::new(true, "compact".to_string());
1214
1215 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1217
1218 let fixed = rule.fix(&ctx).unwrap();
1219 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1220 assert_eq!(fixed, expected);
1221 }
1222
1223 #[test]
1224 fn test_md060_tight_style() {
1225 let rule = MD060TableFormat::new(true, "tight".to_string());
1226
1227 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229
1230 let fixed = rule.fix(&ctx).unwrap();
1231 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1232 assert_eq!(fixed, expected);
1233 }
1234
1235 #[test]
1236 fn test_md060_aligned_no_space_style() {
1237 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1239
1240 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242
1243 let fixed = rule.fix(&ctx).unwrap();
1244
1245 let lines: Vec<&str> = fixed.lines().collect();
1247 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1248 assert_eq!(
1249 lines[1], "|-------|-----|",
1250 "Delimiter should have NO spaces around dashes"
1251 );
1252 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1253
1254 assert_eq!(lines[0].len(), lines[1].len());
1256 assert_eq!(lines[1].len(), lines[2].len());
1257 }
1258
1259 #[test]
1260 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1261 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1263
1264 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266
1267 let fixed = rule.fix(&ctx).unwrap();
1268 let lines: Vec<&str> = fixed.lines().collect();
1269
1270 assert!(
1272 fixed.contains("|:"),
1273 "Should have left alignment indicator adjacent to pipe"
1274 );
1275 assert!(
1276 fixed.contains(":|"),
1277 "Should have right alignment indicator adjacent to pipe"
1278 );
1279 assert!(
1281 lines[1].contains(":---") && lines[1].contains("---:"),
1282 "Should have center alignment colons"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_md060_aligned_no_space_three_column_table() {
1288 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1290
1291 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 |";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293
1294 let fixed = rule.fix(&ctx).unwrap();
1295 let lines: Vec<&str> = fixed.lines().collect();
1296
1297 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1299 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1300 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1301 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1302 }
1303
1304 #[test]
1305 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1306 let config = MD060Config {
1308 enabled: true,
1309 style: "aligned-no-space".to_string(),
1310 max_width: LineLength::from_const(50),
1311 column_align: ColumnAlign::Auto,
1312 column_align_header: None,
1313 column_align_body: None,
1314 loose_last_column: false,
1315 aligned_delimiter: false,
1316 };
1317 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1318
1319 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322
1323 let fixed = rule.fix(&ctx).unwrap();
1324
1325 assert!(
1327 fixed.contains("| --- |"),
1328 "Should be compact format when exceeding max-width"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_md060_aligned_no_space_cjk_characters() {
1334 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1336
1337 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339
1340 let fixed = rule.fix(&ctx).unwrap();
1341 let lines: Vec<&str> = fixed.lines().collect();
1342
1343 use unicode_width::UnicodeWidthStr;
1346 assert_eq!(
1347 lines[0].width(),
1348 lines[1].width(),
1349 "Header and delimiter should have same display width"
1350 );
1351 assert_eq!(
1352 lines[1].width(),
1353 lines[2].width(),
1354 "Delimiter and content should have same display width"
1355 );
1356
1357 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1359 }
1360
1361 #[test]
1362 fn test_md060_aligned_no_space_minimum_width() {
1363 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1365
1366 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368
1369 let fixed = rule.fix(&ctx).unwrap();
1370 let lines: Vec<&str> = fixed.lines().collect();
1371
1372 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1374 assert_eq!(lines[0].len(), lines[1].len());
1376 assert_eq!(lines[1].len(), lines[2].len());
1377 }
1378
1379 #[test]
1380 fn test_md060_any_style_consistency() {
1381 let rule = MD060TableFormat::new(true, "any".to_string());
1382
1383 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386
1387 let fixed = rule.fix(&ctx).unwrap();
1388 assert_eq!(fixed, content);
1389
1390 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1392 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1393
1394 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1395 assert_eq!(fixed_aligned, content_aligned);
1396 }
1397
1398 #[test]
1399 fn test_md060_empty_cells() {
1400 let rule = MD060TableFormat::new(true, "aligned".to_string());
1401
1402 let content = "| A | B |\n|---|---|\n| | X |";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404
1405 let fixed = rule.fix(&ctx).unwrap();
1406 assert!(fixed.contains('|'));
1407 }
1408
1409 #[test]
1410 fn test_md060_mixed_content() {
1411 let rule = MD060TableFormat::new(true, "aligned".to_string());
1412
1413 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415
1416 let fixed = rule.fix(&ctx).unwrap();
1417 assert!(fixed.contains("δΈζ"));
1418 assert!(fixed.contains("NYC"));
1419 }
1420
1421 #[test]
1422 fn test_md060_preserve_alignment_indicators() {
1423 let rule = MD060TableFormat::new(true, "aligned".to_string());
1424
1425 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427
1428 let fixed = rule.fix(&ctx).unwrap();
1429
1430 assert!(fixed.contains(":---"), "Should contain left alignment");
1431 assert!(fixed.contains(":----:"), "Should contain center alignment");
1432 assert!(fixed.contains("----:"), "Should contain right alignment");
1433 }
1434
1435 #[test]
1436 fn test_md060_minimum_column_width() {
1437 let rule = MD060TableFormat::new(true, "aligned".to_string());
1438
1439 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443
1444 let fixed = rule.fix(&ctx).unwrap();
1445
1446 let lines: Vec<&str> = fixed.lines().collect();
1447 assert_eq!(lines[0].len(), lines[1].len());
1448 assert_eq!(lines[1].len(), lines[2].len());
1449
1450 assert!(fixed.contains("ID "), "Short content should be padded");
1452 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1453 }
1454
1455 #[test]
1456 fn test_md060_auto_compact_exceeds_default_threshold() {
1457 let config = MD060Config {
1459 enabled: true,
1460 style: "aligned".to_string(),
1461 max_width: LineLength::from_const(0),
1462 column_align: ColumnAlign::Auto,
1463 column_align_header: None,
1464 column_align_body: None,
1465 loose_last_column: false,
1466 aligned_delimiter: false,
1467 };
1468 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1469
1470 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475
1476 let fixed = rule.fix(&ctx).unwrap();
1477
1478 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1480 assert!(fixed.contains("| --- | --- | --- |"));
1481 assert!(fixed.contains("| Short | Data | Here |"));
1482
1483 let lines: Vec<&str> = fixed.lines().collect();
1485 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1487 }
1488
1489 #[test]
1490 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1491 let config = MD060Config {
1493 enabled: true,
1494 style: "aligned".to_string(),
1495 max_width: LineLength::from_const(50),
1496 column_align: ColumnAlign::Auto,
1497 column_align_header: None,
1498 column_align_body: None,
1499 loose_last_column: false,
1500 aligned_delimiter: false,
1501 };
1502 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 |";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509
1510 let fixed = rule.fix(&ctx).unwrap();
1511
1512 assert!(
1514 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1515 );
1516 assert!(fixed.contains("| --- | --- | --- |"));
1517 assert!(fixed.contains("| Data | Data | Data |"));
1518
1519 let lines: Vec<&str> = fixed.lines().collect();
1521 assert!(lines[0].len() != lines[2].len());
1522 }
1523
1524 #[test]
1525 fn test_md060_stays_aligned_under_threshold() {
1526 let config = MD060Config {
1528 enabled: true,
1529 style: "aligned".to_string(),
1530 max_width: LineLength::from_const(100),
1531 column_align: ColumnAlign::Auto,
1532 column_align_header: None,
1533 column_align_body: None,
1534 loose_last_column: false,
1535 aligned_delimiter: false,
1536 };
1537 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1538
1539 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542
1543 let fixed = rule.fix(&ctx).unwrap();
1544
1545 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1547 assert_eq!(fixed, expected);
1548
1549 let lines: Vec<&str> = fixed.lines().collect();
1550 assert_eq!(lines[0].len(), lines[1].len());
1551 assert_eq!(lines[1].len(), lines[2].len());
1552 }
1553
1554 #[test]
1555 fn test_md060_width_calculation_formula() {
1556 let config = MD060Config {
1558 enabled: true,
1559 style: "aligned".to_string(),
1560 max_width: LineLength::from_const(0),
1561 column_align: ColumnAlign::Auto,
1562 column_align_header: None,
1563 column_align_body: None,
1564 loose_last_column: false,
1565 aligned_delimiter: false,
1566 };
1567 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1568
1569 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574
1575 let fixed = rule.fix(&ctx).unwrap();
1576
1577 let lines: Vec<&str> = fixed.lines().collect();
1579 assert_eq!(lines[0].len(), lines[1].len());
1580 assert_eq!(lines[1].len(), lines[2].len());
1581 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1585 enabled: true,
1586 style: "aligned".to_string(),
1587 max_width: LineLength::from_const(24),
1588 column_align: ColumnAlign::Auto,
1589 column_align_header: None,
1590 column_align_body: None,
1591 loose_last_column: false,
1592 aligned_delimiter: false,
1593 };
1594 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1595
1596 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1597
1598 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1600 assert!(fixed_compact.contains("| --- | --- | --- |"));
1601 }
1602
1603 #[test]
1604 fn test_md060_very_wide_table_auto_compacts() {
1605 let config = MD060Config {
1606 enabled: true,
1607 style: "aligned".to_string(),
1608 max_width: LineLength::from_const(0),
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 = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1616
1617 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 |";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622
1623 let fixed = rule.fix(&ctx).unwrap();
1624
1625 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1627 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1628 }
1629
1630 #[test]
1631 fn test_md060_inherit_from_md013_line_length() {
1632 let config = MD060Config {
1634 enabled: true,
1635 style: "aligned".to_string(),
1636 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1638 column_align_header: None,
1639 column_align_body: None,
1640 loose_last_column: false,
1641 aligned_delimiter: false,
1642 };
1643
1644 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1646 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1647
1648 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651
1652 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1654
1655 let fixed_120 = rule_120.fix(&ctx).unwrap();
1657
1658 let lines_120: Vec<&str> = fixed_120.lines().collect();
1660 assert_eq!(lines_120[0].len(), lines_120[1].len());
1661 assert_eq!(lines_120[1].len(), lines_120[2].len());
1662 }
1663
1664 #[test]
1665 fn test_md060_edge_case_exactly_at_threshold() {
1666 let config = MD060Config {
1670 enabled: true,
1671 style: "aligned".to_string(),
1672 max_width: LineLength::from_const(17),
1673 column_align: ColumnAlign::Auto,
1674 column_align_header: None,
1675 column_align_body: None,
1676 loose_last_column: false,
1677 aligned_delimiter: false,
1678 };
1679 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1680
1681 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683
1684 let fixed = rule.fix(&ctx).unwrap();
1685
1686 let lines: Vec<&str> = fixed.lines().collect();
1688 assert_eq!(lines[0].len(), 17);
1689 assert_eq!(lines[0].len(), lines[1].len());
1690 assert_eq!(lines[1].len(), lines[2].len());
1691
1692 let config_under = MD060Config {
1694 enabled: true,
1695 style: "aligned".to_string(),
1696 max_width: LineLength::from_const(16),
1697 column_align: ColumnAlign::Auto,
1698 column_align_header: None,
1699 column_align_body: None,
1700 loose_last_column: false,
1701 aligned_delimiter: false,
1702 };
1703 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1704
1705 let fixed_compact = rule_under.fix(&ctx).unwrap();
1706
1707 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1709 assert!(fixed_compact.contains("| --- | --- |"));
1710 }
1711
1712 #[test]
1713 fn test_md060_auto_compact_warning_message() {
1714 let config = MD060Config {
1716 enabled: true,
1717 style: "aligned".to_string(),
1718 max_width: LineLength::from_const(50),
1719 column_align: ColumnAlign::Auto,
1720 column_align_header: None,
1721 column_align_body: None,
1722 loose_last_column: false,
1723 aligned_delimiter: false,
1724 };
1725 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1726
1727 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1730
1731 let warnings = rule.check(&ctx).unwrap();
1732
1733 assert!(!warnings.is_empty(), "Should generate warnings");
1735
1736 let auto_compact_warnings: Vec<_> = warnings
1737 .iter()
1738 .filter(|w| w.message.contains("too wide for aligned formatting"))
1739 .collect();
1740
1741 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1742
1743 let first_warning = auto_compact_warnings[0];
1745 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1746 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1747 }
1748
1749 #[test]
1750 fn test_md060_issue_129_detect_style_from_all_rows() {
1751 let rule = MD060TableFormat::new(true, "any".to_string());
1755
1756 let content = "| a long heading | another long heading |\n\
1758 | -------------- | -------------------- |\n\
1759 | a | 1 |\n\
1760 | b b | 2 |\n\
1761 | c c c | 3 |";
1762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763
1764 let fixed = rule.fix(&ctx).unwrap();
1765
1766 assert!(
1768 fixed.contains("| a | 1 |"),
1769 "Should preserve aligned padding in first content row"
1770 );
1771 assert!(
1772 fixed.contains("| b b | 2 |"),
1773 "Should preserve aligned padding in second content row"
1774 );
1775 assert!(
1776 fixed.contains("| c c c | 3 |"),
1777 "Should preserve aligned padding in third content row"
1778 );
1779
1780 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1782 }
1783
1784 #[test]
1785 fn test_md060_regular_alignment_warning_message() {
1786 let config = MD060Config {
1788 enabled: true,
1789 style: "aligned".to_string(),
1790 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1792 column_align_header: None,
1793 column_align_body: None,
1794 loose_last_column: false,
1795 aligned_delimiter: false,
1796 };
1797 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1798
1799 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802
1803 let warnings = rule.check(&ctx).unwrap();
1804
1805 assert!(!warnings.is_empty(), "Should generate warnings");
1807
1808 assert!(warnings[0].message.contains("Table columns should be aligned"));
1810 assert!(!warnings[0].message.contains("too wide"));
1811 assert!(!warnings[0].message.contains("max-width"));
1812 }
1813
1814 #[test]
1817 fn test_md060_unlimited_when_md013_disabled() {
1818 let config = MD060Config {
1820 enabled: true,
1821 style: "aligned".to_string(),
1822 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1824 column_align_header: None,
1825 column_align_body: None,
1826 loose_last_column: false,
1827 aligned_delimiter: false,
1828 };
1829 let md013_config = MD013Config::default();
1830 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1831
1832 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let fixed = rule.fix(&ctx).unwrap();
1836
1837 let lines: Vec<&str> = fixed.lines().collect();
1839 assert_eq!(
1841 lines[0].len(),
1842 lines[1].len(),
1843 "Table should be aligned when MD013 is disabled"
1844 );
1845 }
1846
1847 #[test]
1848 fn test_md060_unlimited_when_md013_tables_false() {
1849 let config = MD060Config {
1851 enabled: true,
1852 style: "aligned".to_string(),
1853 max_width: LineLength::from_const(0),
1854 column_align: ColumnAlign::Auto,
1855 column_align_header: None,
1856 column_align_body: None,
1857 loose_last_column: false,
1858 aligned_delimiter: false,
1859 };
1860 let md013_config = MD013Config {
1861 tables: false, line_length: LineLength::from_const(80),
1863 ..Default::default()
1864 };
1865 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1866
1867 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let fixed = rule.fix(&ctx).unwrap();
1871
1872 let lines: Vec<&str> = fixed.lines().collect();
1874 assert_eq!(
1875 lines[0].len(),
1876 lines[1].len(),
1877 "Table should be aligned when MD013.tables=false"
1878 );
1879 }
1880
1881 #[test]
1882 fn test_md060_unlimited_when_md013_line_length_zero() {
1883 let config = MD060Config {
1885 enabled: true,
1886 style: "aligned".to_string(),
1887 max_width: LineLength::from_const(0),
1888 column_align: ColumnAlign::Auto,
1889 column_align_header: None,
1890 column_align_body: None,
1891 loose_last_column: false,
1892 aligned_delimiter: false,
1893 };
1894 let md013_config = MD013Config {
1895 tables: true,
1896 line_length: LineLength::from_const(0), ..Default::default()
1898 };
1899 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1900
1901 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904 let fixed = rule.fix(&ctx).unwrap();
1905
1906 let lines: Vec<&str> = fixed.lines().collect();
1908 assert_eq!(
1909 lines[0].len(),
1910 lines[1].len(),
1911 "Table should be aligned when MD013.line_length=0"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_md060_explicit_max_width_overrides_md013_settings() {
1917 let config = MD060Config {
1919 enabled: true,
1920 style: "aligned".to_string(),
1921 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1923 column_align_header: None,
1924 column_align_body: None,
1925 loose_last_column: false,
1926 aligned_delimiter: false,
1927 };
1928 let md013_config = MD013Config {
1929 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1932 };
1933 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1934
1935 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1938 let fixed = rule.fix(&ctx).unwrap();
1939
1940 assert!(
1942 fixed.contains("| --- |"),
1943 "Should be compact format due to explicit max_width"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1949 let config = MD060Config {
1951 enabled: true,
1952 style: "aligned".to_string(),
1953 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1955 column_align_header: None,
1956 column_align_body: None,
1957 loose_last_column: false,
1958 aligned_delimiter: false,
1959 };
1960 let md013_config = MD013Config {
1961 tables: true,
1962 line_length: LineLength::from_const(50), ..Default::default()
1964 };
1965 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1966
1967 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1970 let fixed = rule.fix(&ctx).unwrap();
1971
1972 assert!(
1974 fixed.contains("| --- |"),
1975 "Should be compact format when inheriting MD013 limit"
1976 );
1977 }
1978
1979 #[test]
1982 fn test_aligned_no_space_reformats_spaced_delimiter() {
1983 let config = MD060Config {
1986 enabled: true,
1987 style: "aligned-no-space".to_string(),
1988 max_width: LineLength::from_const(0),
1989 column_align: ColumnAlign::Auto,
1990 column_align_header: None,
1991 column_align_body: None,
1992 loose_last_column: false,
1993 aligned_delimiter: false,
1994 };
1995 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1996
1997 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000 let fixed = rule.fix(&ctx).unwrap();
2001
2002 assert!(
2005 !fixed.contains("| ----"),
2006 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2007 );
2008 assert!(
2009 !fixed.contains("---- |"),
2010 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2011 );
2012 assert!(
2014 fixed.contains("|----"),
2015 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2016 );
2017 }
2018
2019 #[test]
2020 fn test_aligned_reformats_compact_delimiter() {
2021 let config = MD060Config {
2024 enabled: true,
2025 style: "aligned".to_string(),
2026 max_width: LineLength::from_const(0),
2027 column_align: ColumnAlign::Auto,
2028 column_align_header: None,
2029 column_align_body: None,
2030 loose_last_column: false,
2031 aligned_delimiter: false,
2032 };
2033 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2034
2035 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let fixed = rule.fix(&ctx).unwrap();
2039
2040 assert!(
2042 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2043 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2044 );
2045 }
2046
2047 #[test]
2048 fn test_aligned_no_space_preserves_matching_table() {
2049 let config = MD060Config {
2051 enabled: true,
2052 style: "aligned-no-space".to_string(),
2053 max_width: LineLength::from_const(0),
2054 column_align: ColumnAlign::Auto,
2055 column_align_header: None,
2056 column_align_body: None,
2057 loose_last_column: false,
2058 aligned_delimiter: false,
2059 };
2060 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2061
2062 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2065 let fixed = rule.fix(&ctx).unwrap();
2066
2067 assert_eq!(
2069 fixed, content,
2070 "Table already in aligned-no-space style should be preserved"
2071 );
2072 }
2073
2074 #[test]
2075 fn test_aligned_preserves_matching_table() {
2076 let config = MD060Config {
2078 enabled: true,
2079 style: "aligned".to_string(),
2080 max_width: LineLength::from_const(0),
2081 column_align: ColumnAlign::Auto,
2082 column_align_header: None,
2083 column_align_body: None,
2084 loose_last_column: false,
2085 aligned_delimiter: false,
2086 };
2087 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2088
2089 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2092 let fixed = rule.fix(&ctx).unwrap();
2093
2094 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2096 }
2097
2098 #[test]
2099 fn test_cjk_table_display_width_consistency() {
2100 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2106
2107 let is_aligned =
2109 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2110 assert!(
2111 !is_aligned,
2112 "Table with uneven raw line lengths should NOT be considered aligned"
2113 );
2114 }
2115
2116 #[test]
2117 fn test_cjk_width_calculation_in_aligned_check() {
2118 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2121 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2122
2123 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2124 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2125
2126 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2128 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2129
2130 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2132 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2134 }
2135
2136 #[test]
2139 fn test_md060_column_align_left() {
2140 let config = MD060Config {
2142 enabled: true,
2143 style: "aligned".to_string(),
2144 max_width: LineLength::from_const(0),
2145 column_align: ColumnAlign::Left,
2146 column_align_header: None,
2147 column_align_body: None,
2148 loose_last_column: false,
2149 aligned_delimiter: false,
2150 };
2151 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2152
2153 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155
2156 let fixed = rule.fix(&ctx).unwrap();
2157 let lines: Vec<&str> = fixed.lines().collect();
2158
2159 assert!(
2161 lines[2].contains("| Alice "),
2162 "Content should be left-aligned (Alice should have trailing padding)"
2163 );
2164 assert!(
2165 lines[3].contains("| Bob "),
2166 "Content should be left-aligned (Bob should have trailing padding)"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_md060_column_align_center() {
2172 let config = MD060Config {
2174 enabled: true,
2175 style: "aligned".to_string(),
2176 max_width: LineLength::from_const(0),
2177 column_align: ColumnAlign::Center,
2178 column_align_header: None,
2179 column_align_body: None,
2180 loose_last_column: false,
2181 aligned_delimiter: false,
2182 };
2183 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2184
2185 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2187
2188 let fixed = rule.fix(&ctx).unwrap();
2189 let lines: Vec<&str> = fixed.lines().collect();
2190
2191 assert!(
2194 lines[3].contains("| Bob |"),
2195 "Bob should be centered with padding on both sides. Got: {}",
2196 lines[3]
2197 );
2198 }
2199
2200 #[test]
2201 fn test_md060_column_align_right() {
2202 let config = MD060Config {
2204 enabled: true,
2205 style: "aligned".to_string(),
2206 max_width: LineLength::from_const(0),
2207 column_align: ColumnAlign::Right,
2208 column_align_header: None,
2209 column_align_body: None,
2210 loose_last_column: false,
2211 aligned_delimiter: false,
2212 };
2213 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2214
2215 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217
2218 let fixed = rule.fix(&ctx).unwrap();
2219 let lines: Vec<&str> = fixed.lines().collect();
2220
2221 assert!(
2223 lines[3].contains("| Bob |"),
2224 "Bob should be right-aligned with padding on left. Got: {}",
2225 lines[3]
2226 );
2227 }
2228
2229 #[test]
2230 fn test_md060_column_align_auto_respects_delimiter() {
2231 let config = MD060Config {
2233 enabled: true,
2234 style: "aligned".to_string(),
2235 max_width: LineLength::from_const(0),
2236 column_align: ColumnAlign::Auto,
2237 column_align_header: None,
2238 column_align_body: None,
2239 loose_last_column: false,
2240 aligned_delimiter: false,
2241 };
2242 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2243
2244 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2247
2248 let fixed = rule.fix(&ctx).unwrap();
2249
2250 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2252 let lines: Vec<&str> = fixed.lines().collect();
2254 assert!(
2258 lines[2].contains(" C |"),
2259 "Right column should be right-aligned. Got: {}",
2260 lines[2]
2261 );
2262 }
2263
2264 #[test]
2265 fn test_md060_column_align_overrides_delimiter_indicators() {
2266 let config = MD060Config {
2268 enabled: true,
2269 style: "aligned".to_string(),
2270 max_width: LineLength::from_const(0),
2271 column_align: ColumnAlign::Right, column_align_header: None,
2273 column_align_body: None,
2274 loose_last_column: false,
2275 aligned_delimiter: false,
2276 };
2277 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2278
2279 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2282
2283 let fixed = rule.fix(&ctx).unwrap();
2284 let lines: Vec<&str> = fixed.lines().collect();
2285
2286 assert!(
2289 lines[2].contains(" A |") || lines[2].contains(" A |"),
2290 "Even left-indicated column should be right-aligned. Got: {}",
2291 lines[2]
2292 );
2293 }
2294
2295 #[test]
2296 fn test_md060_column_align_with_aligned_no_space() {
2297 let config = MD060Config {
2299 enabled: true,
2300 style: "aligned-no-space".to_string(),
2301 max_width: LineLength::from_const(0),
2302 column_align: ColumnAlign::Center,
2303 column_align_header: None,
2304 column_align_body: None,
2305 loose_last_column: false,
2306 aligned_delimiter: false,
2307 };
2308 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2309
2310 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2312
2313 let fixed = rule.fix(&ctx).unwrap();
2314 let lines: Vec<&str> = fixed.lines().collect();
2315
2316 assert!(
2318 lines[1].contains("|---"),
2319 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2320 lines[1]
2321 );
2322 assert!(
2324 lines[3].contains("| Bob |"),
2325 "Content should be centered. Got: {}",
2326 lines[3]
2327 );
2328 }
2329
2330 #[test]
2331 fn test_md060_column_align_config_parsing() {
2332 let toml_str = r#"
2334enabled = true
2335style = "aligned"
2336column-align = "center"
2337"#;
2338 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2339 assert_eq!(config.column_align, ColumnAlign::Center);
2340
2341 let toml_str = r#"
2342enabled = true
2343style = "aligned"
2344column-align = "right"
2345"#;
2346 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2347 assert_eq!(config.column_align, ColumnAlign::Right);
2348
2349 let toml_str = r#"
2350enabled = true
2351style = "aligned"
2352column-align = "left"
2353"#;
2354 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2355 assert_eq!(config.column_align, ColumnAlign::Left);
2356
2357 let toml_str = r#"
2358enabled = true
2359style = "aligned"
2360column-align = "auto"
2361"#;
2362 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2363 assert_eq!(config.column_align, ColumnAlign::Auto);
2364 }
2365
2366 #[test]
2367 fn test_md060_column_align_default_is_auto() {
2368 let toml_str = r#"
2370enabled = true
2371style = "aligned"
2372"#;
2373 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2374 assert_eq!(config.column_align, ColumnAlign::Auto);
2375 }
2376
2377 #[test]
2378 fn test_md060_column_align_reformats_already_aligned_table() {
2379 let config = MD060Config {
2381 enabled: true,
2382 style: "aligned".to_string(),
2383 max_width: LineLength::from_const(0),
2384 column_align: ColumnAlign::Right,
2385 column_align_header: None,
2386 column_align_body: None,
2387 loose_last_column: false,
2388 aligned_delimiter: false,
2389 };
2390 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2391
2392 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395
2396 let fixed = rule.fix(&ctx).unwrap();
2397 let lines: Vec<&str> = fixed.lines().collect();
2398
2399 assert!(
2401 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2402 "Already aligned table should be reformatted with right alignment. Got: {}",
2403 lines[2]
2404 );
2405 assert!(
2406 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2407 "Bob should be right-aligned. Got: {}",
2408 lines[3]
2409 );
2410 }
2411
2412 #[test]
2413 fn test_md060_column_align_with_cjk_characters() {
2414 let config = MD060Config {
2416 enabled: true,
2417 style: "aligned".to_string(),
2418 max_width: LineLength::from_const(0),
2419 column_align: ColumnAlign::Center,
2420 column_align_header: None,
2421 column_align_body: None,
2422 loose_last_column: false,
2423 aligned_delimiter: false,
2424 };
2425 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2426
2427 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2429
2430 let fixed = rule.fix(&ctx).unwrap();
2431
2432 assert!(fixed.contains("Bob"), "Table should contain Bob");
2435 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2436 }
2437
2438 #[test]
2439 fn test_md060_column_align_ignored_for_compact_style() {
2440 let config = MD060Config {
2442 enabled: true,
2443 style: "compact".to_string(),
2444 max_width: LineLength::from_const(0),
2445 column_align: ColumnAlign::Right, column_align_header: None,
2447 column_align_body: None,
2448 loose_last_column: false,
2449 aligned_delimiter: false,
2450 };
2451 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2452
2453 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2455
2456 let fixed = rule.fix(&ctx).unwrap();
2457
2458 assert!(
2460 fixed.contains("| Alice |"),
2461 "Compact style should have single space padding, not alignment. Got: {fixed}"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_md060_column_align_ignored_for_tight_style() {
2467 let config = MD060Config {
2469 enabled: true,
2470 style: "tight".to_string(),
2471 max_width: LineLength::from_const(0),
2472 column_align: ColumnAlign::Center, column_align_header: None,
2474 column_align_body: None,
2475 loose_last_column: false,
2476 aligned_delimiter: false,
2477 };
2478 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2479
2480 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482
2483 let fixed = rule.fix(&ctx).unwrap();
2484
2485 assert!(
2487 fixed.contains("|Alice|"),
2488 "Tight style should have no spaces. Got: {fixed}"
2489 );
2490 }
2491
2492 #[test]
2493 fn test_md060_column_align_with_empty_cells() {
2494 let config = MD060Config {
2496 enabled: true,
2497 style: "aligned".to_string(),
2498 max_width: LineLength::from_const(0),
2499 column_align: ColumnAlign::Center,
2500 column_align_header: None,
2501 column_align_body: None,
2502 loose_last_column: false,
2503 aligned_delimiter: false,
2504 };
2505 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2506
2507 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2509
2510 let fixed = rule.fix(&ctx).unwrap();
2511 let lines: Vec<&str> = fixed.lines().collect();
2512
2513 assert!(
2515 lines[3].contains("| |") || lines[3].contains("| |"),
2516 "Empty cell should be padded correctly. Got: {}",
2517 lines[3]
2518 );
2519 }
2520
2521 #[test]
2522 fn test_md060_column_align_auto_preserves_already_aligned() {
2523 let config = MD060Config {
2525 enabled: true,
2526 style: "aligned".to_string(),
2527 max_width: LineLength::from_const(0),
2528 column_align: ColumnAlign::Auto,
2529 column_align_header: None,
2530 column_align_body: None,
2531 loose_last_column: false,
2532 aligned_delimiter: false,
2533 };
2534 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2535
2536 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2539
2540 let fixed = rule.fix(&ctx).unwrap();
2541
2542 assert_eq!(
2544 fixed, content,
2545 "Already aligned table should be preserved with column-align=auto"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_cjk_table_display_aligned_not_flagged() {
2551 use crate::config::MarkdownFlavor;
2555
2556 let table_lines: Vec<&str> = vec![
2558 "| Header | Name |",
2559 "| ------ | ---- |",
2560 "| Hello | Test |",
2561 "| δ½ ε₯½ | Test |",
2562 ];
2563
2564 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2565 assert!(
2566 result,
2567 "Table with CJK characters that is display-aligned should be recognized as aligned"
2568 );
2569 }
2570
2571 #[test]
2572 fn test_cjk_table_not_reformatted_when_aligned() {
2573 let rule = MD060TableFormat::new(true, "aligned".to_string());
2575 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2578
2579 let fixed = rule.fix(&ctx).unwrap();
2581 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2582 }
2583
2584 #[test]
2600 fn md060_pandoc_grid_tables_not_flagged() {
2601 let rule = MD060TableFormat::new(true, "aligned".to_string());
2602 let content = "\
2603+---+---+
2604| a | b |
2605+===+===+
2606| 1 | 2 |
2607+---+---+
2608";
2609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2612 let result = rule.check(&ctx).unwrap();
2613 assert!(
2614 result.is_empty(),
2615 "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2616 );
2617
2618 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2619 let result_std = rule.check(&ctx_std).unwrap();
2620 assert!(
2621 result_std.is_empty(),
2622 "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2623 );
2624 }
2625
2626 #[test]
2627 fn md060_pandoc_multi_line_tables_not_flagged() {
2628 let rule = MD060TableFormat::new(true, "aligned".to_string());
2629 let content = "\
2630--------- -----------
2631Header 1 Header 2
2632--------- -----------
2633Cell 1 Cell 2
2634--------- -----------
2635";
2636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2637 let result = rule.check(&ctx).unwrap();
2638 assert!(
2639 result.is_empty(),
2640 "MD060 should not flag Pandoc multi-line tables: {result:?}"
2641 );
2642
2643 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2644 let result_std = rule.check(&ctx_std).unwrap();
2645 assert!(
2646 result_std.is_empty(),
2647 "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2648 );
2649 }
2650
2651 #[test]
2652 fn md060_pandoc_line_blocks_not_flagged() {
2653 let rule = MD060TableFormat::new(true, "aligned".to_string());
2654 let content = "| First line\n| Second line\n";
2656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2657 let result = rule.check(&ctx).unwrap();
2658 assert!(
2659 result.is_empty(),
2660 "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2661 );
2662
2663 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2664 let result_std = rule.check(&ctx_std).unwrap();
2665 assert!(
2666 result_std.is_empty(),
2667 "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2668 );
2669 }
2670
2671 #[test]
2672 fn md060_pandoc_pipe_table_captions_not_flagged() {
2673 let rule = MD060TableFormat::new(true, "aligned".to_string());
2674 let content = "\
2677| H1 | H2 |
2678| -- | -- |
2679| a | b |
2680
2681: My table caption
2682";
2683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2684 let result = rule.check(&ctx).unwrap();
2685 assert!(
2686 result.is_empty(),
2687 "MD060 should not flag the pipe-table caption line: {result:?}"
2688 );
2689
2690 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2691 let result_std = rule.check(&ctx_std).unwrap();
2692 assert!(
2693 result_std.is_empty(),
2694 "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2695 );
2696 }
2697}