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