1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mdg;
3use crate::utils::range_utils::calculate_line_range;
4use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
5use crate::utils::table_utils::TableUtils;
6use unicode_width::UnicodeWidthStr;
7
8mod md060_config;
9use crate::md013_line_length::MD013Config;
10pub use md060_config::ColumnAlign;
11pub use md060_config::MD060Config;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15enum RowType {
16 Header,
18 Delimiter,
20 Body,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25enum ColumnAlignment {
26 Left,
27 Center,
28 Right,
29}
30
31#[derive(Debug, Clone)]
32struct TableFormatResult {
33 lines: Vec<String>,
34 auto_compacted: bool,
35 aligned_width: Option<usize>,
36}
37
38#[derive(Debug, Clone, Copy)]
40struct RowFormatOptions {
41 row_type: RowType,
43 compact_delimiter: bool,
45 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 mdg_table_indent(
305 table_lines: &[&str],
306 list_content_indent: Option<usize>,
307 flavor: crate::config::MarkdownFlavor,
308 ) -> Option<String> {
309 if flavor != crate::config::MarkdownFlavor::MDG || !table_lines.iter().all(|line| mdg::is_table_row(line)) {
310 return None;
311 }
312
313 let indent = list_content_indent.unwrap_or(0).max(mdg::MIN_TABLE_INDENT);
314 (indent <= mdg::MAX_TABLE_INDENT).then(|| " ".repeat(indent))
315 }
316
317 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
318 delimiter_row
319 .iter()
320 .map(|cell| {
321 let trimmed = cell.trim();
322 let has_left_colon = trimmed.starts_with(':');
323 let has_right_colon = trimmed.ends_with(':');
324
325 match (has_left_colon, has_right_colon) {
326 (true, true) => ColumnAlignment::Center,
327 (false, true) => ColumnAlignment::Right,
328 _ => ColumnAlignment::Left,
329 }
330 })
331 .collect()
332 }
333
334 fn calculate_column_widths(
335 table_lines: &[&str],
336 flavor: crate::config::MarkdownFlavor,
337 loose_last_column: bool,
338 ) -> Vec<usize> {
339 let mut column_widths = Vec::new();
340 let mut delimiter_cells: Option<Vec<String>> = None;
341 let mut is_header = true;
342 let mut header_last_col_width: Option<usize> = None;
343
344 for line in table_lines {
345 let cells = Self::parse_table_row_with_flavor(line, flavor);
346
347 if Self::is_delimiter_row(&cells) {
349 delimiter_cells = Some(cells);
350 is_header = false;
351 continue;
352 }
353
354 for (i, cell) in cells.iter().enumerate() {
355 let width = Self::calculate_cell_display_width(cell);
356 if i >= column_widths.len() {
357 column_widths.push(width);
358 } else {
359 column_widths[i] = column_widths[i].max(width);
360 }
361 }
362
363 if is_header && !cells.is_empty() {
365 let last_idx = cells.len() - 1;
366 header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
367 is_header = false;
368 }
369 }
370
371 if loose_last_column
373 && let Some(header_width) = header_last_col_width
374 && let Some(last) = column_widths.last_mut()
375 {
376 *last = header_width;
377 }
378
379 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
382
383 if let Some(delimiter_cells) = delimiter_cells {
386 for (i, cell) in delimiter_cells.iter().enumerate() {
387 if i < final_widths.len() {
388 let trimmed = cell.trim();
389 let has_left_colon = trimmed.starts_with(':');
390 let has_right_colon = trimmed.ends_with(':');
391 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
392
393 let min_width_for_delimiter = 3 + colon_count;
395 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
396 }
397 }
398 }
399
400 final_widths
401 }
402
403 fn format_table_row(
404 cells: &[String],
405 column_widths: &[usize],
406 column_alignments: &[ColumnAlignment],
407 options: &RowFormatOptions,
408 ) -> String {
409 let formatted_cells: Vec<String> = cells
410 .iter()
411 .enumerate()
412 .map(|(i, cell)| {
413 let target_width = column_widths.get(i).copied().unwrap_or(0);
414
415 match options.row_type {
416 RowType::Delimiter => {
417 let trimmed = cell.trim();
418 let has_left_colon = trimmed.starts_with(':');
419 let has_right_colon = trimmed.ends_with(':');
420
421 let extra_width = if options.compact_delimiter { 2 } else { 0 };
425 let dash_count = if has_left_colon && has_right_colon {
426 (target_width + extra_width).saturating_sub(2)
427 } else if has_left_colon || has_right_colon {
428 (target_width + extra_width).saturating_sub(1)
429 } else {
430 target_width + extra_width
431 };
432
433 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
435 format!(":{dashes}:")
436 } else if has_left_colon {
437 format!(":{dashes}")
438 } else if has_right_colon {
439 format!("{dashes}:")
440 } else {
441 dashes
442 };
443
444 if options.compact_delimiter {
446 delimiter_content
447 } else {
448 format!(" {delimiter_content} ")
449 }
450 }
451 RowType::Header | RowType::Body => {
452 let trimmed = cell.trim();
453 let current_width = Self::calculate_cell_display_width(cell);
454 let padding = target_width.saturating_sub(current_width);
455
456 let effective_align = match options.row_type {
458 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
459 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
460 RowType::Delimiter => unreachable!(),
461 };
462
463 let alignment = match effective_align {
465 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
466 ColumnAlign::Left => ColumnAlignment::Left,
467 ColumnAlign::Center => ColumnAlignment::Center,
468 ColumnAlign::Right => ColumnAlignment::Right,
469 };
470
471 match alignment {
472 ColumnAlignment::Left => {
473 format!(" {trimmed}{} ", " ".repeat(padding))
475 }
476 ColumnAlignment::Center => {
477 let left_padding = padding / 2;
479 let right_padding = padding - left_padding;
480 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
481 }
482 ColumnAlignment::Right => {
483 format!(" {}{trimmed} ", " ".repeat(padding))
485 }
486 }
487 }
488 }
489 })
490 .collect();
491
492 format!("|{}|", formatted_cells.join("|"))
493 }
494
495 fn format_table_compact(cells: &[String]) -> String {
496 let formatted_cells: Vec<String> = cells
500 .iter()
501 .map(|cell| match cell.trim() {
502 "" => " ".to_string(),
503 trimmed => format!(" {trimmed} "),
504 })
505 .collect();
506 format!("|{}|", formatted_cells.join("|"))
507 }
508
509 fn format_table_tight(cells: &[String]) -> String {
510 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
511 format!("|{}|", formatted_cells.join("|"))
512 }
513
514 fn format_delimiter_aligned_to_header(delim_cells: &[String], header_widths: &[usize], compact: bool) -> String {
523 let formatted_cells: Vec<String> = delim_cells
524 .iter()
525 .enumerate()
526 .map(|(i, cell)| {
527 let target_width = header_widths.get(i).copied().unwrap_or(0);
528 let trimmed = cell.trim();
529 let has_left_colon = trimmed.starts_with(':');
530 let has_right_colon = trimmed.ends_with(':');
531 let colon_count = usize::from(has_left_colon) + usize::from(has_right_colon);
532
533 let dash_count = target_width.saturating_sub(colon_count).max(1);
535 let dashes = "-".repeat(dash_count);
536 let delimiter_content = match (has_left_colon, has_right_colon) {
537 (true, true) => format!(":{dashes}:"),
538 (true, false) => format!(":{dashes}"),
539 (false, true) => format!("{dashes}:"),
540 (false, false) => dashes,
541 };
542 if compact {
543 format!(" {delimiter_content} ")
544 } else {
545 delimiter_content
546 }
547 })
548 .collect();
549
550 format!("|{}|", formatted_cells.join("|"))
551 }
552
553 fn header_cell_widths(header_cells: &[String]) -> Vec<usize> {
556 header_cells
557 .iter()
558 .map(|c| Self::calculate_cell_display_width(c))
559 .collect()
560 }
561
562 fn is_table_already_aligned(
574 table_lines: &[&str],
575 flavor: crate::config::MarkdownFlavor,
576 compact_delimiter: bool,
577 ) -> bool {
578 if table_lines.len() < 2 {
579 return false;
580 }
581
582 let first_width = UnicodeWidthStr::width(table_lines[0]);
586 if !table_lines
587 .iter()
588 .all(|line| UnicodeWidthStr::width(*line) == first_width)
589 {
590 return false;
591 }
592
593 let parsed: Vec<Vec<String>> = table_lines
595 .iter()
596 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
597 .collect();
598
599 if parsed.is_empty() {
600 return false;
601 }
602
603 let num_columns = parsed[0].len();
604 if !parsed.iter().all(|row| row.len() == num_columns) {
605 return false;
606 }
607
608 if let Some(delimiter_row) = parsed.get(1) {
611 if !Self::is_delimiter_row(delimiter_row) {
612 return false;
613 }
614 for cell in delimiter_row {
616 let trimmed = cell.trim();
617 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
618 if dash_count < 1 {
619 return false;
620 }
621 }
622
623 let delimiter_has_spaces = delimiter_row
627 .iter()
628 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
629
630 if compact_delimiter && delimiter_has_spaces {
633 return false;
634 }
635 if !compact_delimiter && !delimiter_has_spaces {
636 return false;
637 }
638 }
639
640 for col_idx in 0..num_columns {
644 let mut widths = Vec::new();
645 for (row_idx, row) in parsed.iter().enumerate() {
646 if row_idx == 1 {
648 continue;
649 }
650 if let Some(cell) = row.get(col_idx) {
651 widths.push(cell.width());
652 }
653 }
654 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
656 return false;
657 }
658 }
659
660 if let Some(delimiter_row) = parsed.get(1) {
665 let alignments = Self::parse_column_alignments(delimiter_row);
666 for (col_idx, alignment) in alignments.iter().enumerate() {
667 if *alignment == ColumnAlignment::Left {
668 continue;
669 }
670 for (row_idx, row) in parsed.iter().enumerate() {
671 if row_idx == 1 {
673 continue;
674 }
675 if let Some(cell) = row.get(col_idx) {
676 if cell.trim().is_empty() {
677 continue;
678 }
679 let left_pad = cell.len() - cell.trim_start().len();
681 let right_pad = cell.len() - cell.trim_end().len();
682
683 match alignment {
684 ColumnAlignment::Center => {
685 if left_pad.abs_diff(right_pad) > 1 {
687 return false;
688 }
689 }
690 ColumnAlignment::Right => {
691 if left_pad < right_pad {
693 return false;
694 }
695 }
696 ColumnAlignment::Left => unreachable!(),
697 }
698 }
699 }
700 }
701 }
702
703 true
704 }
705
706 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
707 if table_lines.is_empty() {
708 return None;
709 }
710
711 let mut is_tight = true;
714 let mut is_compact = true;
715
716 for line in table_lines {
717 let cells = Self::parse_table_row_with_flavor(line, flavor);
718
719 if cells.is_empty() {
720 continue;
721 }
722
723 if Self::is_delimiter_row(&cells) {
725 continue;
726 }
727
728 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
730
731 let row_has_single_space = cells.iter().all(|cell| match cell.trim() {
735 "" => cell == " ",
736 trimmed => cell == &format!(" {trimmed} "),
737 });
738
739 if !row_has_no_padding {
741 is_tight = false;
742 }
743
744 if !row_has_single_space {
746 is_compact = false;
747 }
748
749 if !is_tight && !is_compact {
751 return Some("aligned".to_string());
752 }
753 }
754
755 if is_tight {
757 Some("tight".to_string())
758 } else if is_compact {
759 Some("compact".to_string())
760 } else {
761 Some("aligned".to_string())
762 }
763 }
764
765 fn fix_table_block(
766 &self,
767 lines: &[&str],
768 table_block: &crate::utils::table_utils::TableBlock,
769 flavor: crate::config::MarkdownFlavor,
770 ) -> TableFormatResult {
771 let mut result = Vec::new();
772 let mut auto_compacted = false;
773 let mut aligned_width = None;
774
775 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
776 .chain(std::iter::once(lines[table_block.delimiter_line]))
777 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
778 .collect();
779
780 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
781 return TableFormatResult {
782 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
783 auto_compacted: false,
784 aligned_width: None,
785 };
786 }
787
788 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
791
792 let list_context = &table_block.list_context;
794 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
795 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
796 } else {
797 ("", String::new())
798 };
799 let mdg_indent = Self::mdg_table_indent(
800 &table_lines,
801 list_context.as_ref().map(|ctx| ctx.content_indent),
802 flavor,
803 );
804
805 let stripped_lines: Vec<&str> = table_lines
807 .iter()
808 .enumerate()
809 .map(|(i, line)| {
810 let after_blockquote = Self::extract_blockquote_prefix(line).1;
811 if mdg_indent.is_some() {
812 after_blockquote.trim_start_matches([' ', '\t'])
813 } else if list_context.is_some() {
814 if i == 0 {
815 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
817 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
818 })
819 } else {
820 after_blockquote
822 .strip_prefix(&continuation_indent)
823 .unwrap_or(after_blockquote.trim_start())
824 }
825 } else {
826 after_blockquote
827 }
828 })
829 .collect();
830
831 let style = self.config.style.as_str();
832
833 match style {
834 "any" => {
835 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
836 if detected_style.is_none() {
837 return TableFormatResult {
838 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
839 auto_compacted: false,
840 aligned_width: None,
841 };
842 }
843
844 let target_style = detected_style.unwrap();
845
846 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
848 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
849
850 for (row_idx, line) in stripped_lines.iter().enumerate() {
851 let cells = Self::parse_table_row_with_flavor(line, flavor);
852 match target_style.as_str() {
853 "tight" => result.push(Self::format_table_tight(&cells)),
854 "compact" => result.push(Self::format_table_compact(&cells)),
855 _ => {
856 let column_widths =
857 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
858 let row_type = match row_idx {
859 0 => RowType::Header,
860 1 => RowType::Delimiter,
861 _ => RowType::Body,
862 };
863 let options = RowFormatOptions {
864 row_type,
865 compact_delimiter: false,
866 column_align: self.config.column_align,
867 column_align_header: self.config.column_align_header,
868 column_align_body: self.config.column_align_body,
869 };
870 result.push(Self::format_table_row(
871 &cells,
872 &column_widths,
873 &column_alignments,
874 &options,
875 ));
876 }
877 }
878 }
879 }
880 "compact" | "tight" => {
881 let compact = style == "compact";
882 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
883 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
884 Some(Self::header_cell_widths(&header_cells))
885 } else {
886 None
887 };
888
889 for (row_idx, line) in stripped_lines.iter().enumerate() {
890 let cells = Self::parse_table_row_with_flavor(line, flavor);
891 if row_idx == 1
892 && let Some(widths) = &header_widths
893 {
894 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, compact));
895 continue;
896 }
897 result.push(if compact {
898 Self::format_table_compact(&cells)
899 } else {
900 Self::format_table_tight(&cells)
901 });
902 }
903 }
904 "aligned" | "aligned-no-space" => {
905 let compact_delimiter = style == "aligned-no-space";
906
907 let needs_reformat = self.config.column_align != ColumnAlign::Auto
910 || self.config.column_align_header.is_some()
911 || self.config.column_align_body.is_some()
912 || self.config.loose_last_column;
913
914 if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
915 return TableFormatResult {
916 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
917 auto_compacted: false,
918 aligned_width: None,
919 };
920 }
921
922 let column_widths =
923 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
924
925 let num_columns = column_widths.len();
927 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
928 aligned_width = Some(calc_aligned_width);
929
930 if calc_aligned_width > self.effective_max_width() {
935 auto_compacted = true;
936 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
937 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
938 Some(Self::header_cell_widths(&header_cells))
939 } else {
940 None
941 };
942 for (row_idx, line) in stripped_lines.iter().enumerate() {
943 let cells = Self::parse_table_row_with_flavor(line, flavor);
944 if row_idx == 1
945 && let Some(widths) = &header_widths
946 {
947 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, true));
949 continue;
950 }
951 result.push(Self::format_table_compact(&cells));
952 }
953 } else {
954 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
956 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
957
958 for (row_idx, line) in stripped_lines.iter().enumerate() {
959 let cells = Self::parse_table_row_with_flavor(line, flavor);
960 let row_type = match row_idx {
961 0 => RowType::Header,
962 1 => RowType::Delimiter,
963 _ => RowType::Body,
964 };
965 let options = RowFormatOptions {
966 row_type,
967 compact_delimiter,
968 column_align: self.config.column_align,
969 column_align_header: self.config.column_align_header,
970 column_align_body: self.config.column_align_body,
971 };
972 result.push(Self::format_table_row(
973 &cells,
974 &column_widths,
975 &column_alignments,
976 &options,
977 ));
978 }
979 }
980 }
981 _ => {
982 return TableFormatResult {
983 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
984 auto_compacted: false,
985 aligned_width: None,
986 };
987 }
988 }
989
990 let prefixed_result: Vec<String> = result
992 .into_iter()
993 .enumerate()
994 .map(|(i, line)| {
995 if let Some(mdg_indent) = &mdg_indent {
996 format!("{blockquote_prefix}{mdg_indent}{line}")
997 } else if list_context.is_some() {
998 if i == 0 {
999 format!("{blockquote_prefix}{list_prefix}{line}")
1001 } else {
1002 format!("{blockquote_prefix}{continuation_indent}{line}")
1004 }
1005 } else {
1006 format!("{blockquote_prefix}{line}")
1007 }
1008 })
1009 .collect();
1010
1011 TableFormatResult {
1012 lines: prefixed_result,
1013 auto_compacted,
1014 aligned_width,
1015 }
1016 }
1017}
1018
1019impl Rule for MD060TableFormat {
1020 fn name(&self) -> &'static str {
1021 "MD060"
1022 }
1023
1024 fn description(&self) -> &'static str {
1025 "Table columns should be consistently aligned"
1026 }
1027
1028 fn category(&self) -> RuleCategory {
1029 RuleCategory::Table
1030 }
1031
1032 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1033 !ctx.likely_has_tables()
1034 }
1035
1036 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1037 let mut warnings = Vec::new();
1038
1039 let lines = ctx.raw_lines();
1040 let table_blocks = &ctx.table_blocks;
1041
1042 for table_block in table_blocks {
1043 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1044
1045 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1046 .chain(std::iter::once(table_block.delimiter_line))
1047 .chain(table_block.content_lines.iter().copied())
1048 .collect();
1049
1050 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());
1057 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1058 let fixed_line = &format_result.lines[i];
1059 if line_idx < lines.len() - 1 {
1061 fixed_table_lines.push(format!("{fixed_line}\n"));
1062 } else {
1063 fixed_table_lines.push(fixed_line.clone());
1064 }
1065 }
1066 let table_replacement = fixed_table_lines.concat();
1067 let table_range = ctx.line_span_byte_range(table_start_line, table_end_line);
1068
1069 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1070 let original = lines[line_idx];
1071 let fixed = &format_result.lines[i];
1072
1073 if original != fixed {
1074 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1075
1076 let message = if format_result.auto_compacted {
1077 if let Some(width) = format_result.aligned_width {
1078 format!(
1079 "Table too wide for aligned formatting ({} chars > max-width: {})",
1080 width,
1081 self.effective_max_width()
1082 )
1083 } else {
1084 "Table too wide for aligned formatting".to_string()
1085 }
1086 } else {
1087 "Table columns should be aligned".to_string()
1088 };
1089
1090 warnings.push(LintWarning {
1093 rule_name: Some(self.name().to_string()),
1094 severity: Severity::Warning,
1095 message,
1096 line: start_line,
1097 column: start_col,
1098 end_line,
1099 end_column: end_col,
1100 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1101 });
1102 }
1103 }
1104 }
1105
1106 Ok(warnings)
1107 }
1108
1109 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1110 let content = ctx.content;
1111 let lines = ctx.raw_lines();
1112 let table_blocks = &ctx.table_blocks;
1113
1114 if table_blocks.is_empty() {
1117 return Ok(content.to_string());
1118 }
1119
1120 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1121
1122 for table_block in table_blocks {
1123 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1124
1125 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1126 .chain(std::iter::once(table_block.delimiter_line))
1127 .chain(table_block.content_lines.iter().copied())
1128 .collect();
1129
1130 let any_disabled = table_line_indices
1133 .iter()
1134 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1135
1136 if any_disabled {
1137 continue;
1138 }
1139
1140 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1141 result_lines[line_idx].clone_from(&format_result.lines[i]);
1142 }
1143 }
1144
1145 let mut fixed = result_lines.join("\n");
1146 let original_trailing_newlines = content.len() - content.trim_end_matches('\n').len();
1151 fixed.truncate(fixed.trim_end_matches('\n').len());
1152 fixed.push_str(&"\n".repeat(original_trailing_newlines));
1153 Ok(fixed)
1154 }
1155
1156 fn as_any(&self) -> &dyn std::any::Any {
1157 self
1158 }
1159
1160 crate::impl_rule_config_sections!(MD060Config);
1161
1162 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1163 where
1164 Self: Sized,
1165 {
1166 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1167 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1168
1169 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1171
1172 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1173 }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179 use crate::lint_context::LintContext;
1180 use crate::types::LineLength;
1181
1182 fn md013_with_line_length(line_length: usize) -> MD013Config {
1184 MD013Config {
1185 line_length: LineLength::from_const(line_length),
1186 tables: true, ..Default::default()
1188 }
1189 }
1190
1191 #[test]
1192 fn test_md060_align_simple_ascii_table() {
1193 let rule = MD060TableFormat::new(true, "aligned".to_string());
1194
1195 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1197
1198 let fixed = rule.fix(&ctx).unwrap();
1199 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1200 assert_eq!(fixed, expected);
1201
1202 let lines: Vec<&str> = fixed.lines().collect();
1204 assert_eq!(lines[0].len(), lines[1].len());
1205 assert_eq!(lines[1].len(), lines[2].len());
1206 }
1207
1208 #[test]
1209 fn test_md060_cjk_characters_aligned_correctly() {
1210 let rule = MD060TableFormat::new(true, "aligned".to_string());
1211
1212 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214
1215 let fixed = rule.fix(&ctx).unwrap();
1216
1217 let lines: Vec<&str> = fixed.lines().collect();
1218 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1219 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1220
1221 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1222 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1223
1224 assert_eq!(width1, width3);
1225 }
1226
1227 #[test]
1228 fn test_md060_basic_emoji() {
1229 let rule = MD060TableFormat::new(true, "aligned".to_string());
1230
1231 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233
1234 let fixed = rule.fix(&ctx).unwrap();
1235 assert!(fixed.contains("Status"));
1236 }
1237
1238 #[test]
1239 fn test_md060_zwj_emoji_skipped() {
1240 let rule = MD060TableFormat::new(true, "aligned".to_string());
1241
1242 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244
1245 let fixed = rule.fix(&ctx).unwrap();
1246 assert_eq!(fixed, content);
1247 }
1248
1249 #[test]
1250 fn test_md060_inline_code_with_escaped_pipes() {
1251 let rule = MD060TableFormat::new(true, "aligned".to_string());
1254
1255 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258
1259 let fixed = rule.fix(&ctx).unwrap();
1260 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1261 }
1262
1263 #[test]
1264 fn test_md060_compact_style() {
1265 let rule = MD060TableFormat::new(true, "compact".to_string());
1266
1267 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269
1270 let fixed = rule.fix(&ctx).unwrap();
1271 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1272 assert_eq!(fixed, expected);
1273 }
1274
1275 #[test]
1276 fn test_md060_tight_style() {
1277 let rule = MD060TableFormat::new(true, "tight".to_string());
1278
1279 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281
1282 let fixed = rule.fix(&ctx).unwrap();
1283 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1284 assert_eq!(fixed, expected);
1285 }
1286
1287 #[test]
1288 fn test_md060_aligned_no_space_style() {
1289 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1291
1292 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294
1295 let fixed = rule.fix(&ctx).unwrap();
1296
1297 let lines: Vec<&str> = fixed.lines().collect();
1299 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1300 assert_eq!(
1301 lines[1], "|-------|-----|",
1302 "Delimiter should have NO spaces around dashes"
1303 );
1304 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1305
1306 assert_eq!(lines[0].len(), lines[1].len());
1308 assert_eq!(lines[1].len(), lines[2].len());
1309 }
1310
1311 #[test]
1312 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1313 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1315
1316 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318
1319 let fixed = rule.fix(&ctx).unwrap();
1320 let lines: Vec<&str> = fixed.lines().collect();
1321
1322 assert!(
1324 fixed.contains("|:"),
1325 "Should have left alignment indicator adjacent to pipe"
1326 );
1327 assert!(
1328 fixed.contains(":|"),
1329 "Should have right alignment indicator adjacent to pipe"
1330 );
1331 assert!(
1333 lines[1].contains(":---") && lines[1].contains("---:"),
1334 "Should have center alignment colons"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_md060_aligned_no_space_three_column_table() {
1340 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1342
1343 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 |";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345
1346 let fixed = rule.fix(&ctx).unwrap();
1347 let lines: Vec<&str> = fixed.lines().collect();
1348
1349 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1351 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1352 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1353 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1354 }
1355
1356 #[test]
1357 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1358 let config = MD060Config {
1360 enabled: true,
1361 style: "aligned-no-space".to_string(),
1362 max_width: LineLength::from_const(50),
1363 column_align: ColumnAlign::Auto,
1364 column_align_header: None,
1365 column_align_body: None,
1366 loose_last_column: false,
1367 aligned_delimiter: false,
1368 };
1369 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1370
1371 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374
1375 let fixed = rule.fix(&ctx).unwrap();
1376
1377 assert!(
1379 fixed.contains("| --- |"),
1380 "Should be compact format when exceeding max-width"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_md060_aligned_no_space_cjk_characters() {
1386 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1388
1389 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391
1392 let fixed = rule.fix(&ctx).unwrap();
1393 let lines: Vec<&str> = fixed.lines().collect();
1394
1395 use unicode_width::UnicodeWidthStr;
1398 assert_eq!(
1399 lines[0].width(),
1400 lines[1].width(),
1401 "Header and delimiter should have same display width"
1402 );
1403 assert_eq!(
1404 lines[1].width(),
1405 lines[2].width(),
1406 "Delimiter and content should have same display width"
1407 );
1408
1409 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1411 }
1412
1413 #[test]
1414 fn test_md060_aligned_no_space_minimum_width() {
1415 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1417
1418 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420
1421 let fixed = rule.fix(&ctx).unwrap();
1422 let lines: Vec<&str> = fixed.lines().collect();
1423
1424 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1426 assert_eq!(lines[0].len(), lines[1].len());
1428 assert_eq!(lines[1].len(), lines[2].len());
1429 }
1430
1431 #[test]
1432 fn test_md060_any_style_consistency() {
1433 let rule = MD060TableFormat::new(true, "any".to_string());
1434
1435 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438
1439 let fixed = rule.fix(&ctx).unwrap();
1440 assert_eq!(fixed, content);
1441
1442 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1444 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1445
1446 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1447 assert_eq!(fixed_aligned, content_aligned);
1448 }
1449
1450 #[test]
1451 fn test_md060_empty_cells() {
1452 let rule = MD060TableFormat::new(true, "aligned".to_string());
1453
1454 let content = "| A | B |\n|---|---|\n| | X |";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456
1457 let fixed = rule.fix(&ctx).unwrap();
1458 assert!(fixed.contains('|'));
1459 }
1460
1461 #[test]
1462 fn test_md060_mixed_content() {
1463 let rule = MD060TableFormat::new(true, "aligned".to_string());
1464
1465 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467
1468 let fixed = rule.fix(&ctx).unwrap();
1469 assert!(fixed.contains("δΈζ"));
1470 assert!(fixed.contains("NYC"));
1471 }
1472
1473 #[test]
1474 fn test_md060_preserve_alignment_indicators() {
1475 let rule = MD060TableFormat::new(true, "aligned".to_string());
1476
1477 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479
1480 let fixed = rule.fix(&ctx).unwrap();
1481
1482 assert!(fixed.contains(":---"), "Should contain left alignment");
1483 assert!(fixed.contains(":----:"), "Should contain center alignment");
1484 assert!(fixed.contains("----:"), "Should contain right alignment");
1485 }
1486
1487 #[test]
1488 fn test_md060_minimum_column_width() {
1489 let rule = MD060TableFormat::new(true, "aligned".to_string());
1490
1491 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495
1496 let fixed = rule.fix(&ctx).unwrap();
1497
1498 let lines: Vec<&str> = fixed.lines().collect();
1499 assert_eq!(lines[0].len(), lines[1].len());
1500 assert_eq!(lines[1].len(), lines[2].len());
1501
1502 assert!(fixed.contains("ID "), "Short content should be padded");
1504 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1505 }
1506
1507 #[test]
1508 fn test_md060_auto_compact_exceeds_default_threshold() {
1509 let config = MD060Config {
1511 enabled: true,
1512 style: "aligned".to_string(),
1513 max_width: LineLength::from_const(0),
1514 column_align: ColumnAlign::Auto,
1515 column_align_header: None,
1516 column_align_body: None,
1517 loose_last_column: false,
1518 aligned_delimiter: false,
1519 };
1520 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1521
1522 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527
1528 let fixed = rule.fix(&ctx).unwrap();
1529
1530 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1532 assert!(fixed.contains("| --- | --- | --- |"));
1533 assert!(fixed.contains("| Short | Data | Here |"));
1534
1535 let lines: Vec<&str> = fixed.lines().collect();
1537 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1539 }
1540
1541 #[test]
1542 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1543 let config = MD060Config {
1545 enabled: true,
1546 style: "aligned".to_string(),
1547 max_width: LineLength::from_const(50),
1548 column_align: ColumnAlign::Auto,
1549 column_align_header: None,
1550 column_align_body: None,
1551 loose_last_column: false,
1552 aligned_delimiter: false,
1553 };
1554 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 |";
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561
1562 let fixed = rule.fix(&ctx).unwrap();
1563
1564 assert!(
1566 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1567 );
1568 assert!(fixed.contains("| --- | --- | --- |"));
1569 assert!(fixed.contains("| Data | Data | Data |"));
1570
1571 let lines: Vec<&str> = fixed.lines().collect();
1573 assert!(lines[0].len() != lines[2].len());
1574 }
1575
1576 #[test]
1577 fn test_md060_stays_aligned_under_threshold() {
1578 let config = MD060Config {
1580 enabled: true,
1581 style: "aligned".to_string(),
1582 max_width: LineLength::from_const(100),
1583 column_align: ColumnAlign::Auto,
1584 column_align_header: None,
1585 column_align_body: None,
1586 loose_last_column: false,
1587 aligned_delimiter: false,
1588 };
1589 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1590
1591 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594
1595 let fixed = rule.fix(&ctx).unwrap();
1596
1597 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1599 assert_eq!(fixed, expected);
1600
1601 let lines: Vec<&str> = fixed.lines().collect();
1602 assert_eq!(lines[0].len(), lines[1].len());
1603 assert_eq!(lines[1].len(), lines[2].len());
1604 }
1605
1606 #[test]
1607 fn test_md060_width_calculation_formula() {
1608 let config = MD060Config {
1610 enabled: true,
1611 style: "aligned".to_string(),
1612 max_width: LineLength::from_const(0),
1613 column_align: ColumnAlign::Auto,
1614 column_align_header: None,
1615 column_align_body: None,
1616 loose_last_column: false,
1617 aligned_delimiter: false,
1618 };
1619 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1620
1621 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626
1627 let fixed = rule.fix(&ctx).unwrap();
1628
1629 let lines: Vec<&str> = fixed.lines().collect();
1631 assert_eq!(lines[0].len(), lines[1].len());
1632 assert_eq!(lines[1].len(), lines[2].len());
1633 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1637 enabled: true,
1638 style: "aligned".to_string(),
1639 max_width: LineLength::from_const(24),
1640 column_align: ColumnAlign::Auto,
1641 column_align_header: None,
1642 column_align_body: None,
1643 loose_last_column: false,
1644 aligned_delimiter: false,
1645 };
1646 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1647
1648 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1649
1650 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1652 assert!(fixed_compact.contains("| --- | --- | --- |"));
1653 }
1654
1655 #[test]
1656 fn test_md060_very_wide_table_auto_compacts() {
1657 let config = MD060Config {
1658 enabled: true,
1659 style: "aligned".to_string(),
1660 max_width: LineLength::from_const(0),
1661 column_align: ColumnAlign::Auto,
1662 column_align_header: None,
1663 column_align_body: None,
1664 loose_last_column: false,
1665 aligned_delimiter: false,
1666 };
1667 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1668
1669 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 |";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674
1675 let fixed = rule.fix(&ctx).unwrap();
1676
1677 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1679 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1680 }
1681
1682 #[test]
1683 fn test_md060_inherit_from_md013_line_length() {
1684 let config = MD060Config {
1686 enabled: true,
1687 style: "aligned".to_string(),
1688 max_width: LineLength::from_const(0), 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
1696 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1698 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1699
1700 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703
1704 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1706
1707 let fixed_120 = rule_120.fix(&ctx).unwrap();
1709
1710 let lines_120: Vec<&str> = fixed_120.lines().collect();
1712 assert_eq!(lines_120[0].len(), lines_120[1].len());
1713 assert_eq!(lines_120[1].len(), lines_120[2].len());
1714 }
1715
1716 #[test]
1717 fn test_md060_edge_case_exactly_at_threshold() {
1718 let config = MD060Config {
1722 enabled: true,
1723 style: "aligned".to_string(),
1724 max_width: LineLength::from_const(17),
1725 column_align: ColumnAlign::Auto,
1726 column_align_header: None,
1727 column_align_body: None,
1728 loose_last_column: false,
1729 aligned_delimiter: false,
1730 };
1731 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1732
1733 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735
1736 let fixed = rule.fix(&ctx).unwrap();
1737
1738 let lines: Vec<&str> = fixed.lines().collect();
1740 assert_eq!(lines[0].len(), 17);
1741 assert_eq!(lines[0].len(), lines[1].len());
1742 assert_eq!(lines[1].len(), lines[2].len());
1743
1744 let config_under = MD060Config {
1746 enabled: true,
1747 style: "aligned".to_string(),
1748 max_width: LineLength::from_const(16),
1749 column_align: ColumnAlign::Auto,
1750 column_align_header: None,
1751 column_align_body: None,
1752 loose_last_column: false,
1753 aligned_delimiter: false,
1754 };
1755 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1756
1757 let fixed_compact = rule_under.fix(&ctx).unwrap();
1758
1759 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1761 assert!(fixed_compact.contains("| --- | --- |"));
1762 }
1763
1764 #[test]
1765 fn test_md060_auto_compact_warning_message() {
1766 let config = MD060Config {
1768 enabled: true,
1769 style: "aligned".to_string(),
1770 max_width: LineLength::from_const(50),
1771 column_align: ColumnAlign::Auto,
1772 column_align_header: None,
1773 column_align_body: None,
1774 loose_last_column: false,
1775 aligned_delimiter: false,
1776 };
1777 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1778
1779 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782
1783 let warnings = rule.check(&ctx).unwrap();
1784
1785 assert!(!warnings.is_empty(), "Should generate warnings");
1787
1788 let auto_compact_warnings: Vec<_> = warnings
1789 .iter()
1790 .filter(|w| w.message.contains("too wide for aligned formatting"))
1791 .collect();
1792
1793 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1794
1795 let first_warning = auto_compact_warnings[0];
1797 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1798 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1799 }
1800
1801 #[test]
1802 fn test_md060_issue_129_detect_style_from_all_rows() {
1803 let rule = MD060TableFormat::new(true, "any".to_string());
1807
1808 let content = "| a long heading | another long heading |\n\
1810 | -------------- | -------------------- |\n\
1811 | a | 1 |\n\
1812 | b b | 2 |\n\
1813 | c c c | 3 |";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815
1816 let fixed = rule.fix(&ctx).unwrap();
1817
1818 assert!(
1820 fixed.contains("| a | 1 |"),
1821 "Should preserve aligned padding in first content row"
1822 );
1823 assert!(
1824 fixed.contains("| b b | 2 |"),
1825 "Should preserve aligned padding in second content row"
1826 );
1827 assert!(
1828 fixed.contains("| c c c | 3 |"),
1829 "Should preserve aligned padding in third content row"
1830 );
1831
1832 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1834 }
1835
1836 #[test]
1837 fn test_md060_regular_alignment_warning_message() {
1838 let config = MD060Config {
1840 enabled: true,
1841 style: "aligned".to_string(),
1842 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1844 column_align_header: None,
1845 column_align_body: None,
1846 loose_last_column: false,
1847 aligned_delimiter: false,
1848 };
1849 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1850
1851 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854
1855 let warnings = rule.check(&ctx).unwrap();
1856
1857 assert!(!warnings.is_empty(), "Should generate warnings");
1859
1860 assert!(warnings[0].message.contains("Table columns should be aligned"));
1862 assert!(!warnings[0].message.contains("too wide"));
1863 assert!(!warnings[0].message.contains("max-width"));
1864 }
1865
1866 #[test]
1869 fn test_md060_unlimited_when_md013_disabled() {
1870 let config = MD060Config {
1872 enabled: true,
1873 style: "aligned".to_string(),
1874 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1876 column_align_header: None,
1877 column_align_body: None,
1878 loose_last_column: false,
1879 aligned_delimiter: false,
1880 };
1881 let md013_config = MD013Config::default();
1882 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1883
1884 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1887 let fixed = rule.fix(&ctx).unwrap();
1888
1889 let lines: Vec<&str> = fixed.lines().collect();
1891 assert_eq!(
1893 lines[0].len(),
1894 lines[1].len(),
1895 "Table should be aligned when MD013 is disabled"
1896 );
1897 }
1898
1899 #[test]
1900 fn test_md060_unlimited_when_md013_tables_false() {
1901 let config = MD060Config {
1903 enabled: true,
1904 style: "aligned".to_string(),
1905 max_width: LineLength::from_const(0),
1906 column_align: ColumnAlign::Auto,
1907 column_align_header: None,
1908 column_align_body: None,
1909 loose_last_column: false,
1910 aligned_delimiter: false,
1911 };
1912 let md013_config = MD013Config {
1913 tables: false, line_length: LineLength::from_const(80),
1915 ..Default::default()
1916 };
1917 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1918
1919 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1922 let fixed = rule.fix(&ctx).unwrap();
1923
1924 let lines: Vec<&str> = fixed.lines().collect();
1926 assert_eq!(
1927 lines[0].len(),
1928 lines[1].len(),
1929 "Table should be aligned when MD013.tables=false"
1930 );
1931 }
1932
1933 #[test]
1934 fn test_md060_unlimited_when_md013_line_length_zero() {
1935 let config = MD060Config {
1937 enabled: true,
1938 style: "aligned".to_string(),
1939 max_width: LineLength::from_const(0),
1940 column_align: ColumnAlign::Auto,
1941 column_align_header: None,
1942 column_align_body: None,
1943 loose_last_column: false,
1944 aligned_delimiter: false,
1945 };
1946 let md013_config = MD013Config {
1947 tables: true,
1948 line_length: LineLength::from_const(0), ..Default::default()
1950 };
1951 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1952
1953 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1956 let fixed = rule.fix(&ctx).unwrap();
1957
1958 let lines: Vec<&str> = fixed.lines().collect();
1960 assert_eq!(
1961 lines[0].len(),
1962 lines[1].len(),
1963 "Table should be aligned when MD013.line_length=0"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_md060_explicit_max_width_overrides_md013_settings() {
1969 let config = MD060Config {
1971 enabled: true,
1972 style: "aligned".to_string(),
1973 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1975 column_align_header: None,
1976 column_align_body: None,
1977 loose_last_column: false,
1978 aligned_delimiter: false,
1979 };
1980 let md013_config = MD013Config {
1981 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1984 };
1985 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1986
1987 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1990 let fixed = rule.fix(&ctx).unwrap();
1991
1992 assert!(
1994 fixed.contains("| --- |"),
1995 "Should be compact format due to explicit max_width"
1996 );
1997 }
1998
1999 #[test]
2000 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
2001 let config = MD060Config {
2003 enabled: true,
2004 style: "aligned".to_string(),
2005 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
2007 column_align_header: None,
2008 column_align_body: None,
2009 loose_last_column: false,
2010 aligned_delimiter: false,
2011 };
2012 let md013_config = MD013Config {
2013 tables: true,
2014 line_length: LineLength::from_const(50), ..Default::default()
2016 };
2017 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
2018
2019 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
2021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022 let fixed = rule.fix(&ctx).unwrap();
2023
2024 assert!(
2026 fixed.contains("| --- |"),
2027 "Should be compact format when inheriting MD013 limit"
2028 );
2029 }
2030
2031 #[test]
2034 fn test_aligned_no_space_reformats_spaced_delimiter() {
2035 let config = MD060Config {
2038 enabled: true,
2039 style: "aligned-no-space".to_string(),
2040 max_width: LineLength::from_const(0),
2041 column_align: ColumnAlign::Auto,
2042 column_align_header: None,
2043 column_align_body: None,
2044 loose_last_column: false,
2045 aligned_delimiter: false,
2046 };
2047 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2048
2049 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2052 let fixed = rule.fix(&ctx).unwrap();
2053
2054 assert!(
2057 !fixed.contains("| ----"),
2058 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
2059 );
2060 assert!(
2061 !fixed.contains("---- |"),
2062 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2063 );
2064 assert!(
2066 fixed.contains("|----"),
2067 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2068 );
2069 }
2070
2071 #[test]
2072 fn test_aligned_reformats_compact_delimiter() {
2073 let config = MD060Config {
2076 enabled: true,
2077 style: "aligned".to_string(),
2078 max_width: LineLength::from_const(0),
2079 column_align: ColumnAlign::Auto,
2080 column_align_header: None,
2081 column_align_body: None,
2082 loose_last_column: false,
2083 aligned_delimiter: false,
2084 };
2085 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2086
2087 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2090 let fixed = rule.fix(&ctx).unwrap();
2091
2092 assert!(
2094 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2095 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2096 );
2097 }
2098
2099 #[test]
2100 fn test_aligned_no_space_preserves_matching_table() {
2101 let config = MD060Config {
2103 enabled: true,
2104 style: "aligned-no-space".to_string(),
2105 max_width: LineLength::from_const(0),
2106 column_align: ColumnAlign::Auto,
2107 column_align_header: None,
2108 column_align_body: None,
2109 loose_last_column: false,
2110 aligned_delimiter: false,
2111 };
2112 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2113
2114 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2117 let fixed = rule.fix(&ctx).unwrap();
2118
2119 assert_eq!(
2121 fixed, content,
2122 "Table already in aligned-no-space style should be preserved"
2123 );
2124 }
2125
2126 #[test]
2127 fn test_aligned_preserves_matching_table() {
2128 let config = MD060Config {
2130 enabled: true,
2131 style: "aligned".to_string(),
2132 max_width: LineLength::from_const(0),
2133 column_align: ColumnAlign::Auto,
2134 column_align_header: None,
2135 column_align_body: None,
2136 loose_last_column: false,
2137 aligned_delimiter: false,
2138 };
2139 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2140
2141 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2144 let fixed = rule.fix(&ctx).unwrap();
2145
2146 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2148 }
2149
2150 #[test]
2151 fn test_cjk_table_display_width_consistency() {
2152 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2158
2159 let is_aligned =
2161 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2162 assert!(
2163 !is_aligned,
2164 "Table with uneven raw line lengths should NOT be considered aligned"
2165 );
2166 }
2167
2168 #[test]
2169 fn test_cjk_width_calculation_in_aligned_check() {
2170 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2173 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2174
2175 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2176 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2177
2178 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2180 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2181
2182 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2184 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2186 }
2187
2188 #[test]
2191 fn test_md060_column_align_left() {
2192 let config = MD060Config {
2194 enabled: true,
2195 style: "aligned".to_string(),
2196 max_width: LineLength::from_const(0),
2197 column_align: ColumnAlign::Left,
2198 column_align_header: None,
2199 column_align_body: None,
2200 loose_last_column: false,
2201 aligned_delimiter: false,
2202 };
2203 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2204
2205 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2207
2208 let fixed = rule.fix(&ctx).unwrap();
2209 let lines: Vec<&str> = fixed.lines().collect();
2210
2211 assert!(
2213 lines[2].contains("| Alice "),
2214 "Content should be left-aligned (Alice should have trailing padding)"
2215 );
2216 assert!(
2217 lines[3].contains("| Bob "),
2218 "Content should be left-aligned (Bob should have trailing padding)"
2219 );
2220 }
2221
2222 #[test]
2223 fn test_md060_column_align_center() {
2224 let config = MD060Config {
2226 enabled: true,
2227 style: "aligned".to_string(),
2228 max_width: LineLength::from_const(0),
2229 column_align: ColumnAlign::Center,
2230 column_align_header: None,
2231 column_align_body: None,
2232 loose_last_column: false,
2233 aligned_delimiter: false,
2234 };
2235 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2236
2237 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239
2240 let fixed = rule.fix(&ctx).unwrap();
2241 let lines: Vec<&str> = fixed.lines().collect();
2242
2243 assert!(
2246 lines[3].contains("| Bob |"),
2247 "Bob should be centered with padding on both sides. Got: {}",
2248 lines[3]
2249 );
2250 }
2251
2252 #[test]
2253 fn test_md060_column_align_right() {
2254 let config = MD060Config {
2256 enabled: true,
2257 style: "aligned".to_string(),
2258 max_width: LineLength::from_const(0),
2259 column_align: ColumnAlign::Right,
2260 column_align_header: None,
2261 column_align_body: None,
2262 loose_last_column: false,
2263 aligned_delimiter: false,
2264 };
2265 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2266
2267 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2269
2270 let fixed = rule.fix(&ctx).unwrap();
2271 let lines: Vec<&str> = fixed.lines().collect();
2272
2273 assert!(
2275 lines[3].contains("| Bob |"),
2276 "Bob should be right-aligned with padding on left. Got: {}",
2277 lines[3]
2278 );
2279 }
2280
2281 #[test]
2282 fn test_md060_column_align_auto_respects_delimiter() {
2283 let config = MD060Config {
2285 enabled: true,
2286 style: "aligned".to_string(),
2287 max_width: LineLength::from_const(0),
2288 column_align: ColumnAlign::Auto,
2289 column_align_header: None,
2290 column_align_body: None,
2291 loose_last_column: false,
2292 aligned_delimiter: false,
2293 };
2294 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2295
2296 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2299
2300 let fixed = rule.fix(&ctx).unwrap();
2301
2302 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2304 let lines: Vec<&str> = fixed.lines().collect();
2306 assert!(
2310 lines[2].contains(" C |"),
2311 "Right column should be right-aligned. Got: {}",
2312 lines[2]
2313 );
2314 }
2315
2316 #[test]
2317 fn test_md060_column_align_overrides_delimiter_indicators() {
2318 let config = MD060Config {
2320 enabled: true,
2321 style: "aligned".to_string(),
2322 max_width: LineLength::from_const(0),
2323 column_align: ColumnAlign::Right, column_align_header: None,
2325 column_align_body: None,
2326 loose_last_column: false,
2327 aligned_delimiter: false,
2328 };
2329 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2330
2331 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334
2335 let fixed = rule.fix(&ctx).unwrap();
2336 let lines: Vec<&str> = fixed.lines().collect();
2337
2338 assert!(
2341 lines[2].contains(" A |") || lines[2].contains(" A |"),
2342 "Even left-indicated column should be right-aligned. Got: {}",
2343 lines[2]
2344 );
2345 }
2346
2347 #[test]
2348 fn test_md060_column_align_with_aligned_no_space() {
2349 let config = MD060Config {
2351 enabled: true,
2352 style: "aligned-no-space".to_string(),
2353 max_width: LineLength::from_const(0),
2354 column_align: ColumnAlign::Center,
2355 column_align_header: None,
2356 column_align_body: None,
2357 loose_last_column: false,
2358 aligned_delimiter: false,
2359 };
2360 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2361
2362 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2364
2365 let fixed = rule.fix(&ctx).unwrap();
2366 let lines: Vec<&str> = fixed.lines().collect();
2367
2368 assert!(
2370 lines[1].contains("|---"),
2371 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2372 lines[1]
2373 );
2374 assert!(
2376 lines[3].contains("| Bob |"),
2377 "Content should be centered. Got: {}",
2378 lines[3]
2379 );
2380 }
2381
2382 #[test]
2383 fn test_md060_column_align_config_parsing() {
2384 let toml_str = r#"
2386enabled = true
2387style = "aligned"
2388column-align = "center"
2389"#;
2390 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2391 assert_eq!(config.column_align, ColumnAlign::Center);
2392
2393 let toml_str = r#"
2394enabled = true
2395style = "aligned"
2396column-align = "right"
2397"#;
2398 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2399 assert_eq!(config.column_align, ColumnAlign::Right);
2400
2401 let toml_str = r#"
2402enabled = true
2403style = "aligned"
2404column-align = "left"
2405"#;
2406 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2407 assert_eq!(config.column_align, ColumnAlign::Left);
2408
2409 let toml_str = r#"
2410enabled = true
2411style = "aligned"
2412column-align = "auto"
2413"#;
2414 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2415 assert_eq!(config.column_align, ColumnAlign::Auto);
2416 }
2417
2418 #[test]
2419 fn test_md060_column_align_default_is_auto() {
2420 let toml_str = r#"
2422enabled = true
2423style = "aligned"
2424"#;
2425 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2426 assert_eq!(config.column_align, ColumnAlign::Auto);
2427 }
2428
2429 #[test]
2430 fn test_md060_column_align_reformats_already_aligned_table() {
2431 let config = MD060Config {
2433 enabled: true,
2434 style: "aligned".to_string(),
2435 max_width: LineLength::from_const(0),
2436 column_align: ColumnAlign::Right,
2437 column_align_header: None,
2438 column_align_body: None,
2439 loose_last_column: false,
2440 aligned_delimiter: false,
2441 };
2442 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2443
2444 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447
2448 let fixed = rule.fix(&ctx).unwrap();
2449 let lines: Vec<&str> = fixed.lines().collect();
2450
2451 assert!(
2453 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2454 "Already aligned table should be reformatted with right alignment. Got: {}",
2455 lines[2]
2456 );
2457 assert!(
2458 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2459 "Bob should be right-aligned. Got: {}",
2460 lines[3]
2461 );
2462 }
2463
2464 #[test]
2465 fn test_md060_column_align_with_cjk_characters() {
2466 let config = MD060Config {
2468 enabled: true,
2469 style: "aligned".to_string(),
2470 max_width: LineLength::from_const(0),
2471 column_align: ColumnAlign::Center,
2472 column_align_header: None,
2473 column_align_body: None,
2474 loose_last_column: false,
2475 aligned_delimiter: false,
2476 };
2477 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2478
2479 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2481
2482 let fixed = rule.fix(&ctx).unwrap();
2483
2484 assert!(fixed.contains("Bob"), "Table should contain Bob");
2487 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2488 }
2489
2490 #[test]
2491 fn test_md060_column_align_ignored_for_compact_style() {
2492 let config = MD060Config {
2494 enabled: true,
2495 style: "compact".to_string(),
2496 max_width: LineLength::from_const(0),
2497 column_align: ColumnAlign::Right, column_align_header: None,
2499 column_align_body: None,
2500 loose_last_column: false,
2501 aligned_delimiter: false,
2502 };
2503 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2504
2505 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507
2508 let fixed = rule.fix(&ctx).unwrap();
2509
2510 assert!(
2512 fixed.contains("| Alice |"),
2513 "Compact style should have single space padding, not alignment. Got: {fixed}"
2514 );
2515 }
2516
2517 #[test]
2518 fn test_md060_column_align_ignored_for_tight_style() {
2519 let config = MD060Config {
2521 enabled: true,
2522 style: "tight".to_string(),
2523 max_width: LineLength::from_const(0),
2524 column_align: ColumnAlign::Center, column_align_header: None,
2526 column_align_body: None,
2527 loose_last_column: false,
2528 aligned_delimiter: false,
2529 };
2530 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2531
2532 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2534
2535 let fixed = rule.fix(&ctx).unwrap();
2536
2537 assert!(
2539 fixed.contains("|Alice|"),
2540 "Tight style should have no spaces. Got: {fixed}"
2541 );
2542 }
2543
2544 #[test]
2545 fn test_md060_column_align_with_empty_cells() {
2546 let config = MD060Config {
2548 enabled: true,
2549 style: "aligned".to_string(),
2550 max_width: LineLength::from_const(0),
2551 column_align: ColumnAlign::Center,
2552 column_align_header: None,
2553 column_align_body: None,
2554 loose_last_column: false,
2555 aligned_delimiter: false,
2556 };
2557 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2558
2559 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2561
2562 let fixed = rule.fix(&ctx).unwrap();
2563 let lines: Vec<&str> = fixed.lines().collect();
2564
2565 assert!(
2567 lines[3].contains("| |") || lines[3].contains("| |"),
2568 "Empty cell should be padded correctly. Got: {}",
2569 lines[3]
2570 );
2571 }
2572
2573 #[test]
2574 fn test_md060_column_align_auto_preserves_already_aligned() {
2575 let config = MD060Config {
2577 enabled: true,
2578 style: "aligned".to_string(),
2579 max_width: LineLength::from_const(0),
2580 column_align: ColumnAlign::Auto,
2581 column_align_header: None,
2582 column_align_body: None,
2583 loose_last_column: false,
2584 aligned_delimiter: false,
2585 };
2586 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2587
2588 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2591
2592 let fixed = rule.fix(&ctx).unwrap();
2593
2594 assert_eq!(
2596 fixed, content,
2597 "Already aligned table should be preserved with column-align=auto"
2598 );
2599 }
2600
2601 #[test]
2602 fn test_cjk_table_display_aligned_not_flagged() {
2603 use crate::config::MarkdownFlavor;
2607
2608 let table_lines: Vec<&str> = vec![
2610 "| Header | Name |",
2611 "| ------ | ---- |",
2612 "| Hello | Test |",
2613 "| δ½ ε₯½ | Test |",
2614 ];
2615
2616 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2617 assert!(
2618 result,
2619 "Table with CJK characters that is display-aligned should be recognized as aligned"
2620 );
2621 }
2622
2623 #[test]
2624 fn test_cjk_table_not_reformatted_when_aligned() {
2625 let rule = MD060TableFormat::new(true, "aligned".to_string());
2627 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2630
2631 let fixed = rule.fix(&ctx).unwrap();
2633 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2634 }
2635
2636 #[test]
2652 fn md060_pandoc_grid_tables_not_flagged() {
2653 let rule = MD060TableFormat::new(true, "aligned".to_string());
2654 let content = "\
2655+---+---+
2656| a | b |
2657+===+===+
2658| 1 | 2 |
2659+---+---+
2660";
2661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2664 let result = rule.check(&ctx).unwrap();
2665 assert!(
2666 result.is_empty(),
2667 "MD060 should not flag Pandoc grid tables (excluded by table_blocks): {result:?}"
2668 );
2669
2670 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2671 let result_std = rule.check(&ctx_std).unwrap();
2672 assert!(
2673 result_std.is_empty(),
2674 "MD060 should not flag grid-table-like content under Standard: {result_std:?}"
2675 );
2676 }
2677
2678 #[test]
2679 fn md060_pandoc_multi_line_tables_not_flagged() {
2680 let rule = MD060TableFormat::new(true, "aligned".to_string());
2681 let content = "\
2682--------- -----------
2683Header 1 Header 2
2684--------- -----------
2685Cell 1 Cell 2
2686--------- -----------
2687";
2688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2689 let result = rule.check(&ctx).unwrap();
2690 assert!(
2691 result.is_empty(),
2692 "MD060 should not flag Pandoc multi-line tables: {result:?}"
2693 );
2694
2695 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696 let result_std = rule.check(&ctx_std).unwrap();
2697 assert!(
2698 result_std.is_empty(),
2699 "MD060 should not flag multi-line table content under Standard: {result_std:?}"
2700 );
2701 }
2702
2703 #[test]
2704 fn md060_pandoc_line_blocks_not_flagged() {
2705 let rule = MD060TableFormat::new(true, "aligned".to_string());
2706 let content = "| First line\n| Second line\n";
2708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2709 let result = rule.check(&ctx).unwrap();
2710 assert!(
2711 result.is_empty(),
2712 "MD060 should not treat Pandoc line blocks as tables: {result:?}"
2713 );
2714
2715 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2716 let result_std = rule.check(&ctx_std).unwrap();
2717 assert!(
2718 result_std.is_empty(),
2719 "MD060 should not treat line-block-like content as tables under Standard: {result_std:?}"
2720 );
2721 }
2722
2723 #[test]
2724 fn md060_pandoc_pipe_table_captions_not_flagged() {
2725 let rule = MD060TableFormat::new(true, "aligned".to_string());
2726 let content = "\
2729| H1 | H2 |
2730| -- | -- |
2731| a | b |
2732
2733: My table caption
2734";
2735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2736 let result = rule.check(&ctx).unwrap();
2737 assert!(
2738 result.is_empty(),
2739 "MD060 should not flag the pipe-table caption line: {result:?}"
2740 );
2741
2742 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2743 let result_std = rule.check(&ctx_std).unwrap();
2744 assert!(
2745 result_std.is_empty(),
2746 "MD060 already-aligned table with caption should have no warnings under Standard: {result_std:?}"
2747 );
2748 }
2749
2750 #[test]
2751 fn test_fix_preserves_trailing_blank_lines_and_is_idempotent() {
2752 let rule = MD060TableFormat::new(true, "aligned".to_string());
2756
2757 for input in ["# \n\n\n\n", "text\n\n\n", "no trailing newline", "only blanks\n\n"] {
2759 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
2760 assert_eq!(
2761 rule.fix(&ctx).unwrap(),
2762 input,
2763 "MD060 must not alter table-free content: {input:?}"
2764 );
2765 }
2766
2767 let with_table = "| a | b |\n|---|---|\n| 1 | 2 |\n\n\n";
2770 let ctx = LintContext::new(with_table, crate::config::MarkdownFlavor::Standard, None);
2771 let once = rule.fix(&ctx).unwrap();
2772 assert!(
2773 once.ends_with("\n\n\n"),
2774 "trailing blank lines must be preserved, got: {once:?}"
2775 );
2776 let ctx2 = LintContext::new(&once, crate::config::MarkdownFlavor::Standard, None);
2777 let twice = rule.fix(&ctx2).unwrap();
2778 assert_eq!(once, twice, "MD060 fix must be idempotent with trailing blank lines");
2779 }
2780
2781 fn indent_block(block: &str, indent: usize) -> String {
2783 let spaces = " ".repeat(indent);
2784 block.lines().fold(String::new(), |mut indented, line| {
2785 indented.push_str(&spaces);
2786 indented.push_str(line);
2787 indented.push('\n');
2788 indented
2789 })
2790 }
2791
2792 #[test]
2793 fn test_mdg_table_indent_stays_within_enclosing_list_item() {
2794 let rule = MD060TableFormat::new(true, "aligned".to_string());
2799 let written = "| name | price |\n|---|---|\n| a | b |\n";
2800 let formatted = "| name | price |\n| ---- | ----- |\n| a | b |\n";
2801
2802 let cases = [
2804 ("* Given", 2, 2),
2805 ("* Given", 5, 2),
2806 ("1. Given", 3, 3),
2807 ("1. Given", 5, 3),
2808 ("* Given\n * Nested", 4, 4),
2809 ("* Given\n * Nested", 5, 4),
2810 ];
2811
2812 for (item, indent, expected_indent) in cases {
2813 let input = format!("# Feature: F\n\n{item}\n\n{}", indent_block(written, indent));
2814 let expected = format!("# Feature: F\n\n{item}\n\n{}", indent_block(formatted, expected_indent));
2815 let ctx = LintContext::new(&input, crate::config::MarkdownFlavor::MDG, None);
2816
2817 assert_eq!(
2818 rule.fix(&ctx).unwrap(),
2819 expected,
2820 "a {indent}-space table under {item:?} belongs at {expected_indent} spaces"
2821 );
2822
2823 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::MDG, None);
2824 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2825 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected, "MD060 fix must be idempotent");
2826 }
2827 }
2828
2829 #[test]
2830 fn test_mdg_table_below_a_deep_list_item_keeps_list_indentation() {
2831 let rule = MD060TableFormat::new(true, "aligned".to_string());
2835 let input = "# Feature: F\n\n* A\n * B\n * C\n\n | name | price |\n |---|---|\n | a | b |\n";
2836 let expected = "# Feature: F\n\n* A\n * B\n * C\n\n | name | price |\n | ---- | ----- |\n | a | b |\n";
2837
2838 let mdg = rule
2839 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
2840 .unwrap();
2841 assert_eq!(mdg, expected);
2842 assert_eq!(
2843 mdg,
2844 rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
2845 .unwrap()
2846 );
2847 }
2848
2849 #[test]
2850 fn test_mdg_tab_indented_table_is_normalized_not_flattened() {
2851 let rule = MD060TableFormat::new(true, "aligned".to_string());
2855 let input = "# Feature: F\n\n#### Examples:\n\n\t\t| a | bb |\n\t\t|---|---|\n\t\t| 1 | 2 |\n";
2856 let expected = "# Feature: F\n\n#### Examples:\n\n | a | bb |\n | --- | --- |\n | 1 | 2 |\n";
2857
2858 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::MDG, None);
2859 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2860
2861 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::MDG, None);
2862 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2863
2864 let standard = rule
2865 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
2866 .unwrap();
2867 assert!(
2868 standard.lines().any(|line| line.starts_with("| a")),
2869 "tab handling is Gherkin-only, got: {standard:?}"
2870 );
2871 }
2872
2873 #[test]
2874 fn test_mdg_table_row_needs_a_pipe_behind_its_indent() {
2875 let rule = MD060TableFormat::new(true, "aligned".to_string());
2880
2881 for input in [
2882 "# Feature: F\n\n > | a | b |\n > |---|---|\n > | 1 | 2 |\n",
2883 "# Feature: F\n\n - | a | b |\n |---|---|\n | 1 | 2 |\n",
2884 ] {
2885 let mdg = rule
2886 .fix(&LintContext::new(input, crate::config::MarkdownFlavor::MDG, None))
2887 .unwrap();
2888 assert_eq!(
2889 mdg,
2890 rule.fix(&LintContext::new(input, crate::config::MarkdownFlavor::Standard, None))
2891 .unwrap(),
2892 "{input:?} is not a Gherkin table, so both flavors must agree"
2893 );
2894
2895 let fixed_ctx = LintContext::new(&mdg, crate::config::MarkdownFlavor::MDG, None);
2896 assert_eq!(rule.fix(&fixed_ctx).unwrap(), mdg, "MD060 fix must converge");
2897 }
2898 }
2899}