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