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