1use crate::rule::{LintError, LintResult, LintWarning, Rule, 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 should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
911 !ctx.likely_has_tables()
912 }
913
914 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
915 let line_index = &ctx.line_index;
916 let mut warnings = Vec::new();
917
918 let lines = ctx.raw_lines();
919 let table_blocks = &ctx.table_blocks;
920
921 for table_block in table_blocks {
922 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
923
924 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
925 .chain(std::iter::once(table_block.delimiter_line))
926 .chain(table_block.content_lines.iter().copied())
927 .collect();
928
929 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());
936 for (i, &line_idx) in table_line_indices.iter().enumerate() {
937 let fixed_line = &format_result.lines[i];
938 if line_idx < lines.len() - 1 {
940 fixed_table_lines.push(format!("{fixed_line}\n"));
941 } else {
942 fixed_table_lines.push(fixed_line.clone());
943 }
944 }
945 let table_replacement = fixed_table_lines.concat();
946 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
947
948 for (i, &line_idx) in table_line_indices.iter().enumerate() {
949 let original = lines[line_idx];
950 let fixed = &format_result.lines[i];
951
952 if original != fixed {
953 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
954
955 let message = if format_result.auto_compacted {
956 if let Some(width) = format_result.aligned_width {
957 format!(
958 "Table too wide for aligned formatting ({} chars > max-width: {})",
959 width,
960 self.effective_max_width()
961 )
962 } else {
963 "Table too wide for aligned formatting".to_string()
964 }
965 } else {
966 "Table columns should be aligned".to_string()
967 };
968
969 warnings.push(LintWarning {
972 rule_name: Some(self.name().to_string()),
973 severity: Severity::Warning,
974 message,
975 line: start_line,
976 column: start_col,
977 end_line,
978 end_column: end_col,
979 fix: Some(crate::rule::Fix {
980 range: table_range.clone(),
981 replacement: table_replacement.clone(),
982 }),
983 });
984 }
985 }
986 }
987
988 Ok(warnings)
989 }
990
991 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
992 let content = ctx.content;
993 let lines = ctx.raw_lines();
994 let table_blocks = &ctx.table_blocks;
995
996 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
997
998 for table_block in table_blocks {
999 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1000
1001 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1002 .chain(std::iter::once(table_block.delimiter_line))
1003 .chain(table_block.content_lines.iter().copied())
1004 .collect();
1005
1006 let any_disabled = table_line_indices
1009 .iter()
1010 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1011
1012 if any_disabled {
1013 continue;
1014 }
1015
1016 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1017 result_lines[line_idx] = format_result.lines[i].clone();
1018 }
1019 }
1020
1021 let mut fixed = result_lines.join("\n");
1022 if content.ends_with('\n') && !fixed.ends_with('\n') {
1023 fixed.push('\n');
1024 }
1025 Ok(fixed)
1026 }
1027
1028 fn as_any(&self) -> &dyn std::any::Any {
1029 self
1030 }
1031
1032 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1033 let table = crate::rule_config_serde::config_schema_table(&MD060Config::default())?;
1034 Some((MD060Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1035 }
1036
1037 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1038 where
1039 Self: Sized,
1040 {
1041 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1042 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1043
1044 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1046
1047 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1048 }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054 use crate::lint_context::LintContext;
1055 use crate::types::LineLength;
1056
1057 fn md013_with_line_length(line_length: usize) -> MD013Config {
1059 MD013Config {
1060 line_length: LineLength::from_const(line_length),
1061 tables: true, ..Default::default()
1063 }
1064 }
1065
1066 #[test]
1067 fn test_md060_align_simple_ascii_table() {
1068 let rule = MD060TableFormat::new(true, "aligned".to_string());
1069
1070 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072
1073 let fixed = rule.fix(&ctx).unwrap();
1074 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1075 assert_eq!(fixed, expected);
1076
1077 let lines: Vec<&str> = fixed.lines().collect();
1079 assert_eq!(lines[0].len(), lines[1].len());
1080 assert_eq!(lines[1].len(), lines[2].len());
1081 }
1082
1083 #[test]
1084 fn test_md060_cjk_characters_aligned_correctly() {
1085 let rule = MD060TableFormat::new(true, "aligned".to_string());
1086
1087 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089
1090 let fixed = rule.fix(&ctx).unwrap();
1091
1092 let lines: Vec<&str> = fixed.lines().collect();
1093 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1094 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1095
1096 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1097 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1098
1099 assert_eq!(width1, width3);
1100 }
1101
1102 #[test]
1103 fn test_md060_basic_emoji() {
1104 let rule = MD060TableFormat::new(true, "aligned".to_string());
1105
1106 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108
1109 let fixed = rule.fix(&ctx).unwrap();
1110 assert!(fixed.contains("Status"));
1111 }
1112
1113 #[test]
1114 fn test_md060_zwj_emoji_skipped() {
1115 let rule = MD060TableFormat::new(true, "aligned".to_string());
1116
1117 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119
1120 let fixed = rule.fix(&ctx).unwrap();
1121 assert_eq!(fixed, content);
1122 }
1123
1124 #[test]
1125 fn test_md060_inline_code_with_escaped_pipes() {
1126 let rule = MD060TableFormat::new(true, "aligned".to_string());
1129
1130 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133
1134 let fixed = rule.fix(&ctx).unwrap();
1135 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1136 }
1137
1138 #[test]
1139 fn test_md060_compact_style() {
1140 let rule = MD060TableFormat::new(true, "compact".to_string());
1141
1142 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144
1145 let fixed = rule.fix(&ctx).unwrap();
1146 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1147 assert_eq!(fixed, expected);
1148 }
1149
1150 #[test]
1151 fn test_md060_tight_style() {
1152 let rule = MD060TableFormat::new(true, "tight".to_string());
1153
1154 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156
1157 let fixed = rule.fix(&ctx).unwrap();
1158 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1159 assert_eq!(fixed, expected);
1160 }
1161
1162 #[test]
1163 fn test_md060_aligned_no_space_style() {
1164 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1166
1167 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169
1170 let fixed = rule.fix(&ctx).unwrap();
1171
1172 let lines: Vec<&str> = fixed.lines().collect();
1174 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1175 assert_eq!(
1176 lines[1], "|-------|-----|",
1177 "Delimiter should have NO spaces around dashes"
1178 );
1179 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1180
1181 assert_eq!(lines[0].len(), lines[1].len());
1183 assert_eq!(lines[1].len(), lines[2].len());
1184 }
1185
1186 #[test]
1187 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1188 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1190
1191 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193
1194 let fixed = rule.fix(&ctx).unwrap();
1195 let lines: Vec<&str> = fixed.lines().collect();
1196
1197 assert!(
1199 fixed.contains("|:"),
1200 "Should have left alignment indicator adjacent to pipe"
1201 );
1202 assert!(
1203 fixed.contains(":|"),
1204 "Should have right alignment indicator adjacent to pipe"
1205 );
1206 assert!(
1208 lines[1].contains(":---") && lines[1].contains("---:"),
1209 "Should have center alignment colons"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_md060_aligned_no_space_three_column_table() {
1215 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1217
1218 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 |";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220
1221 let fixed = rule.fix(&ctx).unwrap();
1222 let lines: Vec<&str> = fixed.lines().collect();
1223
1224 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1226 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1227 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1228 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1229 }
1230
1231 #[test]
1232 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1233 let config = MD060Config {
1235 enabled: true,
1236 style: "aligned-no-space".to_string(),
1237 max_width: LineLength::from_const(50),
1238 column_align: ColumnAlign::Auto,
1239 column_align_header: None,
1240 column_align_body: None,
1241 loose_last_column: false,
1242 };
1243 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1244
1245 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248
1249 let fixed = rule.fix(&ctx).unwrap();
1250
1251 assert!(
1253 fixed.contains("| --- |"),
1254 "Should be compact format when exceeding max-width"
1255 );
1256 }
1257
1258 #[test]
1259 fn test_md060_aligned_no_space_cjk_characters() {
1260 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1262
1263 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265
1266 let fixed = rule.fix(&ctx).unwrap();
1267 let lines: Vec<&str> = fixed.lines().collect();
1268
1269 use unicode_width::UnicodeWidthStr;
1272 assert_eq!(
1273 lines[0].width(),
1274 lines[1].width(),
1275 "Header and delimiter should have same display width"
1276 );
1277 assert_eq!(
1278 lines[1].width(),
1279 lines[2].width(),
1280 "Delimiter and content should have same display width"
1281 );
1282
1283 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1285 }
1286
1287 #[test]
1288 fn test_md060_aligned_no_space_minimum_width() {
1289 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1291
1292 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294
1295 let fixed = rule.fix(&ctx).unwrap();
1296 let lines: Vec<&str> = fixed.lines().collect();
1297
1298 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1300 assert_eq!(lines[0].len(), lines[1].len());
1302 assert_eq!(lines[1].len(), lines[2].len());
1303 }
1304
1305 #[test]
1306 fn test_md060_any_style_consistency() {
1307 let rule = MD060TableFormat::new(true, "any".to_string());
1308
1309 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312
1313 let fixed = rule.fix(&ctx).unwrap();
1314 assert_eq!(fixed, content);
1315
1316 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1318 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1319
1320 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1321 assert_eq!(fixed_aligned, content_aligned);
1322 }
1323
1324 #[test]
1325 fn test_md060_empty_cells() {
1326 let rule = MD060TableFormat::new(true, "aligned".to_string());
1327
1328 let content = "| A | B |\n|---|---|\n| | X |";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330
1331 let fixed = rule.fix(&ctx).unwrap();
1332 assert!(fixed.contains("|"));
1333 }
1334
1335 #[test]
1336 fn test_md060_mixed_content() {
1337 let rule = MD060TableFormat::new(true, "aligned".to_string());
1338
1339 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1341
1342 let fixed = rule.fix(&ctx).unwrap();
1343 assert!(fixed.contains("δΈζ"));
1344 assert!(fixed.contains("NYC"));
1345 }
1346
1347 #[test]
1348 fn test_md060_preserve_alignment_indicators() {
1349 let rule = MD060TableFormat::new(true, "aligned".to_string());
1350
1351 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1353
1354 let fixed = rule.fix(&ctx).unwrap();
1355
1356 assert!(fixed.contains(":---"), "Should contain left alignment");
1357 assert!(fixed.contains(":----:"), "Should contain center alignment");
1358 assert!(fixed.contains("----:"), "Should contain right alignment");
1359 }
1360
1361 #[test]
1362 fn test_md060_minimum_column_width() {
1363 let rule = MD060TableFormat::new(true, "aligned".to_string());
1364
1365 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369
1370 let fixed = rule.fix(&ctx).unwrap();
1371
1372 let lines: Vec<&str> = fixed.lines().collect();
1373 assert_eq!(lines[0].len(), lines[1].len());
1374 assert_eq!(lines[1].len(), lines[2].len());
1375
1376 assert!(fixed.contains("ID "), "Short content should be padded");
1378 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1379 }
1380
1381 #[test]
1382 fn test_md060_auto_compact_exceeds_default_threshold() {
1383 let config = MD060Config {
1385 enabled: true,
1386 style: "aligned".to_string(),
1387 max_width: LineLength::from_const(0),
1388 column_align: ColumnAlign::Auto,
1389 column_align_header: None,
1390 column_align_body: None,
1391 loose_last_column: false,
1392 };
1393 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1394
1395 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400
1401 let fixed = rule.fix(&ctx).unwrap();
1402
1403 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1405 assert!(fixed.contains("| --- | --- | --- |"));
1406 assert!(fixed.contains("| Short | Data | Here |"));
1407
1408 let lines: Vec<&str> = fixed.lines().collect();
1410 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1412 }
1413
1414 #[test]
1415 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1416 let config = MD060Config {
1418 enabled: true,
1419 style: "aligned".to_string(),
1420 max_width: LineLength::from_const(50),
1421 column_align: ColumnAlign::Auto,
1422 column_align_header: None,
1423 column_align_body: None,
1424 loose_last_column: false,
1425 };
1426 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 |";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433
1434 let fixed = rule.fix(&ctx).unwrap();
1435
1436 assert!(
1438 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1439 );
1440 assert!(fixed.contains("| --- | --- | --- |"));
1441 assert!(fixed.contains("| Data | Data | Data |"));
1442
1443 let lines: Vec<&str> = fixed.lines().collect();
1445 assert!(lines[0].len() != lines[2].len());
1446 }
1447
1448 #[test]
1449 fn test_md060_stays_aligned_under_threshold() {
1450 let config = MD060Config {
1452 enabled: true,
1453 style: "aligned".to_string(),
1454 max_width: LineLength::from_const(100),
1455 column_align: ColumnAlign::Auto,
1456 column_align_header: None,
1457 column_align_body: None,
1458 loose_last_column: false,
1459 };
1460 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1461
1462 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465
1466 let fixed = rule.fix(&ctx).unwrap();
1467
1468 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1470 assert_eq!(fixed, expected);
1471
1472 let lines: Vec<&str> = fixed.lines().collect();
1473 assert_eq!(lines[0].len(), lines[1].len());
1474 assert_eq!(lines[1].len(), lines[2].len());
1475 }
1476
1477 #[test]
1478 fn test_md060_width_calculation_formula() {
1479 let config = MD060Config {
1481 enabled: true,
1482 style: "aligned".to_string(),
1483 max_width: LineLength::from_const(0),
1484 column_align: ColumnAlign::Auto,
1485 column_align_header: None,
1486 column_align_body: None,
1487 loose_last_column: false,
1488 };
1489 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1490
1491 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496
1497 let fixed = rule.fix(&ctx).unwrap();
1498
1499 let lines: Vec<&str> = fixed.lines().collect();
1501 assert_eq!(lines[0].len(), lines[1].len());
1502 assert_eq!(lines[1].len(), lines[2].len());
1503 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1507 enabled: true,
1508 style: "aligned".to_string(),
1509 max_width: LineLength::from_const(24),
1510 column_align: ColumnAlign::Auto,
1511 column_align_header: None,
1512 column_align_body: None,
1513 loose_last_column: false,
1514 };
1515 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1516
1517 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1518
1519 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1521 assert!(fixed_compact.contains("| --- | --- | --- |"));
1522 }
1523
1524 #[test]
1525 fn test_md060_very_wide_table_auto_compacts() {
1526 let config = MD060Config {
1527 enabled: true,
1528 style: "aligned".to_string(),
1529 max_width: LineLength::from_const(0),
1530 column_align: ColumnAlign::Auto,
1531 column_align_header: None,
1532 column_align_body: None,
1533 loose_last_column: false,
1534 };
1535 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1536
1537 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 |";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542
1543 let fixed = rule.fix(&ctx).unwrap();
1544
1545 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1547 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1548 }
1549
1550 #[test]
1551 fn test_md060_inherit_from_md013_line_length() {
1552 let config = MD060Config {
1554 enabled: true,
1555 style: "aligned".to_string(),
1556 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1558 column_align_header: None,
1559 column_align_body: None,
1560 loose_last_column: false,
1561 };
1562
1563 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1565 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1566
1567 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570
1571 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1573
1574 let fixed_120 = rule_120.fix(&ctx).unwrap();
1576
1577 let lines_120: Vec<&str> = fixed_120.lines().collect();
1579 assert_eq!(lines_120[0].len(), lines_120[1].len());
1580 assert_eq!(lines_120[1].len(), lines_120[2].len());
1581 }
1582
1583 #[test]
1584 fn test_md060_edge_case_exactly_at_threshold() {
1585 let config = MD060Config {
1589 enabled: true,
1590 style: "aligned".to_string(),
1591 max_width: LineLength::from_const(17),
1592 column_align: ColumnAlign::Auto,
1593 column_align_header: None,
1594 column_align_body: None,
1595 loose_last_column: false,
1596 };
1597 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1598
1599 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601
1602 let fixed = rule.fix(&ctx).unwrap();
1603
1604 let lines: Vec<&str> = fixed.lines().collect();
1606 assert_eq!(lines[0].len(), 17);
1607 assert_eq!(lines[0].len(), lines[1].len());
1608 assert_eq!(lines[1].len(), lines[2].len());
1609
1610 let config_under = MD060Config {
1612 enabled: true,
1613 style: "aligned".to_string(),
1614 max_width: LineLength::from_const(16),
1615 column_align: ColumnAlign::Auto,
1616 column_align_header: None,
1617 column_align_body: None,
1618 loose_last_column: false,
1619 };
1620 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1621
1622 let fixed_compact = rule_under.fix(&ctx).unwrap();
1623
1624 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1626 assert!(fixed_compact.contains("| --- | --- |"));
1627 }
1628
1629 #[test]
1630 fn test_md060_auto_compact_warning_message() {
1631 let config = MD060Config {
1633 enabled: true,
1634 style: "aligned".to_string(),
1635 max_width: LineLength::from_const(50),
1636 column_align: ColumnAlign::Auto,
1637 column_align_header: None,
1638 column_align_body: None,
1639 loose_last_column: false,
1640 };
1641 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1642
1643 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646
1647 let warnings = rule.check(&ctx).unwrap();
1648
1649 assert!(!warnings.is_empty(), "Should generate warnings");
1651
1652 let auto_compact_warnings: Vec<_> = warnings
1653 .iter()
1654 .filter(|w| w.message.contains("too wide for aligned formatting"))
1655 .collect();
1656
1657 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1658
1659 let first_warning = auto_compact_warnings[0];
1661 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1662 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1663 }
1664
1665 #[test]
1666 fn test_md060_issue_129_detect_style_from_all_rows() {
1667 let rule = MD060TableFormat::new(true, "any".to_string());
1671
1672 let content = "| a long heading | another long heading |\n\
1674 | -------------- | -------------------- |\n\
1675 | a | 1 |\n\
1676 | b b | 2 |\n\
1677 | c c c | 3 |";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679
1680 let fixed = rule.fix(&ctx).unwrap();
1681
1682 assert!(
1684 fixed.contains("| a | 1 |"),
1685 "Should preserve aligned padding in first content row"
1686 );
1687 assert!(
1688 fixed.contains("| b b | 2 |"),
1689 "Should preserve aligned padding in second content row"
1690 );
1691 assert!(
1692 fixed.contains("| c c c | 3 |"),
1693 "Should preserve aligned padding in third content row"
1694 );
1695
1696 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1698 }
1699
1700 #[test]
1701 fn test_md060_regular_alignment_warning_message() {
1702 let config = MD060Config {
1704 enabled: true,
1705 style: "aligned".to_string(),
1706 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1708 column_align_header: None,
1709 column_align_body: None,
1710 loose_last_column: false,
1711 };
1712 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1713
1714 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717
1718 let warnings = rule.check(&ctx).unwrap();
1719
1720 assert!(!warnings.is_empty(), "Should generate warnings");
1722
1723 assert!(warnings[0].message.contains("Table columns should be aligned"));
1725 assert!(!warnings[0].message.contains("too wide"));
1726 assert!(!warnings[0].message.contains("max-width"));
1727 }
1728
1729 #[test]
1732 fn test_md060_unlimited_when_md013_disabled() {
1733 let config = MD060Config {
1735 enabled: true,
1736 style: "aligned".to_string(),
1737 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1739 column_align_header: None,
1740 column_align_body: None,
1741 loose_last_column: false,
1742 };
1743 let md013_config = MD013Config::default();
1744 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1745
1746 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1749 let fixed = rule.fix(&ctx).unwrap();
1750
1751 let lines: Vec<&str> = fixed.lines().collect();
1753 assert_eq!(
1755 lines[0].len(),
1756 lines[1].len(),
1757 "Table should be aligned when MD013 is disabled"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_md060_unlimited_when_md013_tables_false() {
1763 let config = MD060Config {
1765 enabled: true,
1766 style: "aligned".to_string(),
1767 max_width: LineLength::from_const(0),
1768 column_align: ColumnAlign::Auto,
1769 column_align_header: None,
1770 column_align_body: None,
1771 loose_last_column: false,
1772 };
1773 let md013_config = MD013Config {
1774 tables: false, line_length: LineLength::from_const(80),
1776 ..Default::default()
1777 };
1778 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1779
1780 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let fixed = rule.fix(&ctx).unwrap();
1784
1785 let lines: Vec<&str> = fixed.lines().collect();
1787 assert_eq!(
1788 lines[0].len(),
1789 lines[1].len(),
1790 "Table should be aligned when MD013.tables=false"
1791 );
1792 }
1793
1794 #[test]
1795 fn test_md060_unlimited_when_md013_line_length_zero() {
1796 let config = MD060Config {
1798 enabled: true,
1799 style: "aligned".to_string(),
1800 max_width: LineLength::from_const(0),
1801 column_align: ColumnAlign::Auto,
1802 column_align_header: None,
1803 column_align_body: None,
1804 loose_last_column: false,
1805 };
1806 let md013_config = MD013Config {
1807 tables: true,
1808 line_length: LineLength::from_const(0), ..Default::default()
1810 };
1811 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1812
1813 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 let fixed = rule.fix(&ctx).unwrap();
1817
1818 let lines: Vec<&str> = fixed.lines().collect();
1820 assert_eq!(
1821 lines[0].len(),
1822 lines[1].len(),
1823 "Table should be aligned when MD013.line_length=0"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_md060_explicit_max_width_overrides_md013_settings() {
1829 let config = MD060Config {
1831 enabled: true,
1832 style: "aligned".to_string(),
1833 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1835 column_align_header: None,
1836 column_align_body: None,
1837 loose_last_column: false,
1838 };
1839 let md013_config = MD013Config {
1840 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1843 };
1844 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1845
1846 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1849 let fixed = rule.fix(&ctx).unwrap();
1850
1851 assert!(
1853 fixed.contains("| --- |"),
1854 "Should be compact format due to explicit max_width"
1855 );
1856 }
1857
1858 #[test]
1859 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1860 let config = MD060Config {
1862 enabled: true,
1863 style: "aligned".to_string(),
1864 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1866 column_align_header: None,
1867 column_align_body: None,
1868 loose_last_column: false,
1869 };
1870 let md013_config = MD013Config {
1871 tables: true,
1872 line_length: LineLength::from_const(50), ..Default::default()
1874 };
1875 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1876
1877 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let fixed = rule.fix(&ctx).unwrap();
1881
1882 assert!(
1884 fixed.contains("| --- |"),
1885 "Should be compact format when inheriting MD013 limit"
1886 );
1887 }
1888
1889 #[test]
1892 fn test_aligned_no_space_reformats_spaced_delimiter() {
1893 let config = MD060Config {
1896 enabled: true,
1897 style: "aligned-no-space".to_string(),
1898 max_width: LineLength::from_const(0),
1899 column_align: ColumnAlign::Auto,
1900 column_align_header: None,
1901 column_align_body: None,
1902 loose_last_column: false,
1903 };
1904 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1905
1906 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let fixed = rule.fix(&ctx).unwrap();
1910
1911 assert!(
1914 !fixed.contains("| ----"),
1915 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1916 );
1917 assert!(
1918 !fixed.contains("---- |"),
1919 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1920 );
1921 assert!(
1923 fixed.contains("|----"),
1924 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_aligned_reformats_compact_delimiter() {
1930 let config = MD060Config {
1933 enabled: true,
1934 style: "aligned".to_string(),
1935 max_width: LineLength::from_const(0),
1936 column_align: ColumnAlign::Auto,
1937 column_align_header: None,
1938 column_align_body: None,
1939 loose_last_column: false,
1940 };
1941 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1942
1943 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1946 let fixed = rule.fix(&ctx).unwrap();
1947
1948 assert!(
1950 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1951 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_aligned_no_space_preserves_matching_table() {
1957 let config = MD060Config {
1959 enabled: true,
1960 style: "aligned-no-space".to_string(),
1961 max_width: LineLength::from_const(0),
1962 column_align: ColumnAlign::Auto,
1963 column_align_header: None,
1964 column_align_body: None,
1965 loose_last_column: false,
1966 };
1967 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1968
1969 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let fixed = rule.fix(&ctx).unwrap();
1973
1974 assert_eq!(
1976 fixed, content,
1977 "Table already in aligned-no-space style should be preserved"
1978 );
1979 }
1980
1981 #[test]
1982 fn test_aligned_preserves_matching_table() {
1983 let config = MD060Config {
1985 enabled: true,
1986 style: "aligned".to_string(),
1987 max_width: LineLength::from_const(0),
1988 column_align: ColumnAlign::Auto,
1989 column_align_header: None,
1990 column_align_body: None,
1991 loose_last_column: false,
1992 };
1993 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1994
1995 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let fixed = rule.fix(&ctx).unwrap();
1999
2000 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2002 }
2003
2004 #[test]
2005 fn test_cjk_table_display_width_consistency() {
2006 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2012
2013 let is_aligned =
2015 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2016 assert!(
2017 !is_aligned,
2018 "Table with uneven raw line lengths should NOT be considered aligned"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_cjk_width_calculation_in_aligned_check() {
2024 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2027 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2028
2029 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2030 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2031
2032 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2034 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2035
2036 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2038 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2040 }
2041
2042 #[test]
2045 fn test_md060_column_align_left() {
2046 let config = MD060Config {
2048 enabled: true,
2049 style: "aligned".to_string(),
2050 max_width: LineLength::from_const(0),
2051 column_align: ColumnAlign::Left,
2052 column_align_header: None,
2053 column_align_body: None,
2054 loose_last_column: false,
2055 };
2056 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2057
2058 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2060
2061 let fixed = rule.fix(&ctx).unwrap();
2062 let lines: Vec<&str> = fixed.lines().collect();
2063
2064 assert!(
2066 lines[2].contains("| Alice "),
2067 "Content should be left-aligned (Alice should have trailing padding)"
2068 );
2069 assert!(
2070 lines[3].contains("| Bob "),
2071 "Content should be left-aligned (Bob should have trailing padding)"
2072 );
2073 }
2074
2075 #[test]
2076 fn test_md060_column_align_center() {
2077 let config = MD060Config {
2079 enabled: true,
2080 style: "aligned".to_string(),
2081 max_width: LineLength::from_const(0),
2082 column_align: ColumnAlign::Center,
2083 column_align_header: None,
2084 column_align_body: None,
2085 loose_last_column: false,
2086 };
2087 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2088
2089 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091
2092 let fixed = rule.fix(&ctx).unwrap();
2093 let lines: Vec<&str> = fixed.lines().collect();
2094
2095 assert!(
2098 lines[3].contains("| Bob |"),
2099 "Bob should be centered with padding on both sides. Got: {}",
2100 lines[3]
2101 );
2102 }
2103
2104 #[test]
2105 fn test_md060_column_align_right() {
2106 let config = MD060Config {
2108 enabled: true,
2109 style: "aligned".to_string(),
2110 max_width: LineLength::from_const(0),
2111 column_align: ColumnAlign::Right,
2112 column_align_header: None,
2113 column_align_body: None,
2114 loose_last_column: false,
2115 };
2116 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2117
2118 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2120
2121 let fixed = rule.fix(&ctx).unwrap();
2122 let lines: Vec<&str> = fixed.lines().collect();
2123
2124 assert!(
2126 lines[3].contains("| Bob |"),
2127 "Bob should be right-aligned with padding on left. Got: {}",
2128 lines[3]
2129 );
2130 }
2131
2132 #[test]
2133 fn test_md060_column_align_auto_respects_delimiter() {
2134 let config = MD060Config {
2136 enabled: true,
2137 style: "aligned".to_string(),
2138 max_width: LineLength::from_const(0),
2139 column_align: ColumnAlign::Auto,
2140 column_align_header: None,
2141 column_align_body: None,
2142 loose_last_column: false,
2143 };
2144 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2145
2146 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2149
2150 let fixed = rule.fix(&ctx).unwrap();
2151
2152 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2154 let lines: Vec<&str> = fixed.lines().collect();
2156 assert!(
2160 lines[2].contains(" C |"),
2161 "Right column should be right-aligned. Got: {}",
2162 lines[2]
2163 );
2164 }
2165
2166 #[test]
2167 fn test_md060_column_align_overrides_delimiter_indicators() {
2168 let config = MD060Config {
2170 enabled: true,
2171 style: "aligned".to_string(),
2172 max_width: LineLength::from_const(0),
2173 column_align: ColumnAlign::Right, column_align_header: None,
2175 column_align_body: None,
2176 loose_last_column: false,
2177 };
2178 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2179
2180 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2183
2184 let fixed = rule.fix(&ctx).unwrap();
2185 let lines: Vec<&str> = fixed.lines().collect();
2186
2187 assert!(
2190 lines[2].contains(" A |") || lines[2].contains(" A |"),
2191 "Even left-indicated column should be right-aligned. Got: {}",
2192 lines[2]
2193 );
2194 }
2195
2196 #[test]
2197 fn test_md060_column_align_with_aligned_no_space() {
2198 let config = MD060Config {
2200 enabled: true,
2201 style: "aligned-no-space".to_string(),
2202 max_width: LineLength::from_const(0),
2203 column_align: ColumnAlign::Center,
2204 column_align_header: None,
2205 column_align_body: None,
2206 loose_last_column: false,
2207 };
2208 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2209
2210 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2212
2213 let fixed = rule.fix(&ctx).unwrap();
2214 let lines: Vec<&str> = fixed.lines().collect();
2215
2216 assert!(
2218 lines[1].contains("|---"),
2219 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2220 lines[1]
2221 );
2222 assert!(
2224 lines[3].contains("| Bob |"),
2225 "Content should be centered. Got: {}",
2226 lines[3]
2227 );
2228 }
2229
2230 #[test]
2231 fn test_md060_column_align_config_parsing() {
2232 let toml_str = r#"
2234enabled = true
2235style = "aligned"
2236column-align = "center"
2237"#;
2238 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2239 assert_eq!(config.column_align, ColumnAlign::Center);
2240
2241 let toml_str = r#"
2242enabled = true
2243style = "aligned"
2244column-align = "right"
2245"#;
2246 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2247 assert_eq!(config.column_align, ColumnAlign::Right);
2248
2249 let toml_str = r#"
2250enabled = true
2251style = "aligned"
2252column-align = "left"
2253"#;
2254 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2255 assert_eq!(config.column_align, ColumnAlign::Left);
2256
2257 let toml_str = r#"
2258enabled = true
2259style = "aligned"
2260column-align = "auto"
2261"#;
2262 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2263 assert_eq!(config.column_align, ColumnAlign::Auto);
2264 }
2265
2266 #[test]
2267 fn test_md060_column_align_default_is_auto() {
2268 let toml_str = r#"
2270enabled = true
2271style = "aligned"
2272"#;
2273 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2274 assert_eq!(config.column_align, ColumnAlign::Auto);
2275 }
2276
2277 #[test]
2278 fn test_md060_column_align_reformats_already_aligned_table() {
2279 let config = MD060Config {
2281 enabled: true,
2282 style: "aligned".to_string(),
2283 max_width: LineLength::from_const(0),
2284 column_align: ColumnAlign::Right,
2285 column_align_header: None,
2286 column_align_body: None,
2287 loose_last_column: false,
2288 };
2289 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2290
2291 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2294
2295 let fixed = rule.fix(&ctx).unwrap();
2296 let lines: Vec<&str> = fixed.lines().collect();
2297
2298 assert!(
2300 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2301 "Already aligned table should be reformatted with right alignment. Got: {}",
2302 lines[2]
2303 );
2304 assert!(
2305 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2306 "Bob should be right-aligned. Got: {}",
2307 lines[3]
2308 );
2309 }
2310
2311 #[test]
2312 fn test_md060_column_align_with_cjk_characters() {
2313 let config = MD060Config {
2315 enabled: true,
2316 style: "aligned".to_string(),
2317 max_width: LineLength::from_const(0),
2318 column_align: ColumnAlign::Center,
2319 column_align_header: None,
2320 column_align_body: None,
2321 loose_last_column: false,
2322 };
2323 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2324
2325 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327
2328 let fixed = rule.fix(&ctx).unwrap();
2329
2330 assert!(fixed.contains("Bob"), "Table should contain Bob");
2333 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2334 }
2335
2336 #[test]
2337 fn test_md060_column_align_ignored_for_compact_style() {
2338 let config = MD060Config {
2340 enabled: true,
2341 style: "compact".to_string(),
2342 max_width: LineLength::from_const(0),
2343 column_align: ColumnAlign::Right, column_align_header: None,
2345 column_align_body: None,
2346 loose_last_column: false,
2347 };
2348 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2349
2350 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352
2353 let fixed = rule.fix(&ctx).unwrap();
2354
2355 assert!(
2357 fixed.contains("| Alice |"),
2358 "Compact style should have single space padding, not alignment. Got: {fixed}"
2359 );
2360 }
2361
2362 #[test]
2363 fn test_md060_column_align_ignored_for_tight_style() {
2364 let config = MD060Config {
2366 enabled: true,
2367 style: "tight".to_string(),
2368 max_width: LineLength::from_const(0),
2369 column_align: ColumnAlign::Center, column_align_header: None,
2371 column_align_body: None,
2372 loose_last_column: false,
2373 };
2374 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2375
2376 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2378
2379 let fixed = rule.fix(&ctx).unwrap();
2380
2381 assert!(
2383 fixed.contains("|Alice|"),
2384 "Tight style should have no spaces. Got: {fixed}"
2385 );
2386 }
2387
2388 #[test]
2389 fn test_md060_column_align_with_empty_cells() {
2390 let config = MD060Config {
2392 enabled: true,
2393 style: "aligned".to_string(),
2394 max_width: LineLength::from_const(0),
2395 column_align: ColumnAlign::Center,
2396 column_align_header: None,
2397 column_align_body: None,
2398 loose_last_column: false,
2399 };
2400 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2401
2402 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2404
2405 let fixed = rule.fix(&ctx).unwrap();
2406 let lines: Vec<&str> = fixed.lines().collect();
2407
2408 assert!(
2410 lines[3].contains("| |") || lines[3].contains("| |"),
2411 "Empty cell should be padded correctly. Got: {}",
2412 lines[3]
2413 );
2414 }
2415
2416 #[test]
2417 fn test_md060_column_align_auto_preserves_already_aligned() {
2418 let config = MD060Config {
2420 enabled: true,
2421 style: "aligned".to_string(),
2422 max_width: LineLength::from_const(0),
2423 column_align: ColumnAlign::Auto,
2424 column_align_header: None,
2425 column_align_body: None,
2426 loose_last_column: false,
2427 };
2428 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2429
2430 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2433
2434 let fixed = rule.fix(&ctx).unwrap();
2435
2436 assert_eq!(
2438 fixed, content,
2439 "Already aligned table should be preserved with column-align=auto"
2440 );
2441 }
2442
2443 #[test]
2444 fn test_cjk_table_display_aligned_not_flagged() {
2445 use crate::config::MarkdownFlavor;
2449
2450 let table_lines: Vec<&str> = vec![
2452 "| Header | Name |",
2453 "| ------ | ---- |",
2454 "| Hello | Test |",
2455 "| δ½ ε₯½ | Test |",
2456 ];
2457
2458 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2459 assert!(
2460 result,
2461 "Table with CJK characters that is display-aligned should be recognized as aligned"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_cjk_table_not_reformatted_when_aligned() {
2467 let rule = MD060TableFormat::new(true, "aligned".to_string());
2469 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2472
2473 let fixed = rule.fix(&ctx).unwrap();
2475 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2476 }
2477}