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 if let Some(header_width) = header_last_col_width {
352 if let Some(last) = column_widths.last_mut() {
353 *last = header_width;
354 }
355 }
356 }
357
358 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
361
362 if let Some(delimiter_cells) = delimiter_cells {
365 for (i, cell) in delimiter_cells.iter().enumerate() {
366 if i < final_widths.len() {
367 let trimmed = cell.trim();
368 let has_left_colon = trimmed.starts_with(':');
369 let has_right_colon = trimmed.ends_with(':');
370 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
371
372 let min_width_for_delimiter = 3 + colon_count;
374 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
375 }
376 }
377 }
378
379 final_widths
380 }
381
382 fn format_table_row(
383 cells: &[String],
384 column_widths: &[usize],
385 column_alignments: &[ColumnAlignment],
386 options: &RowFormatOptions,
387 ) -> String {
388 let formatted_cells: Vec<String> = cells
389 .iter()
390 .enumerate()
391 .map(|(i, cell)| {
392 let target_width = column_widths.get(i).copied().unwrap_or(0);
393
394 match options.row_type {
395 RowType::Delimiter => {
396 let trimmed = cell.trim();
397 let has_left_colon = trimmed.starts_with(':');
398 let has_right_colon = trimmed.ends_with(':');
399
400 let extra_width = if options.compact_delimiter { 2 } else { 0 };
404 let dash_count = if has_left_colon && has_right_colon {
405 (target_width + extra_width).saturating_sub(2)
406 } else if has_left_colon || has_right_colon {
407 (target_width + extra_width).saturating_sub(1)
408 } else {
409 target_width + extra_width
410 };
411
412 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
414 format!(":{dashes}:")
415 } else if has_left_colon {
416 format!(":{dashes}")
417 } else if has_right_colon {
418 format!("{dashes}:")
419 } else {
420 dashes
421 };
422
423 if options.compact_delimiter {
425 delimiter_content
426 } else {
427 format!(" {delimiter_content} ")
428 }
429 }
430 RowType::Header | RowType::Body => {
431 let trimmed = cell.trim();
432 let current_width = Self::calculate_cell_display_width(cell);
433 let padding = target_width.saturating_sub(current_width);
434
435 let effective_align = match options.row_type {
437 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
438 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
439 RowType::Delimiter => unreachable!(),
440 };
441
442 let alignment = match effective_align {
444 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
445 ColumnAlign::Left => ColumnAlignment::Left,
446 ColumnAlign::Center => ColumnAlignment::Center,
447 ColumnAlign::Right => ColumnAlignment::Right,
448 };
449
450 match alignment {
451 ColumnAlignment::Left => {
452 format!(" {trimmed}{} ", " ".repeat(padding))
454 }
455 ColumnAlignment::Center => {
456 let left_padding = padding / 2;
458 let right_padding = padding - left_padding;
459 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
460 }
461 ColumnAlignment::Right => {
462 format!(" {}{trimmed} ", " ".repeat(padding))
464 }
465 }
466 }
467 }
468 })
469 .collect();
470
471 format!("|{}|", formatted_cells.join("|"))
472 }
473
474 fn format_table_compact(cells: &[String]) -> String {
475 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
476 format!("|{}|", formatted_cells.join("|"))
477 }
478
479 fn format_table_tight(cells: &[String]) -> String {
480 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
481 format!("|{}|", formatted_cells.join("|"))
482 }
483
484 fn is_table_already_aligned(
496 table_lines: &[&str],
497 flavor: crate::config::MarkdownFlavor,
498 compact_delimiter: bool,
499 ) -> bool {
500 if table_lines.len() < 2 {
501 return false;
502 }
503
504 let first_len = table_lines[0].len();
506 if !table_lines.iter().all(|line| line.len() == first_len) {
507 return false;
508 }
509
510 let parsed: Vec<Vec<String>> = table_lines
512 .iter()
513 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
514 .collect();
515
516 if parsed.is_empty() {
517 return false;
518 }
519
520 let num_columns = parsed[0].len();
521 if !parsed.iter().all(|row| row.len() == num_columns) {
522 return false;
523 }
524
525 if let Some(delimiter_row) = parsed.get(1) {
528 if !Self::is_delimiter_row(delimiter_row) {
529 return false;
530 }
531 for cell in delimiter_row {
533 let trimmed = cell.trim();
534 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
535 if dash_count < 1 {
536 return false;
537 }
538 }
539
540 let delimiter_has_spaces = delimiter_row
544 .iter()
545 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
546
547 if compact_delimiter && delimiter_has_spaces {
550 return false;
551 }
552 if !compact_delimiter && !delimiter_has_spaces {
553 return false;
554 }
555 }
556
557 for col_idx in 0..num_columns {
561 let mut widths = Vec::new();
562 for (row_idx, row) in parsed.iter().enumerate() {
563 if row_idx == 1 {
565 continue;
566 }
567 if let Some(cell) = row.get(col_idx) {
568 widths.push(cell.width());
569 }
570 }
571 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
573 return false;
574 }
575 }
576
577 if let Some(delimiter_row) = parsed.get(1) {
582 let alignments = Self::parse_column_alignments(delimiter_row);
583 for (col_idx, alignment) in alignments.iter().enumerate() {
584 if *alignment == ColumnAlignment::Left {
585 continue;
586 }
587 for (row_idx, row) in parsed.iter().enumerate() {
588 if row_idx == 1 {
590 continue;
591 }
592 if let Some(cell) = row.get(col_idx) {
593 if cell.trim().is_empty() {
594 continue;
595 }
596 let left_pad = cell.len() - cell.trim_start().len();
598 let right_pad = cell.len() - cell.trim_end().len();
599
600 match alignment {
601 ColumnAlignment::Center => {
602 if left_pad.abs_diff(right_pad) > 1 {
604 return false;
605 }
606 }
607 ColumnAlignment::Right => {
608 if left_pad < right_pad {
610 return false;
611 }
612 }
613 ColumnAlignment::Left => unreachable!(),
614 }
615 }
616 }
617 }
618 }
619
620 true
621 }
622
623 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
624 if table_lines.is_empty() {
625 return None;
626 }
627
628 let mut is_tight = true;
631 let mut is_compact = true;
632
633 for line in table_lines {
634 let cells = Self::parse_table_row_with_flavor(line, flavor);
635
636 if cells.is_empty() {
637 continue;
638 }
639
640 if Self::is_delimiter_row(&cells) {
642 continue;
643 }
644
645 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
647
648 let row_has_single_space = cells.iter().all(|cell| {
650 let trimmed = cell.trim();
651 cell == &format!(" {trimmed} ")
652 });
653
654 if !row_has_no_padding {
656 is_tight = false;
657 }
658
659 if !row_has_single_space {
661 is_compact = false;
662 }
663
664 if !is_tight && !is_compact {
666 return Some("aligned".to_string());
667 }
668 }
669
670 if is_tight {
672 Some("tight".to_string())
673 } else if is_compact {
674 Some("compact".to_string())
675 } else {
676 Some("aligned".to_string())
677 }
678 }
679
680 fn fix_table_block(
681 &self,
682 lines: &[&str],
683 table_block: &crate::utils::table_utils::TableBlock,
684 flavor: crate::config::MarkdownFlavor,
685 ) -> TableFormatResult {
686 let mut result = Vec::new();
687 let mut auto_compacted = false;
688 let mut aligned_width = None;
689
690 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
691 .chain(std::iter::once(lines[table_block.delimiter_line]))
692 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
693 .collect();
694
695 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
696 return TableFormatResult {
697 lines: table_lines.iter().map(|s| s.to_string()).collect(),
698 auto_compacted: false,
699 aligned_width: None,
700 };
701 }
702
703 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
706
707 let list_context = &table_block.list_context;
709 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
710 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
711 } else {
712 ("", String::new())
713 };
714
715 let stripped_lines: Vec<&str> = table_lines
717 .iter()
718 .enumerate()
719 .map(|(i, line)| {
720 let after_blockquote = Self::extract_blockquote_prefix(line).1;
721 if list_context.is_some() {
722 if i == 0 {
723 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
725 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
726 })
727 } else {
728 after_blockquote
730 .strip_prefix(&continuation_indent)
731 .unwrap_or(after_blockquote.trim_start())
732 }
733 } else {
734 after_blockquote
735 }
736 })
737 .collect();
738
739 let style = self.config.style.as_str();
740
741 match style {
742 "any" => {
743 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
744 if detected_style.is_none() {
745 return TableFormatResult {
746 lines: table_lines.iter().map(|s| s.to_string()).collect(),
747 auto_compacted: false,
748 aligned_width: None,
749 };
750 }
751
752 let target_style = detected_style.unwrap();
753
754 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
756 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
757
758 for (row_idx, line) in stripped_lines.iter().enumerate() {
759 let cells = Self::parse_table_row_with_flavor(line, flavor);
760 match target_style.as_str() {
761 "tight" => result.push(Self::format_table_tight(&cells)),
762 "compact" => result.push(Self::format_table_compact(&cells)),
763 _ => {
764 let column_widths =
765 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
766 let row_type = match row_idx {
767 0 => RowType::Header,
768 1 => RowType::Delimiter,
769 _ => RowType::Body,
770 };
771 let options = RowFormatOptions {
772 row_type,
773 compact_delimiter: false,
774 column_align: self.config.column_align,
775 column_align_header: self.config.column_align_header,
776 column_align_body: self.config.column_align_body,
777 };
778 result.push(Self::format_table_row(
779 &cells,
780 &column_widths,
781 &column_alignments,
782 &options,
783 ));
784 }
785 }
786 }
787 }
788 "compact" => {
789 for line in &stripped_lines {
790 let cells = Self::parse_table_row_with_flavor(line, flavor);
791 result.push(Self::format_table_compact(&cells));
792 }
793 }
794 "tight" => {
795 for line in &stripped_lines {
796 let cells = Self::parse_table_row_with_flavor(line, flavor);
797 result.push(Self::format_table_tight(&cells));
798 }
799 }
800 "aligned" | "aligned-no-space" => {
801 let compact_delimiter = style == "aligned-no-space";
802
803 let needs_reformat = self.config.column_align != ColumnAlign::Auto
806 || self.config.column_align_header.is_some()
807 || self.config.column_align_body.is_some()
808 || self.config.loose_last_column;
809
810 if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
811 return TableFormatResult {
812 lines: table_lines.iter().map(|s| s.to_string()).collect(),
813 auto_compacted: false,
814 aligned_width: None,
815 };
816 }
817
818 let column_widths =
819 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
820
821 let num_columns = column_widths.len();
823 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
824 aligned_width = Some(calc_aligned_width);
825
826 if calc_aligned_width > self.effective_max_width() {
828 auto_compacted = true;
829 for line in &stripped_lines {
830 let cells = Self::parse_table_row_with_flavor(line, flavor);
831 result.push(Self::format_table_compact(&cells));
832 }
833 } else {
834 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
836 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
837
838 for (row_idx, line) in stripped_lines.iter().enumerate() {
839 let cells = Self::parse_table_row_with_flavor(line, flavor);
840 let row_type = match row_idx {
841 0 => RowType::Header,
842 1 => RowType::Delimiter,
843 _ => RowType::Body,
844 };
845 let options = RowFormatOptions {
846 row_type,
847 compact_delimiter,
848 column_align: self.config.column_align,
849 column_align_header: self.config.column_align_header,
850 column_align_body: self.config.column_align_body,
851 };
852 result.push(Self::format_table_row(
853 &cells,
854 &column_widths,
855 &column_alignments,
856 &options,
857 ));
858 }
859 }
860 }
861 _ => {
862 return TableFormatResult {
863 lines: table_lines.iter().map(|s| s.to_string()).collect(),
864 auto_compacted: false,
865 aligned_width: None,
866 };
867 }
868 }
869
870 let prefixed_result: Vec<String> = result
872 .into_iter()
873 .enumerate()
874 .map(|(i, line)| {
875 if list_context.is_some() {
876 if i == 0 {
877 format!("{blockquote_prefix}{list_prefix}{line}")
879 } else {
880 format!("{blockquote_prefix}{continuation_indent}{line}")
882 }
883 } else {
884 format!("{blockquote_prefix}{line}")
885 }
886 })
887 .collect();
888
889 TableFormatResult {
890 lines: prefixed_result,
891 auto_compacted,
892 aligned_width,
893 }
894 }
895}
896
897impl Rule for MD060TableFormat {
898 fn name(&self) -> &'static str {
899 "MD060"
900 }
901
902 fn description(&self) -> &'static str {
903 "Table columns should be consistently aligned"
904 }
905
906 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
907 !ctx.likely_has_tables()
908 }
909
910 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
911 let line_index = &ctx.line_index;
912 let mut warnings = Vec::new();
913
914 let lines = ctx.raw_lines();
915 let table_blocks = &ctx.table_blocks;
916
917 for table_block in table_blocks {
918 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
919
920 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
921 .chain(std::iter::once(table_block.delimiter_line))
922 .chain(table_block.content_lines.iter().copied())
923 .collect();
924
925 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());
932 for (i, &line_idx) in table_line_indices.iter().enumerate() {
933 let fixed_line = &format_result.lines[i];
934 if line_idx < lines.len() - 1 {
936 fixed_table_lines.push(format!("{fixed_line}\n"));
937 } else {
938 fixed_table_lines.push(fixed_line.clone());
939 }
940 }
941 let table_replacement = fixed_table_lines.concat();
942 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
943
944 for (i, &line_idx) in table_line_indices.iter().enumerate() {
945 let original = lines[line_idx];
946 let fixed = &format_result.lines[i];
947
948 if original != fixed {
949 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
950
951 let message = if format_result.auto_compacted {
952 if let Some(width) = format_result.aligned_width {
953 format!(
954 "Table too wide for aligned formatting ({} chars > max-width: {})",
955 width,
956 self.effective_max_width()
957 )
958 } else {
959 "Table too wide for aligned formatting".to_string()
960 }
961 } else {
962 "Table columns should be aligned".to_string()
963 };
964
965 warnings.push(LintWarning {
968 rule_name: Some(self.name().to_string()),
969 severity: Severity::Warning,
970 message,
971 line: start_line,
972 column: start_col,
973 end_line,
974 end_column: end_col,
975 fix: Some(crate::rule::Fix {
976 range: table_range.clone(),
977 replacement: table_replacement.clone(),
978 }),
979 });
980 }
981 }
982 }
983
984 Ok(warnings)
985 }
986
987 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
988 let content = ctx.content;
989 let lines = ctx.raw_lines();
990 let table_blocks = &ctx.table_blocks;
991
992 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
993
994 for table_block in table_blocks {
995 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
996
997 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
998 .chain(std::iter::once(table_block.delimiter_line))
999 .chain(table_block.content_lines.iter().copied())
1000 .collect();
1001
1002 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1003 result_lines[line_idx] = format_result.lines[i].clone();
1004 }
1005 }
1006
1007 let mut fixed = result_lines.join("\n");
1008 if content.ends_with('\n') && !fixed.ends_with('\n') {
1009 fixed.push('\n');
1010 }
1011 Ok(fixed)
1012 }
1013
1014 fn as_any(&self) -> &dyn std::any::Any {
1015 self
1016 }
1017
1018 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1019 let mut table = toml::map::Map::new();
1022 table.insert("enabled".to_string(), toml::Value::Boolean(self.config.enabled));
1023 table.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
1024 table.insert(
1025 "max-width".to_string(),
1026 toml::Value::Integer(self.config.max_width.get() as i64),
1027 );
1028 table.insert(
1029 "column-align".to_string(),
1030 toml::Value::String(
1031 match self.config.column_align {
1032 ColumnAlign::Auto => "auto",
1033 ColumnAlign::Left => "left",
1034 ColumnAlign::Center => "center",
1035 ColumnAlign::Right => "right",
1036 }
1037 .to_string(),
1038 ),
1039 );
1040 table.insert(
1042 "column-align-header".to_string(),
1043 toml::Value::String("auto".to_string()),
1044 );
1045 table.insert("column-align-body".to_string(), toml::Value::String("auto".to_string()));
1046 table.insert(
1047 "loose-last-column".to_string(),
1048 toml::Value::Boolean(self.config.loose_last_column),
1049 );
1050
1051 Some((self.name().to_string(), toml::Value::Table(table)))
1052 }
1053
1054 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1055 where
1056 Self: Sized,
1057 {
1058 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1059 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1060
1061 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1063
1064 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1065 }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use crate::lint_context::LintContext;
1072 use crate::types::LineLength;
1073
1074 fn md013_with_line_length(line_length: usize) -> MD013Config {
1076 MD013Config {
1077 line_length: LineLength::from_const(line_length),
1078 tables: true, ..Default::default()
1080 }
1081 }
1082
1083 #[test]
1084 fn test_md060_align_simple_ascii_table() {
1085 let rule = MD060TableFormat::new(true, "aligned".to_string());
1086
1087 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089
1090 let fixed = rule.fix(&ctx).unwrap();
1091 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1092 assert_eq!(fixed, expected);
1093
1094 let lines: Vec<&str> = fixed.lines().collect();
1096 assert_eq!(lines[0].len(), lines[1].len());
1097 assert_eq!(lines[1].len(), lines[2].len());
1098 }
1099
1100 #[test]
1101 fn test_md060_cjk_characters_aligned_correctly() {
1102 let rule = MD060TableFormat::new(true, "aligned".to_string());
1103
1104 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106
1107 let fixed = rule.fix(&ctx).unwrap();
1108
1109 let lines: Vec<&str> = fixed.lines().collect();
1110 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1111 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1112
1113 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1114 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1115
1116 assert_eq!(width1, width3);
1117 }
1118
1119 #[test]
1120 fn test_md060_basic_emoji() {
1121 let rule = MD060TableFormat::new(true, "aligned".to_string());
1122
1123 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1125
1126 let fixed = rule.fix(&ctx).unwrap();
1127 assert!(fixed.contains("Status"));
1128 }
1129
1130 #[test]
1131 fn test_md060_zwj_emoji_skipped() {
1132 let rule = MD060TableFormat::new(true, "aligned".to_string());
1133
1134 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136
1137 let fixed = rule.fix(&ctx).unwrap();
1138 assert_eq!(fixed, content);
1139 }
1140
1141 #[test]
1142 fn test_md060_inline_code_with_escaped_pipes() {
1143 let rule = MD060TableFormat::new(true, "aligned".to_string());
1146
1147 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150
1151 let fixed = rule.fix(&ctx).unwrap();
1152 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1153 }
1154
1155 #[test]
1156 fn test_md060_compact_style() {
1157 let rule = MD060TableFormat::new(true, "compact".to_string());
1158
1159 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161
1162 let fixed = rule.fix(&ctx).unwrap();
1163 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1164 assert_eq!(fixed, expected);
1165 }
1166
1167 #[test]
1168 fn test_md060_tight_style() {
1169 let rule = MD060TableFormat::new(true, "tight".to_string());
1170
1171 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173
1174 let fixed = rule.fix(&ctx).unwrap();
1175 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1176 assert_eq!(fixed, expected);
1177 }
1178
1179 #[test]
1180 fn test_md060_aligned_no_space_style() {
1181 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1183
1184 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186
1187 let fixed = rule.fix(&ctx).unwrap();
1188
1189 let lines: Vec<&str> = fixed.lines().collect();
1191 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1192 assert_eq!(
1193 lines[1], "|-------|-----|",
1194 "Delimiter should have NO spaces around dashes"
1195 );
1196 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1197
1198 assert_eq!(lines[0].len(), lines[1].len());
1200 assert_eq!(lines[1].len(), lines[2].len());
1201 }
1202
1203 #[test]
1204 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1205 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1207
1208 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210
1211 let fixed = rule.fix(&ctx).unwrap();
1212 let lines: Vec<&str> = fixed.lines().collect();
1213
1214 assert!(
1216 fixed.contains("|:"),
1217 "Should have left alignment indicator adjacent to pipe"
1218 );
1219 assert!(
1220 fixed.contains(":|"),
1221 "Should have right alignment indicator adjacent to pipe"
1222 );
1223 assert!(
1225 lines[1].contains(":---") && lines[1].contains("---:"),
1226 "Should have center alignment colons"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_md060_aligned_no_space_three_column_table() {
1232 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1234
1235 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 |";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237
1238 let fixed = rule.fix(&ctx).unwrap();
1239 let lines: Vec<&str> = fixed.lines().collect();
1240
1241 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1243 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1244 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1245 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1246 }
1247
1248 #[test]
1249 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1250 let config = MD060Config {
1252 enabled: true,
1253 style: "aligned-no-space".to_string(),
1254 max_width: LineLength::from_const(50),
1255 column_align: ColumnAlign::Auto,
1256 column_align_header: None,
1257 column_align_body: None,
1258 loose_last_column: false,
1259 };
1260 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1261
1262 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265
1266 let fixed = rule.fix(&ctx).unwrap();
1267
1268 assert!(
1270 fixed.contains("| --- |"),
1271 "Should be compact format when exceeding max-width"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_md060_aligned_no_space_cjk_characters() {
1277 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1279
1280 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282
1283 let fixed = rule.fix(&ctx).unwrap();
1284 let lines: Vec<&str> = fixed.lines().collect();
1285
1286 use unicode_width::UnicodeWidthStr;
1289 assert_eq!(
1290 lines[0].width(),
1291 lines[1].width(),
1292 "Header and delimiter should have same display width"
1293 );
1294 assert_eq!(
1295 lines[1].width(),
1296 lines[2].width(),
1297 "Delimiter and content should have same display width"
1298 );
1299
1300 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1302 }
1303
1304 #[test]
1305 fn test_md060_aligned_no_space_minimum_width() {
1306 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1308
1309 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311
1312 let fixed = rule.fix(&ctx).unwrap();
1313 let lines: Vec<&str> = fixed.lines().collect();
1314
1315 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1317 assert_eq!(lines[0].len(), lines[1].len());
1319 assert_eq!(lines[1].len(), lines[2].len());
1320 }
1321
1322 #[test]
1323 fn test_md060_any_style_consistency() {
1324 let rule = MD060TableFormat::new(true, "any".to_string());
1325
1326 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329
1330 let fixed = rule.fix(&ctx).unwrap();
1331 assert_eq!(fixed, content);
1332
1333 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1335 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1336
1337 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1338 assert_eq!(fixed_aligned, content_aligned);
1339 }
1340
1341 #[test]
1342 fn test_md060_empty_cells() {
1343 let rule = MD060TableFormat::new(true, "aligned".to_string());
1344
1345 let content = "| A | B |\n|---|---|\n| | X |";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347
1348 let fixed = rule.fix(&ctx).unwrap();
1349 assert!(fixed.contains("|"));
1350 }
1351
1352 #[test]
1353 fn test_md060_mixed_content() {
1354 let rule = MD060TableFormat::new(true, "aligned".to_string());
1355
1356 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358
1359 let fixed = rule.fix(&ctx).unwrap();
1360 assert!(fixed.contains("δΈζ"));
1361 assert!(fixed.contains("NYC"));
1362 }
1363
1364 #[test]
1365 fn test_md060_preserve_alignment_indicators() {
1366 let rule = MD060TableFormat::new(true, "aligned".to_string());
1367
1368 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370
1371 let fixed = rule.fix(&ctx).unwrap();
1372
1373 assert!(fixed.contains(":---"), "Should contain left alignment");
1374 assert!(fixed.contains(":----:"), "Should contain center alignment");
1375 assert!(fixed.contains("----:"), "Should contain right alignment");
1376 }
1377
1378 #[test]
1379 fn test_md060_minimum_column_width() {
1380 let rule = MD060TableFormat::new(true, "aligned".to_string());
1381
1382 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386
1387 let fixed = rule.fix(&ctx).unwrap();
1388
1389 let lines: Vec<&str> = fixed.lines().collect();
1390 assert_eq!(lines[0].len(), lines[1].len());
1391 assert_eq!(lines[1].len(), lines[2].len());
1392
1393 assert!(fixed.contains("ID "), "Short content should be padded");
1395 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1396 }
1397
1398 #[test]
1399 fn test_md060_auto_compact_exceeds_default_threshold() {
1400 let config = MD060Config {
1402 enabled: true,
1403 style: "aligned".to_string(),
1404 max_width: LineLength::from_const(0),
1405 column_align: ColumnAlign::Auto,
1406 column_align_header: None,
1407 column_align_body: None,
1408 loose_last_column: false,
1409 };
1410 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1411
1412 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417
1418 let fixed = rule.fix(&ctx).unwrap();
1419
1420 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1422 assert!(fixed.contains("| --- | --- | --- |"));
1423 assert!(fixed.contains("| Short | Data | Here |"));
1424
1425 let lines: Vec<&str> = fixed.lines().collect();
1427 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1429 }
1430
1431 #[test]
1432 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1433 let config = MD060Config {
1435 enabled: true,
1436 style: "aligned".to_string(),
1437 max_width: LineLength::from_const(50),
1438 column_align: ColumnAlign::Auto,
1439 column_align_header: None,
1440 column_align_body: None,
1441 loose_last_column: false,
1442 };
1443 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 |";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450
1451 let fixed = rule.fix(&ctx).unwrap();
1452
1453 assert!(
1455 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1456 );
1457 assert!(fixed.contains("| --- | --- | --- |"));
1458 assert!(fixed.contains("| Data | Data | Data |"));
1459
1460 let lines: Vec<&str> = fixed.lines().collect();
1462 assert!(lines[0].len() != lines[2].len());
1463 }
1464
1465 #[test]
1466 fn test_md060_stays_aligned_under_threshold() {
1467 let config = MD060Config {
1469 enabled: true,
1470 style: "aligned".to_string(),
1471 max_width: LineLength::from_const(100),
1472 column_align: ColumnAlign::Auto,
1473 column_align_header: None,
1474 column_align_body: None,
1475 loose_last_column: false,
1476 };
1477 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1478
1479 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482
1483 let fixed = rule.fix(&ctx).unwrap();
1484
1485 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1487 assert_eq!(fixed, expected);
1488
1489 let lines: Vec<&str> = fixed.lines().collect();
1490 assert_eq!(lines[0].len(), lines[1].len());
1491 assert_eq!(lines[1].len(), lines[2].len());
1492 }
1493
1494 #[test]
1495 fn test_md060_width_calculation_formula() {
1496 let config = MD060Config {
1498 enabled: true,
1499 style: "aligned".to_string(),
1500 max_width: LineLength::from_const(0),
1501 column_align: ColumnAlign::Auto,
1502 column_align_header: None,
1503 column_align_body: None,
1504 loose_last_column: false,
1505 };
1506 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1507
1508 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513
1514 let fixed = rule.fix(&ctx).unwrap();
1515
1516 let lines: Vec<&str> = fixed.lines().collect();
1518 assert_eq!(lines[0].len(), lines[1].len());
1519 assert_eq!(lines[1].len(), lines[2].len());
1520 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1524 enabled: true,
1525 style: "aligned".to_string(),
1526 max_width: LineLength::from_const(24),
1527 column_align: ColumnAlign::Auto,
1528 column_align_header: None,
1529 column_align_body: None,
1530 loose_last_column: false,
1531 };
1532 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1533
1534 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1535
1536 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1538 assert!(fixed_compact.contains("| --- | --- | --- |"));
1539 }
1540
1541 #[test]
1542 fn test_md060_very_wide_table_auto_compacts() {
1543 let config = MD060Config {
1544 enabled: true,
1545 style: "aligned".to_string(),
1546 max_width: LineLength::from_const(0),
1547 column_align: ColumnAlign::Auto,
1548 column_align_header: None,
1549 column_align_body: None,
1550 loose_last_column: false,
1551 };
1552 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1553
1554 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 |";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559
1560 let fixed = rule.fix(&ctx).unwrap();
1561
1562 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1564 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1565 }
1566
1567 #[test]
1568 fn test_md060_inherit_from_md013_line_length() {
1569 let config = MD060Config {
1571 enabled: true,
1572 style: "aligned".to_string(),
1573 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1575 column_align_header: None,
1576 column_align_body: None,
1577 loose_last_column: false,
1578 };
1579
1580 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1582 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1583
1584 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1587
1588 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1590
1591 let fixed_120 = rule_120.fix(&ctx).unwrap();
1593
1594 let lines_120: Vec<&str> = fixed_120.lines().collect();
1596 assert_eq!(lines_120[0].len(), lines_120[1].len());
1597 assert_eq!(lines_120[1].len(), lines_120[2].len());
1598 }
1599
1600 #[test]
1601 fn test_md060_edge_case_exactly_at_threshold() {
1602 let config = MD060Config {
1606 enabled: true,
1607 style: "aligned".to_string(),
1608 max_width: LineLength::from_const(17),
1609 column_align: ColumnAlign::Auto,
1610 column_align_header: None,
1611 column_align_body: None,
1612 loose_last_column: false,
1613 };
1614 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1615
1616 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618
1619 let fixed = rule.fix(&ctx).unwrap();
1620
1621 let lines: Vec<&str> = fixed.lines().collect();
1623 assert_eq!(lines[0].len(), 17);
1624 assert_eq!(lines[0].len(), lines[1].len());
1625 assert_eq!(lines[1].len(), lines[2].len());
1626
1627 let config_under = MD060Config {
1629 enabled: true,
1630 style: "aligned".to_string(),
1631 max_width: LineLength::from_const(16),
1632 column_align: ColumnAlign::Auto,
1633 column_align_header: None,
1634 column_align_body: None,
1635 loose_last_column: false,
1636 };
1637 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1638
1639 let fixed_compact = rule_under.fix(&ctx).unwrap();
1640
1641 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1643 assert!(fixed_compact.contains("| --- | --- |"));
1644 }
1645
1646 #[test]
1647 fn test_md060_auto_compact_warning_message() {
1648 let config = MD060Config {
1650 enabled: true,
1651 style: "aligned".to_string(),
1652 max_width: LineLength::from_const(50),
1653 column_align: ColumnAlign::Auto,
1654 column_align_header: None,
1655 column_align_body: None,
1656 loose_last_column: false,
1657 };
1658 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1659
1660 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663
1664 let warnings = rule.check(&ctx).unwrap();
1665
1666 assert!(!warnings.is_empty(), "Should generate warnings");
1668
1669 let auto_compact_warnings: Vec<_> = warnings
1670 .iter()
1671 .filter(|w| w.message.contains("too wide for aligned formatting"))
1672 .collect();
1673
1674 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1675
1676 let first_warning = auto_compact_warnings[0];
1678 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1679 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1680 }
1681
1682 #[test]
1683 fn test_md060_issue_129_detect_style_from_all_rows() {
1684 let rule = MD060TableFormat::new(true, "any".to_string());
1688
1689 let content = "| a long heading | another long heading |\n\
1691 | -------------- | -------------------- |\n\
1692 | a | 1 |\n\
1693 | b b | 2 |\n\
1694 | c c c | 3 |";
1695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1696
1697 let fixed = rule.fix(&ctx).unwrap();
1698
1699 assert!(
1701 fixed.contains("| a | 1 |"),
1702 "Should preserve aligned padding in first content row"
1703 );
1704 assert!(
1705 fixed.contains("| b b | 2 |"),
1706 "Should preserve aligned padding in second content row"
1707 );
1708 assert!(
1709 fixed.contains("| c c c | 3 |"),
1710 "Should preserve aligned padding in third content row"
1711 );
1712
1713 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1715 }
1716
1717 #[test]
1718 fn test_md060_regular_alignment_warning_message() {
1719 let config = MD060Config {
1721 enabled: true,
1722 style: "aligned".to_string(),
1723 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1725 column_align_header: None,
1726 column_align_body: None,
1727 loose_last_column: false,
1728 };
1729 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1730
1731 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1734
1735 let warnings = rule.check(&ctx).unwrap();
1736
1737 assert!(!warnings.is_empty(), "Should generate warnings");
1739
1740 assert!(warnings[0].message.contains("Table columns should be aligned"));
1742 assert!(!warnings[0].message.contains("too wide"));
1743 assert!(!warnings[0].message.contains("max-width"));
1744 }
1745
1746 #[test]
1749 fn test_md060_unlimited_when_md013_disabled() {
1750 let config = MD060Config {
1752 enabled: true,
1753 style: "aligned".to_string(),
1754 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1756 column_align_header: None,
1757 column_align_body: None,
1758 loose_last_column: false,
1759 };
1760 let md013_config = MD013Config::default();
1761 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1762
1763 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let fixed = rule.fix(&ctx).unwrap();
1767
1768 let lines: Vec<&str> = fixed.lines().collect();
1770 assert_eq!(
1772 lines[0].len(),
1773 lines[1].len(),
1774 "Table should be aligned when MD013 is disabled"
1775 );
1776 }
1777
1778 #[test]
1779 fn test_md060_unlimited_when_md013_tables_false() {
1780 let config = MD060Config {
1782 enabled: true,
1783 style: "aligned".to_string(),
1784 max_width: LineLength::from_const(0),
1785 column_align: ColumnAlign::Auto,
1786 column_align_header: None,
1787 column_align_body: None,
1788 loose_last_column: false,
1789 };
1790 let md013_config = MD013Config {
1791 tables: false, line_length: LineLength::from_const(80),
1793 ..Default::default()
1794 };
1795 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1796
1797 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800 let fixed = rule.fix(&ctx).unwrap();
1801
1802 let lines: Vec<&str> = fixed.lines().collect();
1804 assert_eq!(
1805 lines[0].len(),
1806 lines[1].len(),
1807 "Table should be aligned when MD013.tables=false"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_md060_unlimited_when_md013_line_length_zero() {
1813 let config = MD060Config {
1815 enabled: true,
1816 style: "aligned".to_string(),
1817 max_width: LineLength::from_const(0),
1818 column_align: ColumnAlign::Auto,
1819 column_align_header: None,
1820 column_align_body: None,
1821 loose_last_column: false,
1822 };
1823 let md013_config = MD013Config {
1824 tables: true,
1825 line_length: LineLength::from_const(0), ..Default::default()
1827 };
1828 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1829
1830 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833 let fixed = rule.fix(&ctx).unwrap();
1834
1835 let lines: Vec<&str> = fixed.lines().collect();
1837 assert_eq!(
1838 lines[0].len(),
1839 lines[1].len(),
1840 "Table should be aligned when MD013.line_length=0"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_md060_explicit_max_width_overrides_md013_settings() {
1846 let config = MD060Config {
1848 enabled: true,
1849 style: "aligned".to_string(),
1850 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1852 column_align_header: None,
1853 column_align_body: None,
1854 loose_last_column: false,
1855 };
1856 let md013_config = MD013Config {
1857 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1860 };
1861 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1862
1863 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1866 let fixed = rule.fix(&ctx).unwrap();
1867
1868 assert!(
1870 fixed.contains("| --- |"),
1871 "Should be compact format due to explicit max_width"
1872 );
1873 }
1874
1875 #[test]
1876 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1877 let config = MD060Config {
1879 enabled: true,
1880 style: "aligned".to_string(),
1881 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1883 column_align_header: None,
1884 column_align_body: None,
1885 loose_last_column: false,
1886 };
1887 let md013_config = MD013Config {
1888 tables: true,
1889 line_length: LineLength::from_const(50), ..Default::default()
1891 };
1892 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1893
1894 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1897 let fixed = rule.fix(&ctx).unwrap();
1898
1899 assert!(
1901 fixed.contains("| --- |"),
1902 "Should be compact format when inheriting MD013 limit"
1903 );
1904 }
1905
1906 #[test]
1909 fn test_aligned_no_space_reformats_spaced_delimiter() {
1910 let config = MD060Config {
1913 enabled: true,
1914 style: "aligned-no-space".to_string(),
1915 max_width: LineLength::from_const(0),
1916 column_align: ColumnAlign::Auto,
1917 column_align_header: None,
1918 column_align_body: None,
1919 loose_last_column: false,
1920 };
1921 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1922
1923 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let fixed = rule.fix(&ctx).unwrap();
1927
1928 assert!(
1931 !fixed.contains("| ----"),
1932 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1933 );
1934 assert!(
1935 !fixed.contains("---- |"),
1936 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1937 );
1938 assert!(
1940 fixed.contains("|----"),
1941 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_aligned_reformats_compact_delimiter() {
1947 let config = MD060Config {
1950 enabled: true,
1951 style: "aligned".to_string(),
1952 max_width: LineLength::from_const(0),
1953 column_align: ColumnAlign::Auto,
1954 column_align_header: None,
1955 column_align_body: None,
1956 loose_last_column: false,
1957 };
1958 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1959
1960 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963 let fixed = rule.fix(&ctx).unwrap();
1964
1965 assert!(
1967 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1968 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1969 );
1970 }
1971
1972 #[test]
1973 fn test_aligned_no_space_preserves_matching_table() {
1974 let config = MD060Config {
1976 enabled: true,
1977 style: "aligned-no-space".to_string(),
1978 max_width: LineLength::from_const(0),
1979 column_align: ColumnAlign::Auto,
1980 column_align_header: None,
1981 column_align_body: None,
1982 loose_last_column: false,
1983 };
1984 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1985
1986 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1989 let fixed = rule.fix(&ctx).unwrap();
1990
1991 assert_eq!(
1993 fixed, content,
1994 "Table already in aligned-no-space style should be preserved"
1995 );
1996 }
1997
1998 #[test]
1999 fn test_aligned_preserves_matching_table() {
2000 let config = MD060Config {
2002 enabled: true,
2003 style: "aligned".to_string(),
2004 max_width: LineLength::from_const(0),
2005 column_align: ColumnAlign::Auto,
2006 column_align_header: None,
2007 column_align_body: None,
2008 loose_last_column: false,
2009 };
2010 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2011
2012 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let fixed = rule.fix(&ctx).unwrap();
2016
2017 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2019 }
2020
2021 #[test]
2022 fn test_cjk_table_display_width_consistency() {
2023 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2029
2030 let is_aligned =
2032 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2033 assert!(
2034 !is_aligned,
2035 "Table with uneven raw line lengths should NOT be considered aligned"
2036 );
2037 }
2038
2039 #[test]
2040 fn test_cjk_width_calculation_in_aligned_check() {
2041 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2044 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2045
2046 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2047 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2048
2049 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2051 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2052
2053 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2055 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2057 }
2058
2059 #[test]
2062 fn test_md060_column_align_left() {
2063 let config = MD060Config {
2065 enabled: true,
2066 style: "aligned".to_string(),
2067 max_width: LineLength::from_const(0),
2068 column_align: ColumnAlign::Left,
2069 column_align_header: None,
2070 column_align_body: None,
2071 loose_last_column: false,
2072 };
2073 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2074
2075 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2077
2078 let fixed = rule.fix(&ctx).unwrap();
2079 let lines: Vec<&str> = fixed.lines().collect();
2080
2081 assert!(
2083 lines[2].contains("| Alice "),
2084 "Content should be left-aligned (Alice should have trailing padding)"
2085 );
2086 assert!(
2087 lines[3].contains("| Bob "),
2088 "Content should be left-aligned (Bob should have trailing padding)"
2089 );
2090 }
2091
2092 #[test]
2093 fn test_md060_column_align_center() {
2094 let config = MD060Config {
2096 enabled: true,
2097 style: "aligned".to_string(),
2098 max_width: LineLength::from_const(0),
2099 column_align: ColumnAlign::Center,
2100 column_align_header: None,
2101 column_align_body: None,
2102 loose_last_column: false,
2103 };
2104 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2105
2106 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2108
2109 let fixed = rule.fix(&ctx).unwrap();
2110 let lines: Vec<&str> = fixed.lines().collect();
2111
2112 assert!(
2115 lines[3].contains("| Bob |"),
2116 "Bob should be centered with padding on both sides. Got: {}",
2117 lines[3]
2118 );
2119 }
2120
2121 #[test]
2122 fn test_md060_column_align_right() {
2123 let config = MD060Config {
2125 enabled: true,
2126 style: "aligned".to_string(),
2127 max_width: LineLength::from_const(0),
2128 column_align: ColumnAlign::Right,
2129 column_align_header: None,
2130 column_align_body: None,
2131 loose_last_column: false,
2132 };
2133 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2134
2135 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2137
2138 let fixed = rule.fix(&ctx).unwrap();
2139 let lines: Vec<&str> = fixed.lines().collect();
2140
2141 assert!(
2143 lines[3].contains("| Bob |"),
2144 "Bob should be right-aligned with padding on left. Got: {}",
2145 lines[3]
2146 );
2147 }
2148
2149 #[test]
2150 fn test_md060_column_align_auto_respects_delimiter() {
2151 let config = MD060Config {
2153 enabled: true,
2154 style: "aligned".to_string(),
2155 max_width: LineLength::from_const(0),
2156 column_align: ColumnAlign::Auto,
2157 column_align_header: None,
2158 column_align_body: None,
2159 loose_last_column: false,
2160 };
2161 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2162
2163 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2166
2167 let fixed = rule.fix(&ctx).unwrap();
2168
2169 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2171 let lines: Vec<&str> = fixed.lines().collect();
2173 assert!(
2177 lines[2].contains(" C |"),
2178 "Right column should be right-aligned. Got: {}",
2179 lines[2]
2180 );
2181 }
2182
2183 #[test]
2184 fn test_md060_column_align_overrides_delimiter_indicators() {
2185 let config = MD060Config {
2187 enabled: true,
2188 style: "aligned".to_string(),
2189 max_width: LineLength::from_const(0),
2190 column_align: ColumnAlign::Right, column_align_header: None,
2192 column_align_body: None,
2193 loose_last_column: false,
2194 };
2195 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2196
2197 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2200
2201 let fixed = rule.fix(&ctx).unwrap();
2202 let lines: Vec<&str> = fixed.lines().collect();
2203
2204 assert!(
2207 lines[2].contains(" A |") || lines[2].contains(" A |"),
2208 "Even left-indicated column should be right-aligned. Got: {}",
2209 lines[2]
2210 );
2211 }
2212
2213 #[test]
2214 fn test_md060_column_align_with_aligned_no_space() {
2215 let config = MD060Config {
2217 enabled: true,
2218 style: "aligned-no-space".to_string(),
2219 max_width: LineLength::from_const(0),
2220 column_align: ColumnAlign::Center,
2221 column_align_header: None,
2222 column_align_body: None,
2223 loose_last_column: false,
2224 };
2225 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2226
2227 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2229
2230 let fixed = rule.fix(&ctx).unwrap();
2231 let lines: Vec<&str> = fixed.lines().collect();
2232
2233 assert!(
2235 lines[1].contains("|---"),
2236 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2237 lines[1]
2238 );
2239 assert!(
2241 lines[3].contains("| Bob |"),
2242 "Content should be centered. Got: {}",
2243 lines[3]
2244 );
2245 }
2246
2247 #[test]
2248 fn test_md060_column_align_config_parsing() {
2249 let toml_str = r#"
2251enabled = true
2252style = "aligned"
2253column-align = "center"
2254"#;
2255 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2256 assert_eq!(config.column_align, ColumnAlign::Center);
2257
2258 let toml_str = r#"
2259enabled = true
2260style = "aligned"
2261column-align = "right"
2262"#;
2263 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2264 assert_eq!(config.column_align, ColumnAlign::Right);
2265
2266 let toml_str = r#"
2267enabled = true
2268style = "aligned"
2269column-align = "left"
2270"#;
2271 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2272 assert_eq!(config.column_align, ColumnAlign::Left);
2273
2274 let toml_str = r#"
2275enabled = true
2276style = "aligned"
2277column-align = "auto"
2278"#;
2279 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2280 assert_eq!(config.column_align, ColumnAlign::Auto);
2281 }
2282
2283 #[test]
2284 fn test_md060_column_align_default_is_auto() {
2285 let toml_str = r#"
2287enabled = true
2288style = "aligned"
2289"#;
2290 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2291 assert_eq!(config.column_align, ColumnAlign::Auto);
2292 }
2293
2294 #[test]
2295 fn test_md060_column_align_reformats_already_aligned_table() {
2296 let config = MD060Config {
2298 enabled: true,
2299 style: "aligned".to_string(),
2300 max_width: LineLength::from_const(0),
2301 column_align: ColumnAlign::Right,
2302 column_align_header: None,
2303 column_align_body: None,
2304 loose_last_column: false,
2305 };
2306 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2307
2308 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311
2312 let fixed = rule.fix(&ctx).unwrap();
2313 let lines: Vec<&str> = fixed.lines().collect();
2314
2315 assert!(
2317 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2318 "Already aligned table should be reformatted with right alignment. Got: {}",
2319 lines[2]
2320 );
2321 assert!(
2322 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2323 "Bob should be right-aligned. Got: {}",
2324 lines[3]
2325 );
2326 }
2327
2328 #[test]
2329 fn test_md060_column_align_with_cjk_characters() {
2330 let config = MD060Config {
2332 enabled: true,
2333 style: "aligned".to_string(),
2334 max_width: LineLength::from_const(0),
2335 column_align: ColumnAlign::Center,
2336 column_align_header: None,
2337 column_align_body: None,
2338 loose_last_column: false,
2339 };
2340 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2341
2342 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2344
2345 let fixed = rule.fix(&ctx).unwrap();
2346
2347 assert!(fixed.contains("Bob"), "Table should contain Bob");
2350 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2351 }
2352
2353 #[test]
2354 fn test_md060_column_align_ignored_for_compact_style() {
2355 let config = MD060Config {
2357 enabled: true,
2358 style: "compact".to_string(),
2359 max_width: LineLength::from_const(0),
2360 column_align: ColumnAlign::Right, column_align_header: None,
2362 column_align_body: None,
2363 loose_last_column: false,
2364 };
2365 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2366
2367 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369
2370 let fixed = rule.fix(&ctx).unwrap();
2371
2372 assert!(
2374 fixed.contains("| Alice |"),
2375 "Compact style should have single space padding, not alignment. Got: {fixed}"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_md060_column_align_ignored_for_tight_style() {
2381 let config = MD060Config {
2383 enabled: true,
2384 style: "tight".to_string(),
2385 max_width: LineLength::from_const(0),
2386 column_align: ColumnAlign::Center, column_align_header: None,
2388 column_align_body: None,
2389 loose_last_column: false,
2390 };
2391 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2392
2393 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395
2396 let fixed = rule.fix(&ctx).unwrap();
2397
2398 assert!(
2400 fixed.contains("|Alice|"),
2401 "Tight style should have no spaces. Got: {fixed}"
2402 );
2403 }
2404
2405 #[test]
2406 fn test_md060_column_align_with_empty_cells() {
2407 let config = MD060Config {
2409 enabled: true,
2410 style: "aligned".to_string(),
2411 max_width: LineLength::from_const(0),
2412 column_align: ColumnAlign::Center,
2413 column_align_header: None,
2414 column_align_body: None,
2415 loose_last_column: false,
2416 };
2417 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2418
2419 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2421
2422 let fixed = rule.fix(&ctx).unwrap();
2423 let lines: Vec<&str> = fixed.lines().collect();
2424
2425 assert!(
2427 lines[3].contains("| |") || lines[3].contains("| |"),
2428 "Empty cell should be padded correctly. Got: {}",
2429 lines[3]
2430 );
2431 }
2432
2433 #[test]
2434 fn test_md060_column_align_auto_preserves_already_aligned() {
2435 let config = MD060Config {
2437 enabled: true,
2438 style: "aligned".to_string(),
2439 max_width: LineLength::from_const(0),
2440 column_align: ColumnAlign::Auto,
2441 column_align_header: None,
2442 column_align_body: None,
2443 loose_last_column: false,
2444 };
2445 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2446
2447 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2450
2451 let fixed = rule.fix(&ctx).unwrap();
2452
2453 assert_eq!(
2455 fixed, content,
2456 "Already aligned table should be preserved with column-align=auto"
2457 );
2458 }
2459}