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 true
568 }
569
570 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
571 if table_lines.is_empty() {
572 return None;
573 }
574
575 let mut is_tight = true;
578 let mut is_compact = true;
579
580 for line in table_lines {
581 let cells = Self::parse_table_row_with_flavor(line, flavor);
582
583 if cells.is_empty() {
584 continue;
585 }
586
587 if Self::is_delimiter_row(&cells) {
589 continue;
590 }
591
592 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
594
595 let row_has_single_space = cells.iter().all(|cell| {
597 let trimmed = cell.trim();
598 cell == &format!(" {trimmed} ")
599 });
600
601 if !row_has_no_padding {
603 is_tight = false;
604 }
605
606 if !row_has_single_space {
608 is_compact = false;
609 }
610
611 if !is_tight && !is_compact {
613 return Some("aligned".to_string());
614 }
615 }
616
617 if is_tight {
619 Some("tight".to_string())
620 } else if is_compact {
621 Some("compact".to_string())
622 } else {
623 Some("aligned".to_string())
624 }
625 }
626
627 fn fix_table_block(
628 &self,
629 lines: &[&str],
630 table_block: &crate::utils::table_utils::TableBlock,
631 flavor: crate::config::MarkdownFlavor,
632 ) -> TableFormatResult {
633 let mut result = Vec::new();
634 let mut auto_compacted = false;
635 let mut aligned_width = None;
636
637 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
638 .chain(std::iter::once(lines[table_block.delimiter_line]))
639 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
640 .collect();
641
642 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
643 return TableFormatResult {
644 lines: table_lines.iter().map(|s| s.to_string()).collect(),
645 auto_compacted: false,
646 aligned_width: None,
647 };
648 }
649
650 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
653
654 let list_context = &table_block.list_context;
656 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
657 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
658 } else {
659 ("", String::new())
660 };
661
662 let stripped_lines: Vec<&str> = table_lines
664 .iter()
665 .enumerate()
666 .map(|(i, line)| {
667 let after_blockquote = Self::extract_blockquote_prefix(line).1;
668 if list_context.is_some() {
669 if i == 0 {
670 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
672 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
673 })
674 } else {
675 after_blockquote
677 .strip_prefix(&continuation_indent)
678 .unwrap_or(after_blockquote.trim_start())
679 }
680 } else {
681 after_blockquote
682 }
683 })
684 .collect();
685
686 let style = self.config.style.as_str();
687
688 match style {
689 "any" => {
690 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
691 if detected_style.is_none() {
692 return TableFormatResult {
693 lines: table_lines.iter().map(|s| s.to_string()).collect(),
694 auto_compacted: false,
695 aligned_width: None,
696 };
697 }
698
699 let target_style = detected_style.unwrap();
700
701 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
703 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
704
705 for (row_idx, line) in stripped_lines.iter().enumerate() {
706 let cells = Self::parse_table_row_with_flavor(line, flavor);
707 match target_style.as_str() {
708 "tight" => result.push(Self::format_table_tight(&cells)),
709 "compact" => result.push(Self::format_table_compact(&cells)),
710 _ => {
711 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
712 let row_type = match row_idx {
713 0 => RowType::Header,
714 1 => RowType::Delimiter,
715 _ => RowType::Body,
716 };
717 let options = RowFormatOptions {
718 row_type,
719 compact_delimiter: false,
720 column_align: self.config.column_align,
721 column_align_header: self.config.column_align_header,
722 column_align_body: self.config.column_align_body,
723 loose_last_column: self.config.loose_last_column,
724 };
725 result.push(Self::format_table_row(
726 &cells,
727 &column_widths,
728 &column_alignments,
729 &options,
730 ));
731 }
732 }
733 }
734 }
735 "compact" => {
736 for line in &stripped_lines {
737 let cells = Self::parse_table_row_with_flavor(line, flavor);
738 result.push(Self::format_table_compact(&cells));
739 }
740 }
741 "tight" => {
742 for line in &stripped_lines {
743 let cells = Self::parse_table_row_with_flavor(line, flavor);
744 result.push(Self::format_table_tight(&cells));
745 }
746 }
747 "aligned" | "aligned-no-space" => {
748 let compact_delimiter = style == "aligned-no-space";
749
750 let needs_reformat = self.config.column_align != ColumnAlign::Auto
753 || self.config.column_align_header.is_some()
754 || self.config.column_align_body.is_some()
755 || self.config.loose_last_column;
756
757 if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
758 return TableFormatResult {
759 lines: table_lines.iter().map(|s| s.to_string()).collect(),
760 auto_compacted: false,
761 aligned_width: None,
762 };
763 }
764
765 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
766
767 let num_columns = column_widths.len();
769 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
770 aligned_width = Some(calc_aligned_width);
771
772 if calc_aligned_width > self.effective_max_width() {
774 auto_compacted = true;
775 for line in &stripped_lines {
776 let cells = Self::parse_table_row_with_flavor(line, flavor);
777 result.push(Self::format_table_compact(&cells));
778 }
779 } else {
780 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
782 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
783
784 for (row_idx, line) in stripped_lines.iter().enumerate() {
785 let cells = Self::parse_table_row_with_flavor(line, flavor);
786 let row_type = match row_idx {
787 0 => RowType::Header,
788 1 => RowType::Delimiter,
789 _ => RowType::Body,
790 };
791 let options = RowFormatOptions {
792 row_type,
793 compact_delimiter,
794 column_align: self.config.column_align,
795 column_align_header: self.config.column_align_header,
796 column_align_body: self.config.column_align_body,
797 loose_last_column: self.config.loose_last_column,
798 };
799 result.push(Self::format_table_row(
800 &cells,
801 &column_widths,
802 &column_alignments,
803 &options,
804 ));
805 }
806 }
807 }
808 _ => {
809 return TableFormatResult {
810 lines: table_lines.iter().map(|s| s.to_string()).collect(),
811 auto_compacted: false,
812 aligned_width: None,
813 };
814 }
815 }
816
817 let prefixed_result: Vec<String> = result
819 .into_iter()
820 .enumerate()
821 .map(|(i, line)| {
822 if list_context.is_some() {
823 if i == 0 {
824 format!("{blockquote_prefix}{list_prefix}{line}")
826 } else {
827 format!("{blockquote_prefix}{continuation_indent}{line}")
829 }
830 } else {
831 format!("{blockquote_prefix}{line}")
832 }
833 })
834 .collect();
835
836 TableFormatResult {
837 lines: prefixed_result,
838 auto_compacted,
839 aligned_width,
840 }
841 }
842}
843
844impl Rule for MD060TableFormat {
845 fn name(&self) -> &'static str {
846 "MD060"
847 }
848
849 fn description(&self) -> &'static str {
850 "Table columns should be consistently aligned"
851 }
852
853 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
854 !self.config.enabled || !ctx.likely_has_tables()
855 }
856
857 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
858 if !self.config.enabled {
859 return Ok(Vec::new());
860 }
861
862 let content = ctx.content;
863 let line_index = &ctx.line_index;
864 let mut warnings = Vec::new();
865
866 let lines: Vec<&str> = content.lines().collect();
867 let table_blocks = &ctx.table_blocks;
868
869 for table_block in table_blocks {
870 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
871
872 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
873 .chain(std::iter::once(table_block.delimiter_line))
874 .chain(table_block.content_lines.iter().copied())
875 .collect();
876
877 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());
884 for (i, &line_idx) in table_line_indices.iter().enumerate() {
885 let fixed_line = &format_result.lines[i];
886 if line_idx < lines.len() - 1 {
888 fixed_table_lines.push(format!("{fixed_line}\n"));
889 } else {
890 fixed_table_lines.push(fixed_line.clone());
891 }
892 }
893 let table_replacement = fixed_table_lines.concat();
894 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
895
896 for (i, &line_idx) in table_line_indices.iter().enumerate() {
897 let original = lines[line_idx];
898 let fixed = &format_result.lines[i];
899
900 if original != fixed {
901 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
902
903 let message = if format_result.auto_compacted {
904 if let Some(width) = format_result.aligned_width {
905 format!(
906 "Table too wide for aligned formatting ({} chars > max-width: {})",
907 width,
908 self.effective_max_width()
909 )
910 } else {
911 "Table too wide for aligned formatting".to_string()
912 }
913 } else {
914 "Table columns should be aligned".to_string()
915 };
916
917 warnings.push(LintWarning {
920 rule_name: Some(self.name().to_string()),
921 severity: Severity::Warning,
922 message,
923 line: start_line,
924 column: start_col,
925 end_line,
926 end_column: end_col,
927 fix: Some(crate::rule::Fix {
928 range: table_range.clone(),
929 replacement: table_replacement.clone(),
930 }),
931 });
932 }
933 }
934 }
935
936 Ok(warnings)
937 }
938
939 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
940 if !self.config.enabled {
941 return Ok(ctx.content.to_string());
942 }
943
944 let content = ctx.content;
945 let lines: Vec<&str> = content.lines().collect();
946 let table_blocks = &ctx.table_blocks;
947
948 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
949
950 for table_block in table_blocks {
951 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
952
953 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
954 .chain(std::iter::once(table_block.delimiter_line))
955 .chain(table_block.content_lines.iter().copied())
956 .collect();
957
958 for (i, &line_idx) in table_line_indices.iter().enumerate() {
959 result_lines[line_idx] = format_result.lines[i].clone();
960 }
961 }
962
963 let mut fixed = result_lines.join("\n");
964 if content.ends_with('\n') && !fixed.ends_with('\n') {
965 fixed.push('\n');
966 }
967 Ok(fixed)
968 }
969
970 fn as_any(&self) -> &dyn std::any::Any {
971 self
972 }
973
974 fn default_config_section(&self) -> Option<(String, toml::Value)> {
975 let mut table = toml::map::Map::new();
978 table.insert("enabled".to_string(), toml::Value::Boolean(self.config.enabled));
979 table.insert("style".to_string(), toml::Value::String(self.config.style.clone()));
980 table.insert(
981 "max-width".to_string(),
982 toml::Value::Integer(self.config.max_width.get() as i64),
983 );
984 table.insert(
985 "column-align".to_string(),
986 toml::Value::String(
987 match self.config.column_align {
988 ColumnAlign::Auto => "auto",
989 ColumnAlign::Left => "left",
990 ColumnAlign::Center => "center",
991 ColumnAlign::Right => "right",
992 }
993 .to_string(),
994 ),
995 );
996 table.insert(
998 "column-align-header".to_string(),
999 toml::Value::String("auto".to_string()),
1000 );
1001 table.insert("column-align-body".to_string(), toml::Value::String("auto".to_string()));
1002 table.insert(
1003 "loose-last-column".to_string(),
1004 toml::Value::Boolean(self.config.loose_last_column),
1005 );
1006
1007 Some((self.name().to_string(), toml::Value::Table(table)))
1008 }
1009
1010 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1011 where
1012 Self: Sized,
1013 {
1014 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1015 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1016
1017 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1019
1020 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use crate::lint_context::LintContext;
1028 use crate::types::LineLength;
1029
1030 fn md013_with_line_length(line_length: usize) -> MD013Config {
1032 MD013Config {
1033 line_length: LineLength::from_const(line_length),
1034 tables: true, ..Default::default()
1036 }
1037 }
1038
1039 #[test]
1040 fn test_md060_disabled_by_default() {
1041 let rule = MD060TableFormat::default();
1042 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044
1045 let warnings = rule.check(&ctx).unwrap();
1046 assert_eq!(warnings.len(), 0);
1047
1048 let fixed = rule.fix(&ctx).unwrap();
1049 assert_eq!(fixed, content);
1050 }
1051
1052 #[test]
1053 fn test_md060_align_simple_ascii_table() {
1054 let rule = MD060TableFormat::new(true, "aligned".to_string());
1055
1056 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058
1059 let fixed = rule.fix(&ctx).unwrap();
1060 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1061 assert_eq!(fixed, expected);
1062
1063 let lines: Vec<&str> = fixed.lines().collect();
1065 assert_eq!(lines[0].len(), lines[1].len());
1066 assert_eq!(lines[1].len(), lines[2].len());
1067 }
1068
1069 #[test]
1070 fn test_md060_cjk_characters_aligned_correctly() {
1071 let rule = MD060TableFormat::new(true, "aligned".to_string());
1072
1073 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075
1076 let fixed = rule.fix(&ctx).unwrap();
1077
1078 let lines: Vec<&str> = fixed.lines().collect();
1079 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1080 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1081
1082 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1083 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1084
1085 assert_eq!(width1, width3);
1086 }
1087
1088 #[test]
1089 fn test_md060_basic_emoji() {
1090 let rule = MD060TableFormat::new(true, "aligned".to_string());
1091
1092 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094
1095 let fixed = rule.fix(&ctx).unwrap();
1096 assert!(fixed.contains("Status"));
1097 }
1098
1099 #[test]
1100 fn test_md060_zwj_emoji_skipped() {
1101 let rule = MD060TableFormat::new(true, "aligned".to_string());
1102
1103 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105
1106 let fixed = rule.fix(&ctx).unwrap();
1107 assert_eq!(fixed, content);
1108 }
1109
1110 #[test]
1111 fn test_md060_inline_code_with_escaped_pipes() {
1112 let rule = MD060TableFormat::new(true, "aligned".to_string());
1115
1116 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119
1120 let fixed = rule.fix(&ctx).unwrap();
1121 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1122 }
1123
1124 #[test]
1125 fn test_md060_compact_style() {
1126 let rule = MD060TableFormat::new(true, "compact".to_string());
1127
1128 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130
1131 let fixed = rule.fix(&ctx).unwrap();
1132 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1133 assert_eq!(fixed, expected);
1134 }
1135
1136 #[test]
1137 fn test_md060_tight_style() {
1138 let rule = MD060TableFormat::new(true, "tight".to_string());
1139
1140 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142
1143 let fixed = rule.fix(&ctx).unwrap();
1144 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1145 assert_eq!(fixed, expected);
1146 }
1147
1148 #[test]
1149 fn test_md060_aligned_no_space_style() {
1150 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1152
1153 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155
1156 let fixed = rule.fix(&ctx).unwrap();
1157
1158 let lines: Vec<&str> = fixed.lines().collect();
1160 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1161 assert_eq!(
1162 lines[1], "|-------|-----|",
1163 "Delimiter should have NO spaces around dashes"
1164 );
1165 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1166
1167 assert_eq!(lines[0].len(), lines[1].len());
1169 assert_eq!(lines[1].len(), lines[2].len());
1170 }
1171
1172 #[test]
1173 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1174 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1176
1177 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179
1180 let fixed = rule.fix(&ctx).unwrap();
1181 let lines: Vec<&str> = fixed.lines().collect();
1182
1183 assert!(
1185 fixed.contains("|:"),
1186 "Should have left alignment indicator adjacent to pipe"
1187 );
1188 assert!(
1189 fixed.contains(":|"),
1190 "Should have right alignment indicator adjacent to pipe"
1191 );
1192 assert!(
1194 lines[1].contains(":---") && lines[1].contains("---:"),
1195 "Should have center alignment colons"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_md060_aligned_no_space_three_column_table() {
1201 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1203
1204 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 |";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206
1207 let fixed = rule.fix(&ctx).unwrap();
1208 let lines: Vec<&str> = fixed.lines().collect();
1209
1210 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1212 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1213 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1214 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1215 }
1216
1217 #[test]
1218 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1219 let config = MD060Config {
1221 enabled: true,
1222 style: "aligned-no-space".to_string(),
1223 max_width: LineLength::from_const(50),
1224 column_align: ColumnAlign::Auto,
1225 column_align_header: None,
1226 column_align_body: None,
1227 loose_last_column: false,
1228 };
1229 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1230
1231 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234
1235 let fixed = rule.fix(&ctx).unwrap();
1236
1237 assert!(
1239 fixed.contains("| --- |"),
1240 "Should be compact format when exceeding max-width"
1241 );
1242 }
1243
1244 #[test]
1245 fn test_md060_aligned_no_space_cjk_characters() {
1246 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1248
1249 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251
1252 let fixed = rule.fix(&ctx).unwrap();
1253 let lines: Vec<&str> = fixed.lines().collect();
1254
1255 use unicode_width::UnicodeWidthStr;
1258 assert_eq!(
1259 lines[0].width(),
1260 lines[1].width(),
1261 "Header and delimiter should have same display width"
1262 );
1263 assert_eq!(
1264 lines[1].width(),
1265 lines[2].width(),
1266 "Delimiter and content should have same display width"
1267 );
1268
1269 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1271 }
1272
1273 #[test]
1274 fn test_md060_aligned_no_space_minimum_width() {
1275 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1277
1278 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280
1281 let fixed = rule.fix(&ctx).unwrap();
1282 let lines: Vec<&str> = fixed.lines().collect();
1283
1284 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1286 assert_eq!(lines[0].len(), lines[1].len());
1288 assert_eq!(lines[1].len(), lines[2].len());
1289 }
1290
1291 #[test]
1292 fn test_md060_any_style_consistency() {
1293 let rule = MD060TableFormat::new(true, "any".to_string());
1294
1295 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298
1299 let fixed = rule.fix(&ctx).unwrap();
1300 assert_eq!(fixed, content);
1301
1302 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1304 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1305
1306 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1307 assert_eq!(fixed_aligned, content_aligned);
1308 }
1309
1310 #[test]
1311 fn test_md060_empty_cells() {
1312 let rule = MD060TableFormat::new(true, "aligned".to_string());
1313
1314 let content = "| A | B |\n|---|---|\n| | X |";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316
1317 let fixed = rule.fix(&ctx).unwrap();
1318 assert!(fixed.contains("|"));
1319 }
1320
1321 #[test]
1322 fn test_md060_mixed_content() {
1323 let rule = MD060TableFormat::new(true, "aligned".to_string());
1324
1325 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327
1328 let fixed = rule.fix(&ctx).unwrap();
1329 assert!(fixed.contains("δΈζ"));
1330 assert!(fixed.contains("NYC"));
1331 }
1332
1333 #[test]
1334 fn test_md060_preserve_alignment_indicators() {
1335 let rule = MD060TableFormat::new(true, "aligned".to_string());
1336
1337 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339
1340 let fixed = rule.fix(&ctx).unwrap();
1341
1342 assert!(fixed.contains(":---"), "Should contain left alignment");
1343 assert!(fixed.contains(":----:"), "Should contain center alignment");
1344 assert!(fixed.contains("----:"), "Should contain right alignment");
1345 }
1346
1347 #[test]
1348 fn test_md060_minimum_column_width() {
1349 let rule = MD060TableFormat::new(true, "aligned".to_string());
1350
1351 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355
1356 let fixed = rule.fix(&ctx).unwrap();
1357
1358 let lines: Vec<&str> = fixed.lines().collect();
1359 assert_eq!(lines[0].len(), lines[1].len());
1360 assert_eq!(lines[1].len(), lines[2].len());
1361
1362 assert!(fixed.contains("ID "), "Short content should be padded");
1364 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1365 }
1366
1367 #[test]
1368 fn test_md060_auto_compact_exceeds_default_threshold() {
1369 let config = MD060Config {
1371 enabled: true,
1372 style: "aligned".to_string(),
1373 max_width: LineLength::from_const(0),
1374 column_align: ColumnAlign::Auto,
1375 column_align_header: None,
1376 column_align_body: None,
1377 loose_last_column: false,
1378 };
1379 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1380
1381 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386
1387 let fixed = rule.fix(&ctx).unwrap();
1388
1389 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1391 assert!(fixed.contains("| --- | --- | --- |"));
1392 assert!(fixed.contains("| Short | Data | Here |"));
1393
1394 let lines: Vec<&str> = fixed.lines().collect();
1396 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1398 }
1399
1400 #[test]
1401 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1402 let config = MD060Config {
1404 enabled: true,
1405 style: "aligned".to_string(),
1406 max_width: LineLength::from_const(50),
1407 column_align: ColumnAlign::Auto,
1408 column_align_header: None,
1409 column_align_body: None,
1410 loose_last_column: false,
1411 };
1412 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 |";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419
1420 let fixed = rule.fix(&ctx).unwrap();
1421
1422 assert!(
1424 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1425 );
1426 assert!(fixed.contains("| --- | --- | --- |"));
1427 assert!(fixed.contains("| Data | Data | Data |"));
1428
1429 let lines: Vec<&str> = fixed.lines().collect();
1431 assert!(lines[0].len() != lines[2].len());
1432 }
1433
1434 #[test]
1435 fn test_md060_stays_aligned_under_threshold() {
1436 let config = MD060Config {
1438 enabled: true,
1439 style: "aligned".to_string(),
1440 max_width: LineLength::from_const(100),
1441 column_align: ColumnAlign::Auto,
1442 column_align_header: None,
1443 column_align_body: None,
1444 loose_last_column: false,
1445 };
1446 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1447
1448 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451
1452 let fixed = rule.fix(&ctx).unwrap();
1453
1454 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1456 assert_eq!(fixed, expected);
1457
1458 let lines: Vec<&str> = fixed.lines().collect();
1459 assert_eq!(lines[0].len(), lines[1].len());
1460 assert_eq!(lines[1].len(), lines[2].len());
1461 }
1462
1463 #[test]
1464 fn test_md060_width_calculation_formula() {
1465 let config = MD060Config {
1467 enabled: true,
1468 style: "aligned".to_string(),
1469 max_width: LineLength::from_const(0),
1470 column_align: ColumnAlign::Auto,
1471 column_align_header: None,
1472 column_align_body: None,
1473 loose_last_column: false,
1474 };
1475 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1476
1477 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482
1483 let fixed = rule.fix(&ctx).unwrap();
1484
1485 let lines: Vec<&str> = fixed.lines().collect();
1487 assert_eq!(lines[0].len(), lines[1].len());
1488 assert_eq!(lines[1].len(), lines[2].len());
1489 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1493 enabled: true,
1494 style: "aligned".to_string(),
1495 max_width: LineLength::from_const(24),
1496 column_align: ColumnAlign::Auto,
1497 column_align_header: None,
1498 column_align_body: None,
1499 loose_last_column: false,
1500 };
1501 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1502
1503 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1504
1505 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1507 assert!(fixed_compact.contains("| --- | --- | --- |"));
1508 }
1509
1510 #[test]
1511 fn test_md060_very_wide_table_auto_compacts() {
1512 let config = MD060Config {
1513 enabled: true,
1514 style: "aligned".to_string(),
1515 max_width: LineLength::from_const(0),
1516 column_align: ColumnAlign::Auto,
1517 column_align_header: None,
1518 column_align_body: None,
1519 loose_last_column: false,
1520 };
1521 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1522
1523 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 |";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528
1529 let fixed = rule.fix(&ctx).unwrap();
1530
1531 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1533 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1534 }
1535
1536 #[test]
1537 fn test_md060_inherit_from_md013_line_length() {
1538 let config = MD060Config {
1540 enabled: true,
1541 style: "aligned".to_string(),
1542 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1544 column_align_header: None,
1545 column_align_body: None,
1546 loose_last_column: false,
1547 };
1548
1549 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1551 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1552
1553 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556
1557 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1559
1560 let fixed_120 = rule_120.fix(&ctx).unwrap();
1562
1563 let lines_120: Vec<&str> = fixed_120.lines().collect();
1565 assert_eq!(lines_120[0].len(), lines_120[1].len());
1566 assert_eq!(lines_120[1].len(), lines_120[2].len());
1567 }
1568
1569 #[test]
1570 fn test_md060_edge_case_exactly_at_threshold() {
1571 let config = MD060Config {
1575 enabled: true,
1576 style: "aligned".to_string(),
1577 max_width: LineLength::from_const(17),
1578 column_align: ColumnAlign::Auto,
1579 column_align_header: None,
1580 column_align_body: None,
1581 loose_last_column: false,
1582 };
1583 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1584
1585 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1587
1588 let fixed = rule.fix(&ctx).unwrap();
1589
1590 let lines: Vec<&str> = fixed.lines().collect();
1592 assert_eq!(lines[0].len(), 17);
1593 assert_eq!(lines[0].len(), lines[1].len());
1594 assert_eq!(lines[1].len(), lines[2].len());
1595
1596 let config_under = MD060Config {
1598 enabled: true,
1599 style: "aligned".to_string(),
1600 max_width: LineLength::from_const(16),
1601 column_align: ColumnAlign::Auto,
1602 column_align_header: None,
1603 column_align_body: None,
1604 loose_last_column: false,
1605 };
1606 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1607
1608 let fixed_compact = rule_under.fix(&ctx).unwrap();
1609
1610 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1612 assert!(fixed_compact.contains("| --- | --- |"));
1613 }
1614
1615 #[test]
1616 fn test_md060_auto_compact_warning_message() {
1617 let config = MD060Config {
1619 enabled: true,
1620 style: "aligned".to_string(),
1621 max_width: LineLength::from_const(50),
1622 column_align: ColumnAlign::Auto,
1623 column_align_header: None,
1624 column_align_body: None,
1625 loose_last_column: false,
1626 };
1627 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1628
1629 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632
1633 let warnings = rule.check(&ctx).unwrap();
1634
1635 assert!(!warnings.is_empty(), "Should generate warnings");
1637
1638 let auto_compact_warnings: Vec<_> = warnings
1639 .iter()
1640 .filter(|w| w.message.contains("too wide for aligned formatting"))
1641 .collect();
1642
1643 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1644
1645 let first_warning = auto_compact_warnings[0];
1647 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1648 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1649 }
1650
1651 #[test]
1652 fn test_md060_issue_129_detect_style_from_all_rows() {
1653 let rule = MD060TableFormat::new(true, "any".to_string());
1657
1658 let content = "| a long heading | another long heading |\n\
1660 | -------------- | -------------------- |\n\
1661 | a | 1 |\n\
1662 | b b | 2 |\n\
1663 | c c c | 3 |";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665
1666 let fixed = rule.fix(&ctx).unwrap();
1667
1668 assert!(
1670 fixed.contains("| a | 1 |"),
1671 "Should preserve aligned padding in first content row"
1672 );
1673 assert!(
1674 fixed.contains("| b b | 2 |"),
1675 "Should preserve aligned padding in second content row"
1676 );
1677 assert!(
1678 fixed.contains("| c c c | 3 |"),
1679 "Should preserve aligned padding in third content row"
1680 );
1681
1682 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1684 }
1685
1686 #[test]
1687 fn test_md060_regular_alignment_warning_message() {
1688 let config = MD060Config {
1690 enabled: true,
1691 style: "aligned".to_string(),
1692 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1694 column_align_header: None,
1695 column_align_body: None,
1696 loose_last_column: false,
1697 };
1698 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1699
1700 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703
1704 let warnings = rule.check(&ctx).unwrap();
1705
1706 assert!(!warnings.is_empty(), "Should generate warnings");
1708
1709 assert!(warnings[0].message.contains("Table columns should be aligned"));
1711 assert!(!warnings[0].message.contains("too wide"));
1712 assert!(!warnings[0].message.contains("max-width"));
1713 }
1714
1715 #[test]
1718 fn test_md060_unlimited_when_md013_disabled() {
1719 let config = MD060Config {
1721 enabled: true,
1722 style: "aligned".to_string(),
1723 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1725 column_align_header: None,
1726 column_align_body: None,
1727 loose_last_column: false,
1728 };
1729 let md013_config = MD013Config::default();
1730 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1731
1732 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735 let fixed = rule.fix(&ctx).unwrap();
1736
1737 let lines: Vec<&str> = fixed.lines().collect();
1739 assert_eq!(
1741 lines[0].len(),
1742 lines[1].len(),
1743 "Table should be aligned when MD013 is disabled"
1744 );
1745 }
1746
1747 #[test]
1748 fn test_md060_unlimited_when_md013_tables_false() {
1749 let config = MD060Config {
1751 enabled: true,
1752 style: "aligned".to_string(),
1753 max_width: LineLength::from_const(0),
1754 column_align: ColumnAlign::Auto,
1755 column_align_header: None,
1756 column_align_body: None,
1757 loose_last_column: false,
1758 };
1759 let md013_config = MD013Config {
1760 tables: false, line_length: LineLength::from_const(80),
1762 ..Default::default()
1763 };
1764 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1765
1766 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let fixed = rule.fix(&ctx).unwrap();
1770
1771 let lines: Vec<&str> = fixed.lines().collect();
1773 assert_eq!(
1774 lines[0].len(),
1775 lines[1].len(),
1776 "Table should be aligned when MD013.tables=false"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_md060_unlimited_when_md013_line_length_zero() {
1782 let config = MD060Config {
1784 enabled: true,
1785 style: "aligned".to_string(),
1786 max_width: LineLength::from_const(0),
1787 column_align: ColumnAlign::Auto,
1788 column_align_header: None,
1789 column_align_body: None,
1790 loose_last_column: false,
1791 };
1792 let md013_config = MD013Config {
1793 tables: true,
1794 line_length: LineLength::from_const(0), ..Default::default()
1796 };
1797 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1798
1799 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802 let fixed = rule.fix(&ctx).unwrap();
1803
1804 let lines: Vec<&str> = fixed.lines().collect();
1806 assert_eq!(
1807 lines[0].len(),
1808 lines[1].len(),
1809 "Table should be aligned when MD013.line_length=0"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_md060_explicit_max_width_overrides_md013_settings() {
1815 let config = MD060Config {
1817 enabled: true,
1818 style: "aligned".to_string(),
1819 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1821 column_align_header: None,
1822 column_align_body: None,
1823 loose_last_column: false,
1824 };
1825 let md013_config = MD013Config {
1826 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1829 };
1830 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1831
1832 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let fixed = rule.fix(&ctx).unwrap();
1836
1837 assert!(
1839 fixed.contains("| --- |"),
1840 "Should be compact format due to explicit max_width"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1846 let config = MD060Config {
1848 enabled: true,
1849 style: "aligned".to_string(),
1850 max_width: LineLength::from_const(0), 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: true,
1858 line_length: LineLength::from_const(50), ..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 when inheriting MD013 limit"
1872 );
1873 }
1874
1875 #[test]
1878 fn test_aligned_no_space_reformats_spaced_delimiter() {
1879 let config = MD060Config {
1882 enabled: true,
1883 style: "aligned-no-space".to_string(),
1884 max_width: LineLength::from_const(0),
1885 column_align: ColumnAlign::Auto,
1886 column_align_header: None,
1887 column_align_body: None,
1888 loose_last_column: false,
1889 };
1890 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1891
1892 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let fixed = rule.fix(&ctx).unwrap();
1896
1897 assert!(
1900 !fixed.contains("| ----"),
1901 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1902 );
1903 assert!(
1904 !fixed.contains("---- |"),
1905 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1906 );
1907 assert!(
1909 fixed.contains("|----"),
1910 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1911 );
1912 }
1913
1914 #[test]
1915 fn test_aligned_reformats_compact_delimiter() {
1916 let config = MD060Config {
1919 enabled: true,
1920 style: "aligned".to_string(),
1921 max_width: LineLength::from_const(0),
1922 column_align: ColumnAlign::Auto,
1923 column_align_header: None,
1924 column_align_body: None,
1925 loose_last_column: false,
1926 };
1927 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1928
1929 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932 let fixed = rule.fix(&ctx).unwrap();
1933
1934 assert!(
1936 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1937 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1938 );
1939 }
1940
1941 #[test]
1942 fn test_aligned_no_space_preserves_matching_table() {
1943 let config = MD060Config {
1945 enabled: true,
1946 style: "aligned-no-space".to_string(),
1947 max_width: LineLength::from_const(0),
1948 column_align: ColumnAlign::Auto,
1949 column_align_header: None,
1950 column_align_body: None,
1951 loose_last_column: false,
1952 };
1953 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1954
1955 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958 let fixed = rule.fix(&ctx).unwrap();
1959
1960 assert_eq!(
1962 fixed, content,
1963 "Table already in aligned-no-space style should be preserved"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_aligned_preserves_matching_table() {
1969 let config = MD060Config {
1971 enabled: true,
1972 style: "aligned".to_string(),
1973 max_width: LineLength::from_const(0),
1974 column_align: ColumnAlign::Auto,
1975 column_align_header: None,
1976 column_align_body: None,
1977 loose_last_column: false,
1978 };
1979 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1980
1981 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 let fixed = rule.fix(&ctx).unwrap();
1985
1986 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
1988 }
1989
1990 #[test]
1991 fn test_cjk_table_display_width_consistency() {
1992 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
1998
1999 let is_aligned =
2001 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2002 assert!(
2003 !is_aligned,
2004 "Table with uneven raw line lengths should NOT be considered aligned"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_cjk_width_calculation_in_aligned_check() {
2010 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2013 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2014
2015 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2016 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2017
2018 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2020 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2021
2022 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2024 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2026 }
2027
2028 #[test]
2031 fn test_md060_column_align_left() {
2032 let config = MD060Config {
2034 enabled: true,
2035 style: "aligned".to_string(),
2036 max_width: LineLength::from_const(0),
2037 column_align: ColumnAlign::Left,
2038 column_align_header: None,
2039 column_align_body: None,
2040 loose_last_column: false,
2041 };
2042 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2043
2044 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046
2047 let fixed = rule.fix(&ctx).unwrap();
2048 let lines: Vec<&str> = fixed.lines().collect();
2049
2050 assert!(
2052 lines[2].contains("| Alice "),
2053 "Content should be left-aligned (Alice should have trailing padding)"
2054 );
2055 assert!(
2056 lines[3].contains("| Bob "),
2057 "Content should be left-aligned (Bob should have trailing padding)"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_md060_column_align_center() {
2063 let config = MD060Config {
2065 enabled: true,
2066 style: "aligned".to_string(),
2067 max_width: LineLength::from_const(0),
2068 column_align: ColumnAlign::Center,
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!(
2084 lines[3].contains("| Bob |"),
2085 "Bob should be centered with padding on both sides. Got: {}",
2086 lines[3]
2087 );
2088 }
2089
2090 #[test]
2091 fn test_md060_column_align_right() {
2092 let config = MD060Config {
2094 enabled: true,
2095 style: "aligned".to_string(),
2096 max_width: LineLength::from_const(0),
2097 column_align: ColumnAlign::Right,
2098 column_align_header: None,
2099 column_align_body: None,
2100 loose_last_column: false,
2101 };
2102 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2103
2104 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2106
2107 let fixed = rule.fix(&ctx).unwrap();
2108 let lines: Vec<&str> = fixed.lines().collect();
2109
2110 assert!(
2112 lines[3].contains("| Bob |"),
2113 "Bob should be right-aligned with padding on left. Got: {}",
2114 lines[3]
2115 );
2116 }
2117
2118 #[test]
2119 fn test_md060_column_align_auto_respects_delimiter() {
2120 let config = MD060Config {
2122 enabled: true,
2123 style: "aligned".to_string(),
2124 max_width: LineLength::from_const(0),
2125 column_align: ColumnAlign::Auto,
2126 column_align_header: None,
2127 column_align_body: None,
2128 loose_last_column: false,
2129 };
2130 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2131
2132 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2135
2136 let fixed = rule.fix(&ctx).unwrap();
2137
2138 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2140 let lines: Vec<&str> = fixed.lines().collect();
2142 assert!(
2146 lines[2].contains(" C |"),
2147 "Right column should be right-aligned. Got: {}",
2148 lines[2]
2149 );
2150 }
2151
2152 #[test]
2153 fn test_md060_column_align_overrides_delimiter_indicators() {
2154 let config = MD060Config {
2156 enabled: true,
2157 style: "aligned".to_string(),
2158 max_width: LineLength::from_const(0),
2159 column_align: ColumnAlign::Right, column_align_header: None,
2161 column_align_body: None,
2162 loose_last_column: false,
2163 };
2164 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2165
2166 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2169
2170 let fixed = rule.fix(&ctx).unwrap();
2171 let lines: Vec<&str> = fixed.lines().collect();
2172
2173 assert!(
2176 lines[2].contains(" A |") || lines[2].contains(" A |"),
2177 "Even left-indicated column should be right-aligned. Got: {}",
2178 lines[2]
2179 );
2180 }
2181
2182 #[test]
2183 fn test_md060_column_align_with_aligned_no_space() {
2184 let config = MD060Config {
2186 enabled: true,
2187 style: "aligned-no-space".to_string(),
2188 max_width: LineLength::from_const(0),
2189 column_align: ColumnAlign::Center,
2190 column_align_header: None,
2191 column_align_body: None,
2192 loose_last_column: false,
2193 };
2194 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2195
2196 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198
2199 let fixed = rule.fix(&ctx).unwrap();
2200 let lines: Vec<&str> = fixed.lines().collect();
2201
2202 assert!(
2204 lines[1].contains("|---"),
2205 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2206 lines[1]
2207 );
2208 assert!(
2210 lines[3].contains("| Bob |"),
2211 "Content should be centered. Got: {}",
2212 lines[3]
2213 );
2214 }
2215
2216 #[test]
2217 fn test_md060_column_align_config_parsing() {
2218 let toml_str = r#"
2220enabled = true
2221style = "aligned"
2222column-align = "center"
2223"#;
2224 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2225 assert_eq!(config.column_align, ColumnAlign::Center);
2226
2227 let toml_str = r#"
2228enabled = true
2229style = "aligned"
2230column-align = "right"
2231"#;
2232 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2233 assert_eq!(config.column_align, ColumnAlign::Right);
2234
2235 let toml_str = r#"
2236enabled = true
2237style = "aligned"
2238column-align = "left"
2239"#;
2240 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2241 assert_eq!(config.column_align, ColumnAlign::Left);
2242
2243 let toml_str = r#"
2244enabled = true
2245style = "aligned"
2246column-align = "auto"
2247"#;
2248 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2249 assert_eq!(config.column_align, ColumnAlign::Auto);
2250 }
2251
2252 #[test]
2253 fn test_md060_column_align_default_is_auto() {
2254 let toml_str = r#"
2256enabled = true
2257style = "aligned"
2258"#;
2259 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2260 assert_eq!(config.column_align, ColumnAlign::Auto);
2261 }
2262
2263 #[test]
2264 fn test_md060_column_align_reformats_already_aligned_table() {
2265 let config = MD060Config {
2267 enabled: true,
2268 style: "aligned".to_string(),
2269 max_width: LineLength::from_const(0),
2270 column_align: ColumnAlign::Right,
2271 column_align_header: None,
2272 column_align_body: None,
2273 loose_last_column: false,
2274 };
2275 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2276
2277 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280
2281 let fixed = rule.fix(&ctx).unwrap();
2282 let lines: Vec<&str> = fixed.lines().collect();
2283
2284 assert!(
2286 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2287 "Already aligned table should be reformatted with right alignment. Got: {}",
2288 lines[2]
2289 );
2290 assert!(
2291 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2292 "Bob should be right-aligned. Got: {}",
2293 lines[3]
2294 );
2295 }
2296
2297 #[test]
2298 fn test_md060_column_align_with_cjk_characters() {
2299 let config = MD060Config {
2301 enabled: true,
2302 style: "aligned".to_string(),
2303 max_width: LineLength::from_const(0),
2304 column_align: ColumnAlign::Center,
2305 column_align_header: None,
2306 column_align_body: None,
2307 loose_last_column: false,
2308 };
2309 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2310
2311 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2313
2314 let fixed = rule.fix(&ctx).unwrap();
2315
2316 assert!(fixed.contains("Bob"), "Table should contain Bob");
2319 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2320 }
2321
2322 #[test]
2323 fn test_md060_column_align_ignored_for_compact_style() {
2324 let config = MD060Config {
2326 enabled: true,
2327 style: "compact".to_string(),
2328 max_width: LineLength::from_const(0),
2329 column_align: ColumnAlign::Right, column_align_header: None,
2331 column_align_body: None,
2332 loose_last_column: false,
2333 };
2334 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2335
2336 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2338
2339 let fixed = rule.fix(&ctx).unwrap();
2340
2341 assert!(
2343 fixed.contains("| Alice |"),
2344 "Compact style should have single space padding, not alignment. Got: {fixed}"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_md060_column_align_ignored_for_tight_style() {
2350 let config = MD060Config {
2352 enabled: true,
2353 style: "tight".to_string(),
2354 max_width: LineLength::from_const(0),
2355 column_align: ColumnAlign::Center, column_align_header: None,
2357 column_align_body: None,
2358 loose_last_column: false,
2359 };
2360 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2361
2362 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2364
2365 let fixed = rule.fix(&ctx).unwrap();
2366
2367 assert!(
2369 fixed.contains("|Alice|"),
2370 "Tight style should have no spaces. Got: {fixed}"
2371 );
2372 }
2373
2374 #[test]
2375 fn test_md060_column_align_with_empty_cells() {
2376 let config = MD060Config {
2378 enabled: true,
2379 style: "aligned".to_string(),
2380 max_width: LineLength::from_const(0),
2381 column_align: ColumnAlign::Center,
2382 column_align_header: None,
2383 column_align_body: None,
2384 loose_last_column: false,
2385 };
2386 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2387
2388 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2390
2391 let fixed = rule.fix(&ctx).unwrap();
2392 let lines: Vec<&str> = fixed.lines().collect();
2393
2394 assert!(
2396 lines[3].contains("| |") || lines[3].contains("| |"),
2397 "Empty cell should be padded correctly. Got: {}",
2398 lines[3]
2399 );
2400 }
2401
2402 #[test]
2403 fn test_md060_column_align_auto_preserves_already_aligned() {
2404 let config = MD060Config {
2406 enabled: true,
2407 style: "aligned".to_string(),
2408 max_width: LineLength::from_const(0),
2409 column_align: ColumnAlign::Auto,
2410 column_align_header: None,
2411 column_align_body: None,
2412 loose_last_column: false,
2413 };
2414 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2415
2416 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419
2420 let fixed = rule.fix(&ctx).unwrap();
2421
2422 assert_eq!(
2424 fixed, content,
2425 "Already aligned table should be preserved with column-align=auto"
2426 );
2427 }
2428}