1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
4use crate::utils::table_utils::TableUtils;
5use unicode_width::UnicodeWidthStr;
6
7mod md060_config;
8use crate::md013_line_length::MD013Config;
9pub use md060_config::ColumnAlign;
10pub use md060_config::MD060Config;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
14enum RowType {
15 Header,
17 Delimiter,
19 Body,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24enum ColumnAlignment {
25 Left,
26 Center,
27 Right,
28}
29
30#[derive(Debug, Clone)]
31struct TableFormatResult {
32 lines: Vec<String>,
33 auto_compacted: bool,
34 aligned_width: Option<usize>,
35}
36
37#[derive(Debug, Clone, Copy)]
39struct RowFormatOptions {
40 row_type: RowType,
42 compact_delimiter: bool,
44 column_align: ColumnAlign,
46 column_align_header: Option<ColumnAlign>,
48 column_align_body: Option<ColumnAlign>,
50}
51
52#[derive(Debug, Clone, Default)]
175pub struct MD060TableFormat {
176 config: MD060Config,
177 md013_config: MD013Config,
178 md013_disabled: bool,
179}
180
181impl MD060TableFormat {
182 pub fn new(enabled: bool, style: String) -> Self {
183 use crate::types::LineLength;
184 Self {
185 config: MD060Config {
186 enabled,
187 style,
188 max_width: LineLength::from_const(0),
189 column_align: ColumnAlign::Auto,
190 column_align_header: None,
191 column_align_body: None,
192 loose_last_column: false,
193 },
194 md013_config: MD013Config::default(),
195 md013_disabled: false,
196 }
197 }
198
199 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
200 Self {
201 config,
202 md013_config,
203 md013_disabled,
204 }
205 }
206
207 fn effective_max_width(&self) -> usize {
217 if !self.config.max_width.is_unlimited() {
219 return self.config.max_width.get();
220 }
221
222 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
227 return usize::MAX; }
229
230 self.md013_config.line_length.get()
232 }
233
234 fn contains_problematic_chars(text: &str) -> bool {
245 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
250
251 fn calculate_cell_display_width(cell_content: &str) -> usize {
252 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
253 masked.trim().width()
254 }
255
256 #[cfg(test)]
259 fn parse_table_row(line: &str) -> Vec<String> {
260 TableUtils::split_table_row(line)
261 }
262
263 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
268 TableUtils::split_table_row_with_flavor(line, flavor)
269 }
270
271 fn is_delimiter_row(row: &[String]) -> bool {
272 if row.is_empty() {
273 return false;
274 }
275 row.iter().all(|cell| {
276 let trimmed = cell.trim();
277 !trimmed.is_empty()
280 && trimmed.contains('-')
281 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
282 })
283 }
284
285 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
288 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
289 (&line[..m.end()], &line[m.end()..])
290 } else {
291 ("", line)
292 }
293 }
294
295 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
296 delimiter_row
297 .iter()
298 .map(|cell| {
299 let trimmed = cell.trim();
300 let has_left_colon = trimmed.starts_with(':');
301 let has_right_colon = trimmed.ends_with(':');
302
303 match (has_left_colon, has_right_colon) {
304 (true, true) => ColumnAlignment::Center,
305 (false, true) => ColumnAlignment::Right,
306 _ => ColumnAlignment::Left,
307 }
308 })
309 .collect()
310 }
311
312 fn calculate_column_widths(
313 table_lines: &[&str],
314 flavor: crate::config::MarkdownFlavor,
315 loose_last_column: bool,
316 ) -> Vec<usize> {
317 let mut column_widths = Vec::new();
318 let mut delimiter_cells: Option<Vec<String>> = None;
319 let mut is_header = true;
320 let mut header_last_col_width: Option<usize> = None;
321
322 for line in table_lines {
323 let cells = Self::parse_table_row_with_flavor(line, flavor);
324
325 if Self::is_delimiter_row(&cells) {
327 delimiter_cells = Some(cells);
328 is_header = false;
329 continue;
330 }
331
332 for (i, cell) in cells.iter().enumerate() {
333 let width = Self::calculate_cell_display_width(cell);
334 if i >= column_widths.len() {
335 column_widths.push(width);
336 } else {
337 column_widths[i] = column_widths[i].max(width);
338 }
339 }
340
341 if is_header && !cells.is_empty() {
343 let last_idx = cells.len() - 1;
344 header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
345 is_header = false;
346 }
347 }
348
349 if loose_last_column
351 && let Some(header_width) = header_last_col_width
352 && let Some(last) = column_widths.last_mut()
353 {
354 *last = header_width;
355 }
356
357 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
360
361 if let Some(delimiter_cells) = delimiter_cells {
364 for (i, cell) in delimiter_cells.iter().enumerate() {
365 if i < final_widths.len() {
366 let trimmed = cell.trim();
367 let has_left_colon = trimmed.starts_with(':');
368 let has_right_colon = trimmed.ends_with(':');
369 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
370
371 let min_width_for_delimiter = 3 + colon_count;
373 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
374 }
375 }
376 }
377
378 final_widths
379 }
380
381 fn format_table_row(
382 cells: &[String],
383 column_widths: &[usize],
384 column_alignments: &[ColumnAlignment],
385 options: &RowFormatOptions,
386 ) -> String {
387 let formatted_cells: Vec<String> = cells
388 .iter()
389 .enumerate()
390 .map(|(i, cell)| {
391 let target_width = column_widths.get(i).copied().unwrap_or(0);
392
393 match options.row_type {
394 RowType::Delimiter => {
395 let trimmed = cell.trim();
396 let has_left_colon = trimmed.starts_with(':');
397 let has_right_colon = trimmed.ends_with(':');
398
399 let extra_width = if options.compact_delimiter { 2 } else { 0 };
403 let dash_count = if has_left_colon && has_right_colon {
404 (target_width + extra_width).saturating_sub(2)
405 } else if has_left_colon || has_right_colon {
406 (target_width + extra_width).saturating_sub(1)
407 } else {
408 target_width + extra_width
409 };
410
411 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
413 format!(":{dashes}:")
414 } else if has_left_colon {
415 format!(":{dashes}")
416 } else if has_right_colon {
417 format!("{dashes}:")
418 } else {
419 dashes
420 };
421
422 if options.compact_delimiter {
424 delimiter_content
425 } else {
426 format!(" {delimiter_content} ")
427 }
428 }
429 RowType::Header | RowType::Body => {
430 let trimmed = cell.trim();
431 let current_width = Self::calculate_cell_display_width(cell);
432 let padding = target_width.saturating_sub(current_width);
433
434 let effective_align = match options.row_type {
436 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
437 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
438 RowType::Delimiter => unreachable!(),
439 };
440
441 let alignment = match effective_align {
443 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
444 ColumnAlign::Left => ColumnAlignment::Left,
445 ColumnAlign::Center => ColumnAlignment::Center,
446 ColumnAlign::Right => ColumnAlignment::Right,
447 };
448
449 match alignment {
450 ColumnAlignment::Left => {
451 format!(" {trimmed}{} ", " ".repeat(padding))
453 }
454 ColumnAlignment::Center => {
455 let left_padding = padding / 2;
457 let right_padding = padding - left_padding;
458 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
459 }
460 ColumnAlignment::Right => {
461 format!(" {}{trimmed} ", " ".repeat(padding))
463 }
464 }
465 }
466 }
467 })
468 .collect();
469
470 format!("|{}|", formatted_cells.join("|"))
471 }
472
473 fn format_table_compact(cells: &[String]) -> String {
474 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
475 format!("|{}|", formatted_cells.join("|"))
476 }
477
478 fn format_table_tight(cells: &[String]) -> String {
479 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
480 format!("|{}|", formatted_cells.join("|"))
481 }
482
483 fn is_table_already_aligned(
495 table_lines: &[&str],
496 flavor: crate::config::MarkdownFlavor,
497 compact_delimiter: bool,
498 ) -> bool {
499 if table_lines.len() < 2 {
500 return false;
501 }
502
503 let first_len = table_lines[0].len();
505 if !table_lines.iter().all(|line| line.len() == first_len) {
506 return false;
507 }
508
509 let parsed: Vec<Vec<String>> = table_lines
511 .iter()
512 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
513 .collect();
514
515 if parsed.is_empty() {
516 return false;
517 }
518
519 let num_columns = parsed[0].len();
520 if !parsed.iter().all(|row| row.len() == num_columns) {
521 return false;
522 }
523
524 if let Some(delimiter_row) = parsed.get(1) {
527 if !Self::is_delimiter_row(delimiter_row) {
528 return false;
529 }
530 for cell in delimiter_row {
532 let trimmed = cell.trim();
533 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
534 if dash_count < 1 {
535 return false;
536 }
537 }
538
539 let delimiter_has_spaces = delimiter_row
543 .iter()
544 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
545
546 if compact_delimiter && delimiter_has_spaces {
549 return false;
550 }
551 if !compact_delimiter && !delimiter_has_spaces {
552 return false;
553 }
554 }
555
556 for col_idx in 0..num_columns {
560 let mut widths = Vec::new();
561 for (row_idx, row) in parsed.iter().enumerate() {
562 if row_idx == 1 {
564 continue;
565 }
566 if let Some(cell) = row.get(col_idx) {
567 widths.push(cell.width());
568 }
569 }
570 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
572 return false;
573 }
574 }
575
576 if let Some(delimiter_row) = parsed.get(1) {
581 let alignments = Self::parse_column_alignments(delimiter_row);
582 for (col_idx, alignment) in alignments.iter().enumerate() {
583 if *alignment == ColumnAlignment::Left {
584 continue;
585 }
586 for (row_idx, row) in parsed.iter().enumerate() {
587 if row_idx == 1 {
589 continue;
590 }
591 if let Some(cell) = row.get(col_idx) {
592 if cell.trim().is_empty() {
593 continue;
594 }
595 let left_pad = cell.len() - cell.trim_start().len();
597 let right_pad = cell.len() - cell.trim_end().len();
598
599 match alignment {
600 ColumnAlignment::Center => {
601 if left_pad.abs_diff(right_pad) > 1 {
603 return false;
604 }
605 }
606 ColumnAlignment::Right => {
607 if left_pad < right_pad {
609 return false;
610 }
611 }
612 ColumnAlignment::Left => unreachable!(),
613 }
614 }
615 }
616 }
617 }
618
619 true
620 }
621
622 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
623 if table_lines.is_empty() {
624 return None;
625 }
626
627 let mut is_tight = true;
630 let mut is_compact = true;
631
632 for line in table_lines {
633 let cells = Self::parse_table_row_with_flavor(line, flavor);
634
635 if cells.is_empty() {
636 continue;
637 }
638
639 if Self::is_delimiter_row(&cells) {
641 continue;
642 }
643
644 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
646
647 let row_has_single_space = cells.iter().all(|cell| {
649 let trimmed = cell.trim();
650 cell == &format!(" {trimmed} ")
651 });
652
653 if !row_has_no_padding {
655 is_tight = false;
656 }
657
658 if !row_has_single_space {
660 is_compact = false;
661 }
662
663 if !is_tight && !is_compact {
665 return Some("aligned".to_string());
666 }
667 }
668
669 if is_tight {
671 Some("tight".to_string())
672 } else if is_compact {
673 Some("compact".to_string())
674 } else {
675 Some("aligned".to_string())
676 }
677 }
678
679 fn fix_table_block(
680 &self,
681 lines: &[&str],
682 table_block: &crate::utils::table_utils::TableBlock,
683 flavor: crate::config::MarkdownFlavor,
684 ) -> TableFormatResult {
685 let mut result = Vec::new();
686 let mut auto_compacted = false;
687 let mut aligned_width = None;
688
689 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
690 .chain(std::iter::once(lines[table_block.delimiter_line]))
691 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
692 .collect();
693
694 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
695 return TableFormatResult {
696 lines: table_lines.iter().map(|s| s.to_string()).collect(),
697 auto_compacted: false,
698 aligned_width: None,
699 };
700 }
701
702 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
705
706 let list_context = &table_block.list_context;
708 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
709 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
710 } else {
711 ("", String::new())
712 };
713
714 let stripped_lines: Vec<&str> = table_lines
716 .iter()
717 .enumerate()
718 .map(|(i, line)| {
719 let after_blockquote = Self::extract_blockquote_prefix(line).1;
720 if list_context.is_some() {
721 if i == 0 {
722 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
724 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
725 })
726 } else {
727 after_blockquote
729 .strip_prefix(&continuation_indent)
730 .unwrap_or(after_blockquote.trim_start())
731 }
732 } else {
733 after_blockquote
734 }
735 })
736 .collect();
737
738 let style = self.config.style.as_str();
739
740 match style {
741 "any" => {
742 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
743 if detected_style.is_none() {
744 return TableFormatResult {
745 lines: table_lines.iter().map(|s| s.to_string()).collect(),
746 auto_compacted: false,
747 aligned_width: None,
748 };
749 }
750
751 let target_style = detected_style.unwrap();
752
753 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
755 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
756
757 for (row_idx, line) in stripped_lines.iter().enumerate() {
758 let cells = Self::parse_table_row_with_flavor(line, flavor);
759 match target_style.as_str() {
760 "tight" => result.push(Self::format_table_tight(&cells)),
761 "compact" => result.push(Self::format_table_compact(&cells)),
762 _ => {
763 let column_widths =
764 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
765 let row_type = match row_idx {
766 0 => RowType::Header,
767 1 => RowType::Delimiter,
768 _ => RowType::Body,
769 };
770 let options = RowFormatOptions {
771 row_type,
772 compact_delimiter: false,
773 column_align: self.config.column_align,
774 column_align_header: self.config.column_align_header,
775 column_align_body: self.config.column_align_body,
776 };
777 result.push(Self::format_table_row(
778 &cells,
779 &column_widths,
780 &column_alignments,
781 &options,
782 ));
783 }
784 }
785 }
786 }
787 "compact" => {
788 for line in &stripped_lines {
789 let cells = Self::parse_table_row_with_flavor(line, flavor);
790 result.push(Self::format_table_compact(&cells));
791 }
792 }
793 "tight" => {
794 for line in &stripped_lines {
795 let cells = Self::parse_table_row_with_flavor(line, flavor);
796 result.push(Self::format_table_tight(&cells));
797 }
798 }
799 "aligned" | "aligned-no-space" => {
800 let compact_delimiter = style == "aligned-no-space";
801
802 let needs_reformat = self.config.column_align != ColumnAlign::Auto
805 || self.config.column_align_header.is_some()
806 || self.config.column_align_body.is_some()
807 || self.config.loose_last_column;
808
809 if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
810 return TableFormatResult {
811 lines: table_lines.iter().map(|s| s.to_string()).collect(),
812 auto_compacted: false,
813 aligned_width: None,
814 };
815 }
816
817 let column_widths =
818 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
819
820 let num_columns = column_widths.len();
822 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
823 aligned_width = Some(calc_aligned_width);
824
825 if calc_aligned_width > self.effective_max_width() {
827 auto_compacted = true;
828 for line in &stripped_lines {
829 let cells = Self::parse_table_row_with_flavor(line, flavor);
830 result.push(Self::format_table_compact(&cells));
831 }
832 } else {
833 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
835 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
836
837 for (row_idx, line) in stripped_lines.iter().enumerate() {
838 let cells = Self::parse_table_row_with_flavor(line, flavor);
839 let row_type = match row_idx {
840 0 => RowType::Header,
841 1 => RowType::Delimiter,
842 _ => RowType::Body,
843 };
844 let options = RowFormatOptions {
845 row_type,
846 compact_delimiter,
847 column_align: self.config.column_align,
848 column_align_header: self.config.column_align_header,
849 column_align_body: self.config.column_align_body,
850 };
851 result.push(Self::format_table_row(
852 &cells,
853 &column_widths,
854 &column_alignments,
855 &options,
856 ));
857 }
858 }
859 }
860 _ => {
861 return TableFormatResult {
862 lines: table_lines.iter().map(|s| s.to_string()).collect(),
863 auto_compacted: false,
864 aligned_width: None,
865 };
866 }
867 }
868
869 let prefixed_result: Vec<String> = result
871 .into_iter()
872 .enumerate()
873 .map(|(i, line)| {
874 if list_context.is_some() {
875 if i == 0 {
876 format!("{blockquote_prefix}{list_prefix}{line}")
878 } else {
879 format!("{blockquote_prefix}{continuation_indent}{line}")
881 }
882 } else {
883 format!("{blockquote_prefix}{line}")
884 }
885 })
886 .collect();
887
888 TableFormatResult {
889 lines: prefixed_result,
890 auto_compacted,
891 aligned_width,
892 }
893 }
894}
895
896impl Rule for MD060TableFormat {
897 fn name(&self) -> &'static str {
898 "MD060"
899 }
900
901 fn description(&self) -> &'static str {
902 "Table columns should be consistently aligned"
903 }
904
905 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
906 !ctx.likely_has_tables()
907 }
908
909 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
910 let line_index = &ctx.line_index;
911 let mut warnings = Vec::new();
912
913 let lines = ctx.raw_lines();
914 let table_blocks = &ctx.table_blocks;
915
916 for table_block in table_blocks {
917 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
918
919 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
920 .chain(std::iter::once(table_block.delimiter_line))
921 .chain(table_block.content_lines.iter().copied())
922 .collect();
923
924 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());
931 for (i, &line_idx) in table_line_indices.iter().enumerate() {
932 let fixed_line = &format_result.lines[i];
933 if line_idx < lines.len() - 1 {
935 fixed_table_lines.push(format!("{fixed_line}\n"));
936 } else {
937 fixed_table_lines.push(fixed_line.clone());
938 }
939 }
940 let table_replacement = fixed_table_lines.concat();
941 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
942
943 for (i, &line_idx) in table_line_indices.iter().enumerate() {
944 let original = lines[line_idx];
945 let fixed = &format_result.lines[i];
946
947 if original != fixed {
948 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
949
950 let message = if format_result.auto_compacted {
951 if let Some(width) = format_result.aligned_width {
952 format!(
953 "Table too wide for aligned formatting ({} chars > max-width: {})",
954 width,
955 self.effective_max_width()
956 )
957 } else {
958 "Table too wide for aligned formatting".to_string()
959 }
960 } else {
961 "Table columns should be aligned".to_string()
962 };
963
964 warnings.push(LintWarning {
967 rule_name: Some(self.name().to_string()),
968 severity: Severity::Warning,
969 message,
970 line: start_line,
971 column: start_col,
972 end_line,
973 end_column: end_col,
974 fix: Some(crate::rule::Fix {
975 range: table_range.clone(),
976 replacement: table_replacement.clone(),
977 }),
978 });
979 }
980 }
981 }
982
983 Ok(warnings)
984 }
985
986 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
987 let content = ctx.content;
988 let lines = ctx.raw_lines();
989 let table_blocks = &ctx.table_blocks;
990
991 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
992
993 for table_block in table_blocks {
994 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
995
996 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
997 .chain(std::iter::once(table_block.delimiter_line))
998 .chain(table_block.content_lines.iter().copied())
999 .collect();
1000
1001 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1002 result_lines[line_idx] = format_result.lines[i].clone();
1003 }
1004 }
1005
1006 let mut fixed = result_lines.join("\n");
1007 if content.ends_with('\n') && !fixed.ends_with('\n') {
1008 fixed.push('\n');
1009 }
1010 Ok(fixed)
1011 }
1012
1013 fn as_any(&self) -> &dyn std::any::Any {
1014 self
1015 }
1016
1017 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1018 let mut table = toml::map::Map::new();
1021 table.insert("enabled".to_string(), toml::Value::Boolean(self.config.enabled));
1022 table.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
1023 table.insert(
1024 "max-width".to_string(),
1025 toml::Value::Integer(self.config.max_width.get() as i64),
1026 );
1027 table.insert(
1028 "column-align".to_string(),
1029 toml::Value::String(
1030 match self.config.column_align {
1031 ColumnAlign::Auto => "auto",
1032 ColumnAlign::Left => "left",
1033 ColumnAlign::Center => "center",
1034 ColumnAlign::Right => "right",
1035 }
1036 .to_string(),
1037 ),
1038 );
1039 table.insert(
1041 "column-align-header".to_string(),
1042 toml::Value::String("auto".to_string()),
1043 );
1044 table.insert("column-align-body".to_string(), toml::Value::String("auto".to_string()));
1045 table.insert(
1046 "loose-last-column".to_string(),
1047 toml::Value::Boolean(self.config.loose_last_column),
1048 );
1049
1050 Some((self.name().to_string(), toml::Value::Table(table)))
1051 }
1052
1053 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1054 where
1055 Self: Sized,
1056 {
1057 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1058 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1059
1060 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1062
1063 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1064 }
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use crate::lint_context::LintContext;
1071 use crate::types::LineLength;
1072
1073 fn md013_with_line_length(line_length: usize) -> MD013Config {
1075 MD013Config {
1076 line_length: LineLength::from_const(line_length),
1077 tables: true, ..Default::default()
1079 }
1080 }
1081
1082 #[test]
1083 fn test_md060_align_simple_ascii_table() {
1084 let rule = MD060TableFormat::new(true, "aligned".to_string());
1085
1086 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088
1089 let fixed = rule.fix(&ctx).unwrap();
1090 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1091 assert_eq!(fixed, expected);
1092
1093 let lines: Vec<&str> = fixed.lines().collect();
1095 assert_eq!(lines[0].len(), lines[1].len());
1096 assert_eq!(lines[1].len(), lines[2].len());
1097 }
1098
1099 #[test]
1100 fn test_md060_cjk_characters_aligned_correctly() {
1101 let rule = MD060TableFormat::new(true, "aligned".to_string());
1102
1103 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105
1106 let fixed = rule.fix(&ctx).unwrap();
1107
1108 let lines: Vec<&str> = fixed.lines().collect();
1109 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1110 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1111
1112 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1113 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1114
1115 assert_eq!(width1, width3);
1116 }
1117
1118 #[test]
1119 fn test_md060_basic_emoji() {
1120 let rule = MD060TableFormat::new(true, "aligned".to_string());
1121
1122 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124
1125 let fixed = rule.fix(&ctx).unwrap();
1126 assert!(fixed.contains("Status"));
1127 }
1128
1129 #[test]
1130 fn test_md060_zwj_emoji_skipped() {
1131 let rule = MD060TableFormat::new(true, "aligned".to_string());
1132
1133 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135
1136 let fixed = rule.fix(&ctx).unwrap();
1137 assert_eq!(fixed, content);
1138 }
1139
1140 #[test]
1141 fn test_md060_inline_code_with_escaped_pipes() {
1142 let rule = MD060TableFormat::new(true, "aligned".to_string());
1145
1146 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149
1150 let fixed = rule.fix(&ctx).unwrap();
1151 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1152 }
1153
1154 #[test]
1155 fn test_md060_compact_style() {
1156 let rule = MD060TableFormat::new(true, "compact".to_string());
1157
1158 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160
1161 let fixed = rule.fix(&ctx).unwrap();
1162 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1163 assert_eq!(fixed, expected);
1164 }
1165
1166 #[test]
1167 fn test_md060_tight_style() {
1168 let rule = MD060TableFormat::new(true, "tight".to_string());
1169
1170 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172
1173 let fixed = rule.fix(&ctx).unwrap();
1174 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1175 assert_eq!(fixed, expected);
1176 }
1177
1178 #[test]
1179 fn test_md060_aligned_no_space_style() {
1180 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1182
1183 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185
1186 let fixed = rule.fix(&ctx).unwrap();
1187
1188 let lines: Vec<&str> = fixed.lines().collect();
1190 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1191 assert_eq!(
1192 lines[1], "|-------|-----|",
1193 "Delimiter should have NO spaces around dashes"
1194 );
1195 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1196
1197 assert_eq!(lines[0].len(), lines[1].len());
1199 assert_eq!(lines[1].len(), lines[2].len());
1200 }
1201
1202 #[test]
1203 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1204 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1206
1207 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209
1210 let fixed = rule.fix(&ctx).unwrap();
1211 let lines: Vec<&str> = fixed.lines().collect();
1212
1213 assert!(
1215 fixed.contains("|:"),
1216 "Should have left alignment indicator adjacent to pipe"
1217 );
1218 assert!(
1219 fixed.contains(":|"),
1220 "Should have right alignment indicator adjacent to pipe"
1221 );
1222 assert!(
1224 lines[1].contains(":---") && lines[1].contains("---:"),
1225 "Should have center alignment colons"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_md060_aligned_no_space_three_column_table() {
1231 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1233
1234 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 |";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236
1237 let fixed = rule.fix(&ctx).unwrap();
1238 let lines: Vec<&str> = fixed.lines().collect();
1239
1240 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1242 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1243 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1244 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1245 }
1246
1247 #[test]
1248 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1249 let config = MD060Config {
1251 enabled: true,
1252 style: "aligned-no-space".to_string(),
1253 max_width: LineLength::from_const(50),
1254 column_align: ColumnAlign::Auto,
1255 column_align_header: None,
1256 column_align_body: None,
1257 loose_last_column: false,
1258 };
1259 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1260
1261 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264
1265 let fixed = rule.fix(&ctx).unwrap();
1266
1267 assert!(
1269 fixed.contains("| --- |"),
1270 "Should be compact format when exceeding max-width"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_md060_aligned_no_space_cjk_characters() {
1276 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1278
1279 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281
1282 let fixed = rule.fix(&ctx).unwrap();
1283 let lines: Vec<&str> = fixed.lines().collect();
1284
1285 use unicode_width::UnicodeWidthStr;
1288 assert_eq!(
1289 lines[0].width(),
1290 lines[1].width(),
1291 "Header and delimiter should have same display width"
1292 );
1293 assert_eq!(
1294 lines[1].width(),
1295 lines[2].width(),
1296 "Delimiter and content should have same display width"
1297 );
1298
1299 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1301 }
1302
1303 #[test]
1304 fn test_md060_aligned_no_space_minimum_width() {
1305 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1307
1308 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310
1311 let fixed = rule.fix(&ctx).unwrap();
1312 let lines: Vec<&str> = fixed.lines().collect();
1313
1314 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1316 assert_eq!(lines[0].len(), lines[1].len());
1318 assert_eq!(lines[1].len(), lines[2].len());
1319 }
1320
1321 #[test]
1322 fn test_md060_any_style_consistency() {
1323 let rule = MD060TableFormat::new(true, "any".to_string());
1324
1325 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328
1329 let fixed = rule.fix(&ctx).unwrap();
1330 assert_eq!(fixed, content);
1331
1332 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1334 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1335
1336 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1337 assert_eq!(fixed_aligned, content_aligned);
1338 }
1339
1340 #[test]
1341 fn test_md060_empty_cells() {
1342 let rule = MD060TableFormat::new(true, "aligned".to_string());
1343
1344 let content = "| A | B |\n|---|---|\n| | X |";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346
1347 let fixed = rule.fix(&ctx).unwrap();
1348 assert!(fixed.contains("|"));
1349 }
1350
1351 #[test]
1352 fn test_md060_mixed_content() {
1353 let rule = MD060TableFormat::new(true, "aligned".to_string());
1354
1355 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357
1358 let fixed = rule.fix(&ctx).unwrap();
1359 assert!(fixed.contains("δΈζ"));
1360 assert!(fixed.contains("NYC"));
1361 }
1362
1363 #[test]
1364 fn test_md060_preserve_alignment_indicators() {
1365 let rule = MD060TableFormat::new(true, "aligned".to_string());
1366
1367 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369
1370 let fixed = rule.fix(&ctx).unwrap();
1371
1372 assert!(fixed.contains(":---"), "Should contain left alignment");
1373 assert!(fixed.contains(":----:"), "Should contain center alignment");
1374 assert!(fixed.contains("----:"), "Should contain right alignment");
1375 }
1376
1377 #[test]
1378 fn test_md060_minimum_column_width() {
1379 let rule = MD060TableFormat::new(true, "aligned".to_string());
1380
1381 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385
1386 let fixed = rule.fix(&ctx).unwrap();
1387
1388 let lines: Vec<&str> = fixed.lines().collect();
1389 assert_eq!(lines[0].len(), lines[1].len());
1390 assert_eq!(lines[1].len(), lines[2].len());
1391
1392 assert!(fixed.contains("ID "), "Short content should be padded");
1394 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1395 }
1396
1397 #[test]
1398 fn test_md060_auto_compact_exceeds_default_threshold() {
1399 let config = MD060Config {
1401 enabled: true,
1402 style: "aligned".to_string(),
1403 max_width: LineLength::from_const(0),
1404 column_align: ColumnAlign::Auto,
1405 column_align_header: None,
1406 column_align_body: None,
1407 loose_last_column: false,
1408 };
1409 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1410
1411 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416
1417 let fixed = rule.fix(&ctx).unwrap();
1418
1419 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1421 assert!(fixed.contains("| --- | --- | --- |"));
1422 assert!(fixed.contains("| Short | Data | Here |"));
1423
1424 let lines: Vec<&str> = fixed.lines().collect();
1426 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1428 }
1429
1430 #[test]
1431 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1432 let config = MD060Config {
1434 enabled: true,
1435 style: "aligned".to_string(),
1436 max_width: LineLength::from_const(50),
1437 column_align: ColumnAlign::Auto,
1438 column_align_header: None,
1439 column_align_body: None,
1440 loose_last_column: false,
1441 };
1442 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 |";
1448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1449
1450 let fixed = rule.fix(&ctx).unwrap();
1451
1452 assert!(
1454 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1455 );
1456 assert!(fixed.contains("| --- | --- | --- |"));
1457 assert!(fixed.contains("| Data | Data | Data |"));
1458
1459 let lines: Vec<&str> = fixed.lines().collect();
1461 assert!(lines[0].len() != lines[2].len());
1462 }
1463
1464 #[test]
1465 fn test_md060_stays_aligned_under_threshold() {
1466 let config = MD060Config {
1468 enabled: true,
1469 style: "aligned".to_string(),
1470 max_width: LineLength::from_const(100),
1471 column_align: ColumnAlign::Auto,
1472 column_align_header: None,
1473 column_align_body: None,
1474 loose_last_column: false,
1475 };
1476 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1477
1478 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481
1482 let fixed = rule.fix(&ctx).unwrap();
1483
1484 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1486 assert_eq!(fixed, expected);
1487
1488 let lines: Vec<&str> = fixed.lines().collect();
1489 assert_eq!(lines[0].len(), lines[1].len());
1490 assert_eq!(lines[1].len(), lines[2].len());
1491 }
1492
1493 #[test]
1494 fn test_md060_width_calculation_formula() {
1495 let config = MD060Config {
1497 enabled: true,
1498 style: "aligned".to_string(),
1499 max_width: LineLength::from_const(0),
1500 column_align: ColumnAlign::Auto,
1501 column_align_header: None,
1502 column_align_body: None,
1503 loose_last_column: false,
1504 };
1505 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1506
1507 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512
1513 let fixed = rule.fix(&ctx).unwrap();
1514
1515 let lines: Vec<&str> = fixed.lines().collect();
1517 assert_eq!(lines[0].len(), lines[1].len());
1518 assert_eq!(lines[1].len(), lines[2].len());
1519 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1523 enabled: true,
1524 style: "aligned".to_string(),
1525 max_width: LineLength::from_const(24),
1526 column_align: ColumnAlign::Auto,
1527 column_align_header: None,
1528 column_align_body: None,
1529 loose_last_column: false,
1530 };
1531 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1532
1533 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1534
1535 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1537 assert!(fixed_compact.contains("| --- | --- | --- |"));
1538 }
1539
1540 #[test]
1541 fn test_md060_very_wide_table_auto_compacts() {
1542 let config = MD060Config {
1543 enabled: true,
1544 style: "aligned".to_string(),
1545 max_width: LineLength::from_const(0),
1546 column_align: ColumnAlign::Auto,
1547 column_align_header: None,
1548 column_align_body: None,
1549 loose_last_column: false,
1550 };
1551 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1552
1553 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 |";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558
1559 let fixed = rule.fix(&ctx).unwrap();
1560
1561 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1563 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1564 }
1565
1566 #[test]
1567 fn test_md060_inherit_from_md013_line_length() {
1568 let config = MD060Config {
1570 enabled: true,
1571 style: "aligned".to_string(),
1572 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1574 column_align_header: None,
1575 column_align_body: None,
1576 loose_last_column: false,
1577 };
1578
1579 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1581 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1582
1583 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586
1587 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1589
1590 let fixed_120 = rule_120.fix(&ctx).unwrap();
1592
1593 let lines_120: Vec<&str> = fixed_120.lines().collect();
1595 assert_eq!(lines_120[0].len(), lines_120[1].len());
1596 assert_eq!(lines_120[1].len(), lines_120[2].len());
1597 }
1598
1599 #[test]
1600 fn test_md060_edge_case_exactly_at_threshold() {
1601 let config = MD060Config {
1605 enabled: true,
1606 style: "aligned".to_string(),
1607 max_width: LineLength::from_const(17),
1608 column_align: ColumnAlign::Auto,
1609 column_align_header: None,
1610 column_align_body: None,
1611 loose_last_column: false,
1612 };
1613 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1614
1615 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617
1618 let fixed = rule.fix(&ctx).unwrap();
1619
1620 let lines: Vec<&str> = fixed.lines().collect();
1622 assert_eq!(lines[0].len(), 17);
1623 assert_eq!(lines[0].len(), lines[1].len());
1624 assert_eq!(lines[1].len(), lines[2].len());
1625
1626 let config_under = MD060Config {
1628 enabled: true,
1629 style: "aligned".to_string(),
1630 max_width: LineLength::from_const(16),
1631 column_align: ColumnAlign::Auto,
1632 column_align_header: None,
1633 column_align_body: None,
1634 loose_last_column: false,
1635 };
1636 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1637
1638 let fixed_compact = rule_under.fix(&ctx).unwrap();
1639
1640 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1642 assert!(fixed_compact.contains("| --- | --- |"));
1643 }
1644
1645 #[test]
1646 fn test_md060_auto_compact_warning_message() {
1647 let config = MD060Config {
1649 enabled: true,
1650 style: "aligned".to_string(),
1651 max_width: LineLength::from_const(50),
1652 column_align: ColumnAlign::Auto,
1653 column_align_header: None,
1654 column_align_body: None,
1655 loose_last_column: false,
1656 };
1657 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1658
1659 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662
1663 let warnings = rule.check(&ctx).unwrap();
1664
1665 assert!(!warnings.is_empty(), "Should generate warnings");
1667
1668 let auto_compact_warnings: Vec<_> = warnings
1669 .iter()
1670 .filter(|w| w.message.contains("too wide for aligned formatting"))
1671 .collect();
1672
1673 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1674
1675 let first_warning = auto_compact_warnings[0];
1677 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1678 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1679 }
1680
1681 #[test]
1682 fn test_md060_issue_129_detect_style_from_all_rows() {
1683 let rule = MD060TableFormat::new(true, "any".to_string());
1687
1688 let content = "| a long heading | another long heading |\n\
1690 | -------------- | -------------------- |\n\
1691 | a | 1 |\n\
1692 | b b | 2 |\n\
1693 | c c c | 3 |";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695
1696 let fixed = rule.fix(&ctx).unwrap();
1697
1698 assert!(
1700 fixed.contains("| a | 1 |"),
1701 "Should preserve aligned padding in first content row"
1702 );
1703 assert!(
1704 fixed.contains("| b b | 2 |"),
1705 "Should preserve aligned padding in second content row"
1706 );
1707 assert!(
1708 fixed.contains("| c c c | 3 |"),
1709 "Should preserve aligned padding in third content row"
1710 );
1711
1712 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1714 }
1715
1716 #[test]
1717 fn test_md060_regular_alignment_warning_message() {
1718 let config = MD060Config {
1720 enabled: true,
1721 style: "aligned".to_string(),
1722 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1724 column_align_header: None,
1725 column_align_body: None,
1726 loose_last_column: false,
1727 };
1728 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1729
1730 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733
1734 let warnings = rule.check(&ctx).unwrap();
1735
1736 assert!(!warnings.is_empty(), "Should generate warnings");
1738
1739 assert!(warnings[0].message.contains("Table columns should be aligned"));
1741 assert!(!warnings[0].message.contains("too wide"));
1742 assert!(!warnings[0].message.contains("max-width"));
1743 }
1744
1745 #[test]
1748 fn test_md060_unlimited_when_md013_disabled() {
1749 let config = MD060Config {
1751 enabled: true,
1752 style: "aligned".to_string(),
1753 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1755 column_align_header: None,
1756 column_align_body: None,
1757 loose_last_column: false,
1758 };
1759 let md013_config = MD013Config::default();
1760 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1761
1762 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let fixed = rule.fix(&ctx).unwrap();
1766
1767 let lines: Vec<&str> = fixed.lines().collect();
1769 assert_eq!(
1771 lines[0].len(),
1772 lines[1].len(),
1773 "Table should be aligned when MD013 is disabled"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_md060_unlimited_when_md013_tables_false() {
1779 let config = MD060Config {
1781 enabled: true,
1782 style: "aligned".to_string(),
1783 max_width: LineLength::from_const(0),
1784 column_align: ColumnAlign::Auto,
1785 column_align_header: None,
1786 column_align_body: None,
1787 loose_last_column: false,
1788 };
1789 let md013_config = MD013Config {
1790 tables: false, line_length: LineLength::from_const(80),
1792 ..Default::default()
1793 };
1794 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1795
1796 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1799 let fixed = rule.fix(&ctx).unwrap();
1800
1801 let lines: Vec<&str> = fixed.lines().collect();
1803 assert_eq!(
1804 lines[0].len(),
1805 lines[1].len(),
1806 "Table should be aligned when MD013.tables=false"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_md060_unlimited_when_md013_line_length_zero() {
1812 let config = MD060Config {
1814 enabled: true,
1815 style: "aligned".to_string(),
1816 max_width: LineLength::from_const(0),
1817 column_align: ColumnAlign::Auto,
1818 column_align_header: None,
1819 column_align_body: None,
1820 loose_last_column: false,
1821 };
1822 let md013_config = MD013Config {
1823 tables: true,
1824 line_length: LineLength::from_const(0), ..Default::default()
1826 };
1827 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1828
1829 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832 let fixed = rule.fix(&ctx).unwrap();
1833
1834 let lines: Vec<&str> = fixed.lines().collect();
1836 assert_eq!(
1837 lines[0].len(),
1838 lines[1].len(),
1839 "Table should be aligned when MD013.line_length=0"
1840 );
1841 }
1842
1843 #[test]
1844 fn test_md060_explicit_max_width_overrides_md013_settings() {
1845 let config = MD060Config {
1847 enabled: true,
1848 style: "aligned".to_string(),
1849 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1851 column_align_header: None,
1852 column_align_body: None,
1853 loose_last_column: false,
1854 };
1855 let md013_config = MD013Config {
1856 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1859 };
1860 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1861
1862 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1865 let fixed = rule.fix(&ctx).unwrap();
1866
1867 assert!(
1869 fixed.contains("| --- |"),
1870 "Should be compact format due to explicit max_width"
1871 );
1872 }
1873
1874 #[test]
1875 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1876 let config = MD060Config {
1878 enabled: true,
1879 style: "aligned".to_string(),
1880 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1882 column_align_header: None,
1883 column_align_body: None,
1884 loose_last_column: false,
1885 };
1886 let md013_config = MD013Config {
1887 tables: true,
1888 line_length: LineLength::from_const(50), ..Default::default()
1890 };
1891 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1892
1893 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let fixed = rule.fix(&ctx).unwrap();
1897
1898 assert!(
1900 fixed.contains("| --- |"),
1901 "Should be compact format when inheriting MD013 limit"
1902 );
1903 }
1904
1905 #[test]
1908 fn test_aligned_no_space_reformats_spaced_delimiter() {
1909 let config = MD060Config {
1912 enabled: true,
1913 style: "aligned-no-space".to_string(),
1914 max_width: LineLength::from_const(0),
1915 column_align: ColumnAlign::Auto,
1916 column_align_header: None,
1917 column_align_body: None,
1918 loose_last_column: false,
1919 };
1920 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1921
1922 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let fixed = rule.fix(&ctx).unwrap();
1926
1927 assert!(
1930 !fixed.contains("| ----"),
1931 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1932 );
1933 assert!(
1934 !fixed.contains("---- |"),
1935 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1936 );
1937 assert!(
1939 fixed.contains("|----"),
1940 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1941 );
1942 }
1943
1944 #[test]
1945 fn test_aligned_reformats_compact_delimiter() {
1946 let config = MD060Config {
1949 enabled: true,
1950 style: "aligned".to_string(),
1951 max_width: LineLength::from_const(0),
1952 column_align: ColumnAlign::Auto,
1953 column_align_header: None,
1954 column_align_body: None,
1955 loose_last_column: false,
1956 };
1957 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1958
1959 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let fixed = rule.fix(&ctx).unwrap();
1963
1964 assert!(
1966 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1967 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_aligned_no_space_preserves_matching_table() {
1973 let config = MD060Config {
1975 enabled: true,
1976 style: "aligned-no-space".to_string(),
1977 max_width: LineLength::from_const(0),
1978 column_align: ColumnAlign::Auto,
1979 column_align_header: None,
1980 column_align_body: None,
1981 loose_last_column: false,
1982 };
1983 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1984
1985 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1988 let fixed = rule.fix(&ctx).unwrap();
1989
1990 assert_eq!(
1992 fixed, content,
1993 "Table already in aligned-no-space style should be preserved"
1994 );
1995 }
1996
1997 #[test]
1998 fn test_aligned_preserves_matching_table() {
1999 let config = MD060Config {
2001 enabled: true,
2002 style: "aligned".to_string(),
2003 max_width: LineLength::from_const(0),
2004 column_align: ColumnAlign::Auto,
2005 column_align_header: None,
2006 column_align_body: None,
2007 loose_last_column: false,
2008 };
2009 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2010
2011 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let fixed = rule.fix(&ctx).unwrap();
2015
2016 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2018 }
2019
2020 #[test]
2021 fn test_cjk_table_display_width_consistency() {
2022 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2028
2029 let is_aligned =
2031 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2032 assert!(
2033 !is_aligned,
2034 "Table with uneven raw line lengths should NOT be considered aligned"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_cjk_width_calculation_in_aligned_check() {
2040 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2043 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2044
2045 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2046 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2047
2048 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2050 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2051
2052 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2054 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2056 }
2057
2058 #[test]
2061 fn test_md060_column_align_left() {
2062 let config = MD060Config {
2064 enabled: true,
2065 style: "aligned".to_string(),
2066 max_width: LineLength::from_const(0),
2067 column_align: ColumnAlign::Left,
2068 column_align_header: None,
2069 column_align_body: None,
2070 loose_last_column: false,
2071 };
2072 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2073
2074 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2076
2077 let fixed = rule.fix(&ctx).unwrap();
2078 let lines: Vec<&str> = fixed.lines().collect();
2079
2080 assert!(
2082 lines[2].contains("| Alice "),
2083 "Content should be left-aligned (Alice should have trailing padding)"
2084 );
2085 assert!(
2086 lines[3].contains("| Bob "),
2087 "Content should be left-aligned (Bob should have trailing padding)"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_md060_column_align_center() {
2093 let config = MD060Config {
2095 enabled: true,
2096 style: "aligned".to_string(),
2097 max_width: LineLength::from_const(0),
2098 column_align: ColumnAlign::Center,
2099 column_align_header: None,
2100 column_align_body: None,
2101 loose_last_column: false,
2102 };
2103 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2104
2105 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2107
2108 let fixed = rule.fix(&ctx).unwrap();
2109 let lines: Vec<&str> = fixed.lines().collect();
2110
2111 assert!(
2114 lines[3].contains("| Bob |"),
2115 "Bob should be centered with padding on both sides. Got: {}",
2116 lines[3]
2117 );
2118 }
2119
2120 #[test]
2121 fn test_md060_column_align_right() {
2122 let config = MD060Config {
2124 enabled: true,
2125 style: "aligned".to_string(),
2126 max_width: LineLength::from_const(0),
2127 column_align: ColumnAlign::Right,
2128 column_align_header: None,
2129 column_align_body: None,
2130 loose_last_column: false,
2131 };
2132 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2133
2134 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136
2137 let fixed = rule.fix(&ctx).unwrap();
2138 let lines: Vec<&str> = fixed.lines().collect();
2139
2140 assert!(
2142 lines[3].contains("| Bob |"),
2143 "Bob should be right-aligned with padding on left. Got: {}",
2144 lines[3]
2145 );
2146 }
2147
2148 #[test]
2149 fn test_md060_column_align_auto_respects_delimiter() {
2150 let config = MD060Config {
2152 enabled: true,
2153 style: "aligned".to_string(),
2154 max_width: LineLength::from_const(0),
2155 column_align: ColumnAlign::Auto,
2156 column_align_header: None,
2157 column_align_body: None,
2158 loose_last_column: false,
2159 };
2160 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2161
2162 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165
2166 let fixed = rule.fix(&ctx).unwrap();
2167
2168 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2170 let lines: Vec<&str> = fixed.lines().collect();
2172 assert!(
2176 lines[2].contains(" C |"),
2177 "Right column should be right-aligned. Got: {}",
2178 lines[2]
2179 );
2180 }
2181
2182 #[test]
2183 fn test_md060_column_align_overrides_delimiter_indicators() {
2184 let config = MD060Config {
2186 enabled: true,
2187 style: "aligned".to_string(),
2188 max_width: LineLength::from_const(0),
2189 column_align: ColumnAlign::Right, column_align_header: None,
2191 column_align_body: None,
2192 loose_last_column: false,
2193 };
2194 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2195
2196 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2199
2200 let fixed = rule.fix(&ctx).unwrap();
2201 let lines: Vec<&str> = fixed.lines().collect();
2202
2203 assert!(
2206 lines[2].contains(" A |") || lines[2].contains(" A |"),
2207 "Even left-indicated column should be right-aligned. Got: {}",
2208 lines[2]
2209 );
2210 }
2211
2212 #[test]
2213 fn test_md060_column_align_with_aligned_no_space() {
2214 let config = MD060Config {
2216 enabled: true,
2217 style: "aligned-no-space".to_string(),
2218 max_width: LineLength::from_const(0),
2219 column_align: ColumnAlign::Center,
2220 column_align_header: None,
2221 column_align_body: None,
2222 loose_last_column: false,
2223 };
2224 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2225
2226 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228
2229 let fixed = rule.fix(&ctx).unwrap();
2230 let lines: Vec<&str> = fixed.lines().collect();
2231
2232 assert!(
2234 lines[1].contains("|---"),
2235 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2236 lines[1]
2237 );
2238 assert!(
2240 lines[3].contains("| Bob |"),
2241 "Content should be centered. Got: {}",
2242 lines[3]
2243 );
2244 }
2245
2246 #[test]
2247 fn test_md060_column_align_config_parsing() {
2248 let toml_str = r#"
2250enabled = true
2251style = "aligned"
2252column-align = "center"
2253"#;
2254 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2255 assert_eq!(config.column_align, ColumnAlign::Center);
2256
2257 let toml_str = r#"
2258enabled = true
2259style = "aligned"
2260column-align = "right"
2261"#;
2262 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2263 assert_eq!(config.column_align, ColumnAlign::Right);
2264
2265 let toml_str = r#"
2266enabled = true
2267style = "aligned"
2268column-align = "left"
2269"#;
2270 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2271 assert_eq!(config.column_align, ColumnAlign::Left);
2272
2273 let toml_str = r#"
2274enabled = true
2275style = "aligned"
2276column-align = "auto"
2277"#;
2278 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2279 assert_eq!(config.column_align, ColumnAlign::Auto);
2280 }
2281
2282 #[test]
2283 fn test_md060_column_align_default_is_auto() {
2284 let toml_str = r#"
2286enabled = true
2287style = "aligned"
2288"#;
2289 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2290 assert_eq!(config.column_align, ColumnAlign::Auto);
2291 }
2292
2293 #[test]
2294 fn test_md060_column_align_reformats_already_aligned_table() {
2295 let config = MD060Config {
2297 enabled: true,
2298 style: "aligned".to_string(),
2299 max_width: LineLength::from_const(0),
2300 column_align: ColumnAlign::Right,
2301 column_align_header: None,
2302 column_align_body: None,
2303 loose_last_column: false,
2304 };
2305 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2306
2307 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2310
2311 let fixed = rule.fix(&ctx).unwrap();
2312 let lines: Vec<&str> = fixed.lines().collect();
2313
2314 assert!(
2316 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2317 "Already aligned table should be reformatted with right alignment. Got: {}",
2318 lines[2]
2319 );
2320 assert!(
2321 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2322 "Bob should be right-aligned. Got: {}",
2323 lines[3]
2324 );
2325 }
2326
2327 #[test]
2328 fn test_md060_column_align_with_cjk_characters() {
2329 let config = MD060Config {
2331 enabled: true,
2332 style: "aligned".to_string(),
2333 max_width: LineLength::from_const(0),
2334 column_align: ColumnAlign::Center,
2335 column_align_header: None,
2336 column_align_body: None,
2337 loose_last_column: false,
2338 };
2339 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2340
2341 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2343
2344 let fixed = rule.fix(&ctx).unwrap();
2345
2346 assert!(fixed.contains("Bob"), "Table should contain Bob");
2349 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2350 }
2351
2352 #[test]
2353 fn test_md060_column_align_ignored_for_compact_style() {
2354 let config = MD060Config {
2356 enabled: true,
2357 style: "compact".to_string(),
2358 max_width: LineLength::from_const(0),
2359 column_align: ColumnAlign::Right, column_align_header: None,
2361 column_align_body: None,
2362 loose_last_column: false,
2363 };
2364 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2365
2366 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368
2369 let fixed = rule.fix(&ctx).unwrap();
2370
2371 assert!(
2373 fixed.contains("| Alice |"),
2374 "Compact style should have single space padding, not alignment. Got: {fixed}"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_md060_column_align_ignored_for_tight_style() {
2380 let config = MD060Config {
2382 enabled: true,
2383 style: "tight".to_string(),
2384 max_width: LineLength::from_const(0),
2385 column_align: ColumnAlign::Center, column_align_header: None,
2387 column_align_body: None,
2388 loose_last_column: 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 |";
2393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2394
2395 let fixed = rule.fix(&ctx).unwrap();
2396
2397 assert!(
2399 fixed.contains("|Alice|"),
2400 "Tight style should have no spaces. Got: {fixed}"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_md060_column_align_with_empty_cells() {
2406 let config = MD060Config {
2408 enabled: true,
2409 style: "aligned".to_string(),
2410 max_width: LineLength::from_const(0),
2411 column_align: ColumnAlign::Center,
2412 column_align_header: None,
2413 column_align_body: None,
2414 loose_last_column: false,
2415 };
2416 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2417
2418 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420
2421 let fixed = rule.fix(&ctx).unwrap();
2422 let lines: Vec<&str> = fixed.lines().collect();
2423
2424 assert!(
2426 lines[3].contains("| |") || lines[3].contains("| |"),
2427 "Empty cell should be padded correctly. Got: {}",
2428 lines[3]
2429 );
2430 }
2431
2432 #[test]
2433 fn test_md060_column_align_auto_preserves_already_aligned() {
2434 let config = MD060Config {
2436 enabled: true,
2437 style: "aligned".to_string(),
2438 max_width: LineLength::from_const(0),
2439 column_align: ColumnAlign::Auto,
2440 column_align_header: None,
2441 column_align_body: None,
2442 loose_last_column: false,
2443 };
2444 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2445
2446 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449
2450 let fixed = rule.fix(&ctx).unwrap();
2451
2452 assert_eq!(
2454 fixed, content,
2455 "Already aligned table should be preserved with column-align=auto"
2456 );
2457 }
2458}