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 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1007 result_lines[line_idx] = format_result.lines[i].clone();
1008 }
1009 }
1010
1011 let mut fixed = result_lines.join("\n");
1012 if content.ends_with('\n') && !fixed.ends_with('\n') {
1013 fixed.push('\n');
1014 }
1015 Ok(fixed)
1016 }
1017
1018 fn as_any(&self) -> &dyn std::any::Any {
1019 self
1020 }
1021
1022 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1023 let table = crate::rule_config_serde::config_schema_table(&MD060Config::default())?;
1024 Some((MD060Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1025 }
1026
1027 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1028 where
1029 Self: Sized,
1030 {
1031 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1032 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1033
1034 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1036
1037 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use crate::lint_context::LintContext;
1045 use crate::types::LineLength;
1046
1047 fn md013_with_line_length(line_length: usize) -> MD013Config {
1049 MD013Config {
1050 line_length: LineLength::from_const(line_length),
1051 tables: true, ..Default::default()
1053 }
1054 }
1055
1056 #[test]
1057 fn test_md060_align_simple_ascii_table() {
1058 let rule = MD060TableFormat::new(true, "aligned".to_string());
1059
1060 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062
1063 let fixed = rule.fix(&ctx).unwrap();
1064 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1065 assert_eq!(fixed, expected);
1066
1067 let lines: Vec<&str> = fixed.lines().collect();
1069 assert_eq!(lines[0].len(), lines[1].len());
1070 assert_eq!(lines[1].len(), lines[2].len());
1071 }
1072
1073 #[test]
1074 fn test_md060_cjk_characters_aligned_correctly() {
1075 let rule = MD060TableFormat::new(true, "aligned".to_string());
1076
1077 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079
1080 let fixed = rule.fix(&ctx).unwrap();
1081
1082 let lines: Vec<&str> = fixed.lines().collect();
1083 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1084 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1085
1086 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1087 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1088
1089 assert_eq!(width1, width3);
1090 }
1091
1092 #[test]
1093 fn test_md060_basic_emoji() {
1094 let rule = MD060TableFormat::new(true, "aligned".to_string());
1095
1096 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098
1099 let fixed = rule.fix(&ctx).unwrap();
1100 assert!(fixed.contains("Status"));
1101 }
1102
1103 #[test]
1104 fn test_md060_zwj_emoji_skipped() {
1105 let rule = MD060TableFormat::new(true, "aligned".to_string());
1106
1107 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109
1110 let fixed = rule.fix(&ctx).unwrap();
1111 assert_eq!(fixed, content);
1112 }
1113
1114 #[test]
1115 fn test_md060_inline_code_with_escaped_pipes() {
1116 let rule = MD060TableFormat::new(true, "aligned".to_string());
1119
1120 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123
1124 let fixed = rule.fix(&ctx).unwrap();
1125 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1126 }
1127
1128 #[test]
1129 fn test_md060_compact_style() {
1130 let rule = MD060TableFormat::new(true, "compact".to_string());
1131
1132 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134
1135 let fixed = rule.fix(&ctx).unwrap();
1136 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1137 assert_eq!(fixed, expected);
1138 }
1139
1140 #[test]
1141 fn test_md060_tight_style() {
1142 let rule = MD060TableFormat::new(true, "tight".to_string());
1143
1144 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146
1147 let fixed = rule.fix(&ctx).unwrap();
1148 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1149 assert_eq!(fixed, expected);
1150 }
1151
1152 #[test]
1153 fn test_md060_aligned_no_space_style() {
1154 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1156
1157 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159
1160 let fixed = rule.fix(&ctx).unwrap();
1161
1162 let lines: Vec<&str> = fixed.lines().collect();
1164 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1165 assert_eq!(
1166 lines[1], "|-------|-----|",
1167 "Delimiter should have NO spaces around dashes"
1168 );
1169 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1170
1171 assert_eq!(lines[0].len(), lines[1].len());
1173 assert_eq!(lines[1].len(), lines[2].len());
1174 }
1175
1176 #[test]
1177 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1178 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1180
1181 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183
1184 let fixed = rule.fix(&ctx).unwrap();
1185 let lines: Vec<&str> = fixed.lines().collect();
1186
1187 assert!(
1189 fixed.contains("|:"),
1190 "Should have left alignment indicator adjacent to pipe"
1191 );
1192 assert!(
1193 fixed.contains(":|"),
1194 "Should have right alignment indicator adjacent to pipe"
1195 );
1196 assert!(
1198 lines[1].contains(":---") && lines[1].contains("---:"),
1199 "Should have center alignment colons"
1200 );
1201 }
1202
1203 #[test]
1204 fn test_md060_aligned_no_space_three_column_table() {
1205 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1207
1208 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 |";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210
1211 let fixed = rule.fix(&ctx).unwrap();
1212 let lines: Vec<&str> = fixed.lines().collect();
1213
1214 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1216 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1217 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1218 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1219 }
1220
1221 #[test]
1222 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1223 let config = MD060Config {
1225 enabled: true,
1226 style: "aligned-no-space".to_string(),
1227 max_width: LineLength::from_const(50),
1228 column_align: ColumnAlign::Auto,
1229 column_align_header: None,
1230 column_align_body: None,
1231 loose_last_column: false,
1232 };
1233 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1234
1235 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238
1239 let fixed = rule.fix(&ctx).unwrap();
1240
1241 assert!(
1243 fixed.contains("| --- |"),
1244 "Should be compact format when exceeding max-width"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_md060_aligned_no_space_cjk_characters() {
1250 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1252
1253 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255
1256 let fixed = rule.fix(&ctx).unwrap();
1257 let lines: Vec<&str> = fixed.lines().collect();
1258
1259 use unicode_width::UnicodeWidthStr;
1262 assert_eq!(
1263 lines[0].width(),
1264 lines[1].width(),
1265 "Header and delimiter should have same display width"
1266 );
1267 assert_eq!(
1268 lines[1].width(),
1269 lines[2].width(),
1270 "Delimiter and content should have same display width"
1271 );
1272
1273 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1275 }
1276
1277 #[test]
1278 fn test_md060_aligned_no_space_minimum_width() {
1279 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1281
1282 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284
1285 let fixed = rule.fix(&ctx).unwrap();
1286 let lines: Vec<&str> = fixed.lines().collect();
1287
1288 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1290 assert_eq!(lines[0].len(), lines[1].len());
1292 assert_eq!(lines[1].len(), lines[2].len());
1293 }
1294
1295 #[test]
1296 fn test_md060_any_style_consistency() {
1297 let rule = MD060TableFormat::new(true, "any".to_string());
1298
1299 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302
1303 let fixed = rule.fix(&ctx).unwrap();
1304 assert_eq!(fixed, content);
1305
1306 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1308 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1309
1310 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1311 assert_eq!(fixed_aligned, content_aligned);
1312 }
1313
1314 #[test]
1315 fn test_md060_empty_cells() {
1316 let rule = MD060TableFormat::new(true, "aligned".to_string());
1317
1318 let content = "| A | B |\n|---|---|\n| | X |";
1319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320
1321 let fixed = rule.fix(&ctx).unwrap();
1322 assert!(fixed.contains("|"));
1323 }
1324
1325 #[test]
1326 fn test_md060_mixed_content() {
1327 let rule = MD060TableFormat::new(true, "aligned".to_string());
1328
1329 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331
1332 let fixed = rule.fix(&ctx).unwrap();
1333 assert!(fixed.contains("δΈζ"));
1334 assert!(fixed.contains("NYC"));
1335 }
1336
1337 #[test]
1338 fn test_md060_preserve_alignment_indicators() {
1339 let rule = MD060TableFormat::new(true, "aligned".to_string());
1340
1341 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343
1344 let fixed = rule.fix(&ctx).unwrap();
1345
1346 assert!(fixed.contains(":---"), "Should contain left alignment");
1347 assert!(fixed.contains(":----:"), "Should contain center alignment");
1348 assert!(fixed.contains("----:"), "Should contain right alignment");
1349 }
1350
1351 #[test]
1352 fn test_md060_minimum_column_width() {
1353 let rule = MD060TableFormat::new(true, "aligned".to_string());
1354
1355 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359
1360 let fixed = rule.fix(&ctx).unwrap();
1361
1362 let lines: Vec<&str> = fixed.lines().collect();
1363 assert_eq!(lines[0].len(), lines[1].len());
1364 assert_eq!(lines[1].len(), lines[2].len());
1365
1366 assert!(fixed.contains("ID "), "Short content should be padded");
1368 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1369 }
1370
1371 #[test]
1372 fn test_md060_auto_compact_exceeds_default_threshold() {
1373 let config = MD060Config {
1375 enabled: true,
1376 style: "aligned".to_string(),
1377 max_width: LineLength::from_const(0),
1378 column_align: ColumnAlign::Auto,
1379 column_align_header: None,
1380 column_align_body: None,
1381 loose_last_column: false,
1382 };
1383 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1384
1385 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390
1391 let fixed = rule.fix(&ctx).unwrap();
1392
1393 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1395 assert!(fixed.contains("| --- | --- | --- |"));
1396 assert!(fixed.contains("| Short | Data | Here |"));
1397
1398 let lines: Vec<&str> = fixed.lines().collect();
1400 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1402 }
1403
1404 #[test]
1405 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1406 let config = MD060Config {
1408 enabled: true,
1409 style: "aligned".to_string(),
1410 max_width: LineLength::from_const(50),
1411 column_align: ColumnAlign::Auto,
1412 column_align_header: None,
1413 column_align_body: None,
1414 loose_last_column: false,
1415 };
1416 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 |";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423
1424 let fixed = rule.fix(&ctx).unwrap();
1425
1426 assert!(
1428 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1429 );
1430 assert!(fixed.contains("| --- | --- | --- |"));
1431 assert!(fixed.contains("| Data | Data | Data |"));
1432
1433 let lines: Vec<&str> = fixed.lines().collect();
1435 assert!(lines[0].len() != lines[2].len());
1436 }
1437
1438 #[test]
1439 fn test_md060_stays_aligned_under_threshold() {
1440 let config = MD060Config {
1442 enabled: true,
1443 style: "aligned".to_string(),
1444 max_width: LineLength::from_const(100),
1445 column_align: ColumnAlign::Auto,
1446 column_align_header: None,
1447 column_align_body: None,
1448 loose_last_column: false,
1449 };
1450 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1451
1452 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1455
1456 let fixed = rule.fix(&ctx).unwrap();
1457
1458 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1460 assert_eq!(fixed, expected);
1461
1462 let lines: Vec<&str> = fixed.lines().collect();
1463 assert_eq!(lines[0].len(), lines[1].len());
1464 assert_eq!(lines[1].len(), lines[2].len());
1465 }
1466
1467 #[test]
1468 fn test_md060_width_calculation_formula() {
1469 let config = MD060Config {
1471 enabled: true,
1472 style: "aligned".to_string(),
1473 max_width: LineLength::from_const(0),
1474 column_align: ColumnAlign::Auto,
1475 column_align_header: None,
1476 column_align_body: None,
1477 loose_last_column: false,
1478 };
1479 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1480
1481 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1486
1487 let fixed = rule.fix(&ctx).unwrap();
1488
1489 let lines: Vec<&str> = fixed.lines().collect();
1491 assert_eq!(lines[0].len(), lines[1].len());
1492 assert_eq!(lines[1].len(), lines[2].len());
1493 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1497 enabled: true,
1498 style: "aligned".to_string(),
1499 max_width: LineLength::from_const(24),
1500 column_align: ColumnAlign::Auto,
1501 column_align_header: None,
1502 column_align_body: None,
1503 loose_last_column: false,
1504 };
1505 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1506
1507 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1508
1509 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1511 assert!(fixed_compact.contains("| --- | --- | --- |"));
1512 }
1513
1514 #[test]
1515 fn test_md060_very_wide_table_auto_compacts() {
1516 let config = MD060Config {
1517 enabled: true,
1518 style: "aligned".to_string(),
1519 max_width: LineLength::from_const(0),
1520 column_align: ColumnAlign::Auto,
1521 column_align_header: None,
1522 column_align_body: None,
1523 loose_last_column: false,
1524 };
1525 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1526
1527 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 |";
1531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532
1533 let fixed = rule.fix(&ctx).unwrap();
1534
1535 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1537 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1538 }
1539
1540 #[test]
1541 fn test_md060_inherit_from_md013_line_length() {
1542 let config = MD060Config {
1544 enabled: true,
1545 style: "aligned".to_string(),
1546 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1548 column_align_header: None,
1549 column_align_body: None,
1550 loose_last_column: false,
1551 };
1552
1553 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1555 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1556
1557 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560
1561 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1563
1564 let fixed_120 = rule_120.fix(&ctx).unwrap();
1566
1567 let lines_120: Vec<&str> = fixed_120.lines().collect();
1569 assert_eq!(lines_120[0].len(), lines_120[1].len());
1570 assert_eq!(lines_120[1].len(), lines_120[2].len());
1571 }
1572
1573 #[test]
1574 fn test_md060_edge_case_exactly_at_threshold() {
1575 let config = MD060Config {
1579 enabled: true,
1580 style: "aligned".to_string(),
1581 max_width: LineLength::from_const(17),
1582 column_align: ColumnAlign::Auto,
1583 column_align_header: None,
1584 column_align_body: None,
1585 loose_last_column: false,
1586 };
1587 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1588
1589 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591
1592 let fixed = rule.fix(&ctx).unwrap();
1593
1594 let lines: Vec<&str> = fixed.lines().collect();
1596 assert_eq!(lines[0].len(), 17);
1597 assert_eq!(lines[0].len(), lines[1].len());
1598 assert_eq!(lines[1].len(), lines[2].len());
1599
1600 let config_under = MD060Config {
1602 enabled: true,
1603 style: "aligned".to_string(),
1604 max_width: LineLength::from_const(16),
1605 column_align: ColumnAlign::Auto,
1606 column_align_header: None,
1607 column_align_body: None,
1608 loose_last_column: false,
1609 };
1610 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1611
1612 let fixed_compact = rule_under.fix(&ctx).unwrap();
1613
1614 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1616 assert!(fixed_compact.contains("| --- | --- |"));
1617 }
1618
1619 #[test]
1620 fn test_md060_auto_compact_warning_message() {
1621 let config = MD060Config {
1623 enabled: true,
1624 style: "aligned".to_string(),
1625 max_width: LineLength::from_const(50),
1626 column_align: ColumnAlign::Auto,
1627 column_align_header: None,
1628 column_align_body: None,
1629 loose_last_column: false,
1630 };
1631 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1632
1633 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636
1637 let warnings = rule.check(&ctx).unwrap();
1638
1639 assert!(!warnings.is_empty(), "Should generate warnings");
1641
1642 let auto_compact_warnings: Vec<_> = warnings
1643 .iter()
1644 .filter(|w| w.message.contains("too wide for aligned formatting"))
1645 .collect();
1646
1647 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1648
1649 let first_warning = auto_compact_warnings[0];
1651 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1652 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1653 }
1654
1655 #[test]
1656 fn test_md060_issue_129_detect_style_from_all_rows() {
1657 let rule = MD060TableFormat::new(true, "any".to_string());
1661
1662 let content = "| a long heading | another long heading |\n\
1664 | -------------- | -------------------- |\n\
1665 | a | 1 |\n\
1666 | b b | 2 |\n\
1667 | c c c | 3 |";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669
1670 let fixed = rule.fix(&ctx).unwrap();
1671
1672 assert!(
1674 fixed.contains("| a | 1 |"),
1675 "Should preserve aligned padding in first content row"
1676 );
1677 assert!(
1678 fixed.contains("| b b | 2 |"),
1679 "Should preserve aligned padding in second content row"
1680 );
1681 assert!(
1682 fixed.contains("| c c c | 3 |"),
1683 "Should preserve aligned padding in third content row"
1684 );
1685
1686 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1688 }
1689
1690 #[test]
1691 fn test_md060_regular_alignment_warning_message() {
1692 let config = MD060Config {
1694 enabled: true,
1695 style: "aligned".to_string(),
1696 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1698 column_align_header: None,
1699 column_align_body: None,
1700 loose_last_column: false,
1701 };
1702 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1703
1704 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707
1708 let warnings = rule.check(&ctx).unwrap();
1709
1710 assert!(!warnings.is_empty(), "Should generate warnings");
1712
1713 assert!(warnings[0].message.contains("Table columns should be aligned"));
1715 assert!(!warnings[0].message.contains("too wide"));
1716 assert!(!warnings[0].message.contains("max-width"));
1717 }
1718
1719 #[test]
1722 fn test_md060_unlimited_when_md013_disabled() {
1723 let config = MD060Config {
1725 enabled: true,
1726 style: "aligned".to_string(),
1727 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1729 column_align_header: None,
1730 column_align_body: None,
1731 loose_last_column: false,
1732 };
1733 let md013_config = MD013Config::default();
1734 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1735
1736 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let fixed = rule.fix(&ctx).unwrap();
1740
1741 let lines: Vec<&str> = fixed.lines().collect();
1743 assert_eq!(
1745 lines[0].len(),
1746 lines[1].len(),
1747 "Table should be aligned when MD013 is disabled"
1748 );
1749 }
1750
1751 #[test]
1752 fn test_md060_unlimited_when_md013_tables_false() {
1753 let config = MD060Config {
1755 enabled: true,
1756 style: "aligned".to_string(),
1757 max_width: LineLength::from_const(0),
1758 column_align: ColumnAlign::Auto,
1759 column_align_header: None,
1760 column_align_body: None,
1761 loose_last_column: false,
1762 };
1763 let md013_config = MD013Config {
1764 tables: false, line_length: LineLength::from_const(80),
1766 ..Default::default()
1767 };
1768 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1769
1770 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let fixed = rule.fix(&ctx).unwrap();
1774
1775 let lines: Vec<&str> = fixed.lines().collect();
1777 assert_eq!(
1778 lines[0].len(),
1779 lines[1].len(),
1780 "Table should be aligned when MD013.tables=false"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_md060_unlimited_when_md013_line_length_zero() {
1786 let config = MD060Config {
1788 enabled: true,
1789 style: "aligned".to_string(),
1790 max_width: LineLength::from_const(0),
1791 column_align: ColumnAlign::Auto,
1792 column_align_header: None,
1793 column_align_body: None,
1794 loose_last_column: false,
1795 };
1796 let md013_config = MD013Config {
1797 tables: true,
1798 line_length: LineLength::from_const(0), ..Default::default()
1800 };
1801 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1802
1803 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1806 let fixed = rule.fix(&ctx).unwrap();
1807
1808 let lines: Vec<&str> = fixed.lines().collect();
1810 assert_eq!(
1811 lines[0].len(),
1812 lines[1].len(),
1813 "Table should be aligned when MD013.line_length=0"
1814 );
1815 }
1816
1817 #[test]
1818 fn test_md060_explicit_max_width_overrides_md013_settings() {
1819 let config = MD060Config {
1821 enabled: true,
1822 style: "aligned".to_string(),
1823 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1825 column_align_header: None,
1826 column_align_body: None,
1827 loose_last_column: false,
1828 };
1829 let md013_config = MD013Config {
1830 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1833 };
1834 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1835
1836 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 let fixed = rule.fix(&ctx).unwrap();
1840
1841 assert!(
1843 fixed.contains("| --- |"),
1844 "Should be compact format due to explicit max_width"
1845 );
1846 }
1847
1848 #[test]
1849 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1850 let config = MD060Config {
1852 enabled: true,
1853 style: "aligned".to_string(),
1854 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1856 column_align_header: None,
1857 column_align_body: None,
1858 loose_last_column: false,
1859 };
1860 let md013_config = MD013Config {
1861 tables: true,
1862 line_length: LineLength::from_const(50), ..Default::default()
1864 };
1865 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1866
1867 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let fixed = rule.fix(&ctx).unwrap();
1871
1872 assert!(
1874 fixed.contains("| --- |"),
1875 "Should be compact format when inheriting MD013 limit"
1876 );
1877 }
1878
1879 #[test]
1882 fn test_aligned_no_space_reformats_spaced_delimiter() {
1883 let config = MD060Config {
1886 enabled: true,
1887 style: "aligned-no-space".to_string(),
1888 max_width: LineLength::from_const(0),
1889 column_align: ColumnAlign::Auto,
1890 column_align_header: None,
1891 column_align_body: None,
1892 loose_last_column: false,
1893 };
1894 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1895
1896 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1899 let fixed = rule.fix(&ctx).unwrap();
1900
1901 assert!(
1904 !fixed.contains("| ----"),
1905 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1906 );
1907 assert!(
1908 !fixed.contains("---- |"),
1909 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1910 );
1911 assert!(
1913 fixed.contains("|----"),
1914 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1915 );
1916 }
1917
1918 #[test]
1919 fn test_aligned_reformats_compact_delimiter() {
1920 let config = MD060Config {
1923 enabled: true,
1924 style: "aligned".to_string(),
1925 max_width: LineLength::from_const(0),
1926 column_align: ColumnAlign::Auto,
1927 column_align_header: None,
1928 column_align_body: None,
1929 loose_last_column: false,
1930 };
1931 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1932
1933 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let fixed = rule.fix(&ctx).unwrap();
1937
1938 assert!(
1940 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1941 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_aligned_no_space_preserves_matching_table() {
1947 let config = MD060Config {
1949 enabled: true,
1950 style: "aligned-no-space".to_string(),
1951 max_width: LineLength::from_const(0),
1952 column_align: ColumnAlign::Auto,
1953 column_align_header: None,
1954 column_align_body: None,
1955 loose_last_column: false,
1956 };
1957 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1958
1959 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let fixed = rule.fix(&ctx).unwrap();
1963
1964 assert_eq!(
1966 fixed, content,
1967 "Table already in aligned-no-space style should be preserved"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_aligned_preserves_matching_table() {
1973 let config = MD060Config {
1975 enabled: true,
1976 style: "aligned".to_string(),
1977 max_width: LineLength::from_const(0),
1978 column_align: ColumnAlign::Auto,
1979 column_align_header: None,
1980 column_align_body: None,
1981 loose_last_column: false,
1982 };
1983 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1984
1985 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1988 let fixed = rule.fix(&ctx).unwrap();
1989
1990 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
1992 }
1993
1994 #[test]
1995 fn test_cjk_table_display_width_consistency() {
1996 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2002
2003 let is_aligned =
2005 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2006 assert!(
2007 !is_aligned,
2008 "Table with uneven raw line lengths should NOT be considered aligned"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_cjk_width_calculation_in_aligned_check() {
2014 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2017 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2018
2019 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2020 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2021
2022 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2024 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2025
2026 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2028 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2030 }
2031
2032 #[test]
2035 fn test_md060_column_align_left() {
2036 let config = MD060Config {
2038 enabled: true,
2039 style: "aligned".to_string(),
2040 max_width: LineLength::from_const(0),
2041 column_align: ColumnAlign::Left,
2042 column_align_header: None,
2043 column_align_body: None,
2044 loose_last_column: false,
2045 };
2046 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2047
2048 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2050
2051 let fixed = rule.fix(&ctx).unwrap();
2052 let lines: Vec<&str> = fixed.lines().collect();
2053
2054 assert!(
2056 lines[2].contains("| Alice "),
2057 "Content should be left-aligned (Alice should have trailing padding)"
2058 );
2059 assert!(
2060 lines[3].contains("| Bob "),
2061 "Content should be left-aligned (Bob should have trailing padding)"
2062 );
2063 }
2064
2065 #[test]
2066 fn test_md060_column_align_center() {
2067 let config = MD060Config {
2069 enabled: true,
2070 style: "aligned".to_string(),
2071 max_width: LineLength::from_const(0),
2072 column_align: ColumnAlign::Center,
2073 column_align_header: None,
2074 column_align_body: None,
2075 loose_last_column: false,
2076 };
2077 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2078
2079 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2081
2082 let fixed = rule.fix(&ctx).unwrap();
2083 let lines: Vec<&str> = fixed.lines().collect();
2084
2085 assert!(
2088 lines[3].contains("| Bob |"),
2089 "Bob should be centered with padding on both sides. Got: {}",
2090 lines[3]
2091 );
2092 }
2093
2094 #[test]
2095 fn test_md060_column_align_right() {
2096 let config = MD060Config {
2098 enabled: true,
2099 style: "aligned".to_string(),
2100 max_width: LineLength::from_const(0),
2101 column_align: ColumnAlign::Right,
2102 column_align_header: None,
2103 column_align_body: None,
2104 loose_last_column: false,
2105 };
2106 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2107
2108 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2110
2111 let fixed = rule.fix(&ctx).unwrap();
2112 let lines: Vec<&str> = fixed.lines().collect();
2113
2114 assert!(
2116 lines[3].contains("| Bob |"),
2117 "Bob should be right-aligned with padding on left. Got: {}",
2118 lines[3]
2119 );
2120 }
2121
2122 #[test]
2123 fn test_md060_column_align_auto_respects_delimiter() {
2124 let config = MD060Config {
2126 enabled: true,
2127 style: "aligned".to_string(),
2128 max_width: LineLength::from_const(0),
2129 column_align: ColumnAlign::Auto,
2130 column_align_header: None,
2131 column_align_body: None,
2132 loose_last_column: false,
2133 };
2134 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2135
2136 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2139
2140 let fixed = rule.fix(&ctx).unwrap();
2141
2142 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2144 let lines: Vec<&str> = fixed.lines().collect();
2146 assert!(
2150 lines[2].contains(" C |"),
2151 "Right column should be right-aligned. Got: {}",
2152 lines[2]
2153 );
2154 }
2155
2156 #[test]
2157 fn test_md060_column_align_overrides_delimiter_indicators() {
2158 let config = MD060Config {
2160 enabled: true,
2161 style: "aligned".to_string(),
2162 max_width: LineLength::from_const(0),
2163 column_align: ColumnAlign::Right, column_align_header: None,
2165 column_align_body: None,
2166 loose_last_column: false,
2167 };
2168 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2169
2170 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173
2174 let fixed = rule.fix(&ctx).unwrap();
2175 let lines: Vec<&str> = fixed.lines().collect();
2176
2177 assert!(
2180 lines[2].contains(" A |") || lines[2].contains(" A |"),
2181 "Even left-indicated column should be right-aligned. Got: {}",
2182 lines[2]
2183 );
2184 }
2185
2186 #[test]
2187 fn test_md060_column_align_with_aligned_no_space() {
2188 let config = MD060Config {
2190 enabled: true,
2191 style: "aligned-no-space".to_string(),
2192 max_width: LineLength::from_const(0),
2193 column_align: ColumnAlign::Center,
2194 column_align_header: None,
2195 column_align_body: None,
2196 loose_last_column: false,
2197 };
2198 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2199
2200 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2202
2203 let fixed = rule.fix(&ctx).unwrap();
2204 let lines: Vec<&str> = fixed.lines().collect();
2205
2206 assert!(
2208 lines[1].contains("|---"),
2209 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2210 lines[1]
2211 );
2212 assert!(
2214 lines[3].contains("| Bob |"),
2215 "Content should be centered. Got: {}",
2216 lines[3]
2217 );
2218 }
2219
2220 #[test]
2221 fn test_md060_column_align_config_parsing() {
2222 let toml_str = r#"
2224enabled = true
2225style = "aligned"
2226column-align = "center"
2227"#;
2228 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2229 assert_eq!(config.column_align, ColumnAlign::Center);
2230
2231 let toml_str = r#"
2232enabled = true
2233style = "aligned"
2234column-align = "right"
2235"#;
2236 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2237 assert_eq!(config.column_align, ColumnAlign::Right);
2238
2239 let toml_str = r#"
2240enabled = true
2241style = "aligned"
2242column-align = "left"
2243"#;
2244 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2245 assert_eq!(config.column_align, ColumnAlign::Left);
2246
2247 let toml_str = r#"
2248enabled = true
2249style = "aligned"
2250column-align = "auto"
2251"#;
2252 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2253 assert_eq!(config.column_align, ColumnAlign::Auto);
2254 }
2255
2256 #[test]
2257 fn test_md060_column_align_default_is_auto() {
2258 let toml_str = r#"
2260enabled = true
2261style = "aligned"
2262"#;
2263 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2264 assert_eq!(config.column_align, ColumnAlign::Auto);
2265 }
2266
2267 #[test]
2268 fn test_md060_column_align_reformats_already_aligned_table() {
2269 let config = MD060Config {
2271 enabled: true,
2272 style: "aligned".to_string(),
2273 max_width: LineLength::from_const(0),
2274 column_align: ColumnAlign::Right,
2275 column_align_header: None,
2276 column_align_body: None,
2277 loose_last_column: false,
2278 };
2279 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2280
2281 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2284
2285 let fixed = rule.fix(&ctx).unwrap();
2286 let lines: Vec<&str> = fixed.lines().collect();
2287
2288 assert!(
2290 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2291 "Already aligned table should be reformatted with right alignment. Got: {}",
2292 lines[2]
2293 );
2294 assert!(
2295 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2296 "Bob should be right-aligned. Got: {}",
2297 lines[3]
2298 );
2299 }
2300
2301 #[test]
2302 fn test_md060_column_align_with_cjk_characters() {
2303 let config = MD060Config {
2305 enabled: true,
2306 style: "aligned".to_string(),
2307 max_width: LineLength::from_const(0),
2308 column_align: ColumnAlign::Center,
2309 column_align_header: None,
2310 column_align_body: None,
2311 loose_last_column: false,
2312 };
2313 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2314
2315 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2317
2318 let fixed = rule.fix(&ctx).unwrap();
2319
2320 assert!(fixed.contains("Bob"), "Table should contain Bob");
2323 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2324 }
2325
2326 #[test]
2327 fn test_md060_column_align_ignored_for_compact_style() {
2328 let config = MD060Config {
2330 enabled: true,
2331 style: "compact".to_string(),
2332 max_width: LineLength::from_const(0),
2333 column_align: ColumnAlign::Right, column_align_header: None,
2335 column_align_body: None,
2336 loose_last_column: false,
2337 };
2338 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2339
2340 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342
2343 let fixed = rule.fix(&ctx).unwrap();
2344
2345 assert!(
2347 fixed.contains("| Alice |"),
2348 "Compact style should have single space padding, not alignment. Got: {fixed}"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_md060_column_align_ignored_for_tight_style() {
2354 let config = MD060Config {
2356 enabled: true,
2357 style: "tight".to_string(),
2358 max_width: LineLength::from_const(0),
2359 column_align: ColumnAlign::Center, column_align_header: None,
2361 column_align_body: None,
2362 loose_last_column: false,
2363 };
2364 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2365
2366 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368
2369 let fixed = rule.fix(&ctx).unwrap();
2370
2371 assert!(
2373 fixed.contains("|Alice|"),
2374 "Tight style should have no spaces. Got: {fixed}"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_md060_column_align_with_empty_cells() {
2380 let config = MD060Config {
2382 enabled: true,
2383 style: "aligned".to_string(),
2384 max_width: LineLength::from_const(0),
2385 column_align: ColumnAlign::Center,
2386 column_align_header: None,
2387 column_align_body: None,
2388 loose_last_column: false,
2389 };
2390 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2391
2392 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2394
2395 let fixed = rule.fix(&ctx).unwrap();
2396 let lines: Vec<&str> = fixed.lines().collect();
2397
2398 assert!(
2400 lines[3].contains("| |") || lines[3].contains("| |"),
2401 "Empty cell should be padded correctly. Got: {}",
2402 lines[3]
2403 );
2404 }
2405
2406 #[test]
2407 fn test_md060_column_align_auto_preserves_already_aligned() {
2408 let config = MD060Config {
2410 enabled: true,
2411 style: "aligned".to_string(),
2412 max_width: LineLength::from_const(0),
2413 column_align: ColumnAlign::Auto,
2414 column_align_header: None,
2415 column_align_body: None,
2416 loose_last_column: false,
2417 };
2418 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2419
2420 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2423
2424 let fixed = rule.fix(&ctx).unwrap();
2425
2426 assert_eq!(
2428 fixed, content,
2429 "Already aligned table should be preserved with column-align=auto"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_cjk_table_display_aligned_not_flagged() {
2435 use crate::config::MarkdownFlavor;
2439
2440 let table_lines: Vec<&str> = vec![
2442 "| Header | Name |",
2443 "| ------ | ---- |",
2444 "| Hello | Test |",
2445 "| δ½ ε₯½ | Test |",
2446 ];
2447
2448 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2449 assert!(
2450 result,
2451 "Table with CJK characters that is display-aligned should be recognized as aligned"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_cjk_table_not_reformatted_when_aligned() {
2457 let rule = MD060TableFormat::new(true, "aligned".to_string());
2459 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2462
2463 let fixed = rule.fix(&ctx).unwrap();
2465 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2466 }
2467}