1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_line_range;
4use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
5use crate::utils::table_utils::TableUtils;
6use unicode_width::UnicodeWidthStr;
7
8mod md060_config;
9use crate::md013_line_length::MD013Config;
10pub use md060_config::ColumnAlign;
11pub use md060_config::MD060Config;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15enum RowType {
16 Header,
18 Delimiter,
20 Body,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25enum ColumnAlignment {
26 Left,
27 Center,
28 Right,
29}
30
31#[derive(Debug, Clone)]
32struct TableFormatResult {
33 lines: Vec<String>,
34 auto_compacted: bool,
35 aligned_width: Option<usize>,
36}
37
38#[derive(Debug, Clone, Copy)]
40struct RowFormatOptions {
41 row_type: RowType,
43 compact_delimiter: bool,
45 column_align: ColumnAlign,
47 column_align_header: Option<ColumnAlign>,
49 column_align_body: Option<ColumnAlign>,
51}
52
53#[derive(Debug, Clone, Default)]
176pub struct MD060TableFormat {
177 config: MD060Config,
178 md013_config: MD013Config,
179 md013_disabled: bool,
180}
181
182impl MD060TableFormat {
183 pub fn new(enabled: bool, style: String) -> Self {
184 use crate::types::LineLength;
185 Self {
186 config: MD060Config {
187 enabled,
188 style,
189 max_width: LineLength::from_const(0),
190 column_align: ColumnAlign::Auto,
191 column_align_header: None,
192 column_align_body: None,
193 loose_last_column: false,
194 },
195 md013_config: MD013Config::default(),
196 md013_disabled: false,
197 }
198 }
199
200 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
201 Self {
202 config,
203 md013_config,
204 md013_disabled,
205 }
206 }
207
208 fn effective_max_width(&self) -> usize {
218 if !self.config.max_width.is_unlimited() {
220 return self.config.max_width.get();
221 }
222
223 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
228 return usize::MAX; }
230
231 self.md013_config.line_length.get()
233 }
234
235 fn contains_problematic_chars(text: &str) -> bool {
246 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
251
252 fn calculate_cell_display_width(cell_content: &str) -> usize {
253 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
254 masked.trim().width()
255 }
256
257 #[cfg(test)]
260 fn parse_table_row(line: &str) -> Vec<String> {
261 TableUtils::split_table_row(line)
262 }
263
264 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
268 TableUtils::split_table_row_with_flavor(line, flavor)
269 }
270
271 fn is_delimiter_row(row: &[String]) -> bool {
272 if row.is_empty() {
273 return false;
274 }
275 row.iter().all(|cell| {
276 let trimmed = cell.trim();
277 !trimmed.is_empty()
280 && trimmed.contains('-')
281 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
282 })
283 }
284
285 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
288 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
289 (&line[..m.end()], &line[m.end()..])
290 } else {
291 ("", line)
292 }
293 }
294
295 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
296 delimiter_row
297 .iter()
298 .map(|cell| {
299 let trimmed = cell.trim();
300 let has_left_colon = trimmed.starts_with(':');
301 let has_right_colon = trimmed.ends_with(':');
302
303 match (has_left_colon, has_right_colon) {
304 (true, true) => ColumnAlignment::Center,
305 (false, true) => ColumnAlignment::Right,
306 _ => ColumnAlignment::Left,
307 }
308 })
309 .collect()
310 }
311
312 fn calculate_column_widths(
313 table_lines: &[&str],
314 flavor: crate::config::MarkdownFlavor,
315 loose_last_column: bool,
316 ) -> Vec<usize> {
317 let mut column_widths = Vec::new();
318 let mut delimiter_cells: Option<Vec<String>> = None;
319 let mut is_header = true;
320 let mut header_last_col_width: Option<usize> = None;
321
322 for line in table_lines {
323 let cells = Self::parse_table_row_with_flavor(line, flavor);
324
325 if Self::is_delimiter_row(&cells) {
327 delimiter_cells = Some(cells);
328 is_header = false;
329 continue;
330 }
331
332 for (i, cell) in cells.iter().enumerate() {
333 let width = Self::calculate_cell_display_width(cell);
334 if i >= column_widths.len() {
335 column_widths.push(width);
336 } else {
337 column_widths[i] = column_widths[i].max(width);
338 }
339 }
340
341 if is_header && !cells.is_empty() {
343 let last_idx = cells.len() - 1;
344 header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
345 is_header = false;
346 }
347 }
348
349 if loose_last_column
351 && let Some(header_width) = header_last_col_width
352 && let Some(last) = column_widths.last_mut()
353 {
354 *last = header_width;
355 }
356
357 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
360
361 if let Some(delimiter_cells) = delimiter_cells {
364 for (i, cell) in delimiter_cells.iter().enumerate() {
365 if i < final_widths.len() {
366 let trimmed = cell.trim();
367 let has_left_colon = trimmed.starts_with(':');
368 let has_right_colon = trimmed.ends_with(':');
369 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
370
371 let min_width_for_delimiter = 3 + colon_count;
373 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
374 }
375 }
376 }
377
378 final_widths
379 }
380
381 fn format_table_row(
382 cells: &[String],
383 column_widths: &[usize],
384 column_alignments: &[ColumnAlignment],
385 options: &RowFormatOptions,
386 ) -> String {
387 let formatted_cells: Vec<String> = cells
388 .iter()
389 .enumerate()
390 .map(|(i, cell)| {
391 let target_width = column_widths.get(i).copied().unwrap_or(0);
392
393 match options.row_type {
394 RowType::Delimiter => {
395 let trimmed = cell.trim();
396 let has_left_colon = trimmed.starts_with(':');
397 let has_right_colon = trimmed.ends_with(':');
398
399 let extra_width = if options.compact_delimiter { 2 } else { 0 };
403 let dash_count = if has_left_colon && has_right_colon {
404 (target_width + extra_width).saturating_sub(2)
405 } else if has_left_colon || has_right_colon {
406 (target_width + extra_width).saturating_sub(1)
407 } else {
408 target_width + extra_width
409 };
410
411 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
413 format!(":{dashes}:")
414 } else if has_left_colon {
415 format!(":{dashes}")
416 } else if has_right_colon {
417 format!("{dashes}:")
418 } else {
419 dashes
420 };
421
422 if options.compact_delimiter {
424 delimiter_content
425 } else {
426 format!(" {delimiter_content} ")
427 }
428 }
429 RowType::Header | RowType::Body => {
430 let trimmed = cell.trim();
431 let current_width = Self::calculate_cell_display_width(cell);
432 let padding = target_width.saturating_sub(current_width);
433
434 let effective_align = match options.row_type {
436 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
437 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
438 RowType::Delimiter => unreachable!(),
439 };
440
441 let alignment = match effective_align {
443 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
444 ColumnAlign::Left => ColumnAlignment::Left,
445 ColumnAlign::Center => ColumnAlignment::Center,
446 ColumnAlign::Right => ColumnAlignment::Right,
447 };
448
449 match alignment {
450 ColumnAlignment::Left => {
451 format!(" {trimmed}{} ", " ".repeat(padding))
453 }
454 ColumnAlignment::Center => {
455 let left_padding = padding / 2;
457 let right_padding = padding - left_padding;
458 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
459 }
460 ColumnAlignment::Right => {
461 format!(" {}{trimmed} ", " ".repeat(padding))
463 }
464 }
465 }
466 }
467 })
468 .collect();
469
470 format!("|{}|", formatted_cells.join("|"))
471 }
472
473 fn format_table_compact(cells: &[String]) -> String {
474 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
475 format!("|{}|", formatted_cells.join("|"))
476 }
477
478 fn format_table_tight(cells: &[String]) -> String {
479 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
480 format!("|{}|", formatted_cells.join("|"))
481 }
482
483 fn is_table_already_aligned(
495 table_lines: &[&str],
496 flavor: crate::config::MarkdownFlavor,
497 compact_delimiter: bool,
498 ) -> bool {
499 if table_lines.len() < 2 {
500 return false;
501 }
502
503 let first_width = UnicodeWidthStr::width(table_lines[0]);
507 if !table_lines
508 .iter()
509 .all(|line| UnicodeWidthStr::width(*line) == first_width)
510 {
511 return false;
512 }
513
514 let parsed: Vec<Vec<String>> = table_lines
516 .iter()
517 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
518 .collect();
519
520 if parsed.is_empty() {
521 return false;
522 }
523
524 let num_columns = parsed[0].len();
525 if !parsed.iter().all(|row| row.len() == num_columns) {
526 return false;
527 }
528
529 if let Some(delimiter_row) = parsed.get(1) {
532 if !Self::is_delimiter_row(delimiter_row) {
533 return false;
534 }
535 for cell in delimiter_row {
537 let trimmed = cell.trim();
538 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
539 if dash_count < 1 {
540 return false;
541 }
542 }
543
544 let delimiter_has_spaces = delimiter_row
548 .iter()
549 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
550
551 if compact_delimiter && delimiter_has_spaces {
554 return false;
555 }
556 if !compact_delimiter && !delimiter_has_spaces {
557 return false;
558 }
559 }
560
561 for col_idx in 0..num_columns {
565 let mut widths = Vec::new();
566 for (row_idx, row) in parsed.iter().enumerate() {
567 if row_idx == 1 {
569 continue;
570 }
571 if let Some(cell) = row.get(col_idx) {
572 widths.push(cell.width());
573 }
574 }
575 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
577 return false;
578 }
579 }
580
581 if let Some(delimiter_row) = parsed.get(1) {
586 let alignments = Self::parse_column_alignments(delimiter_row);
587 for (col_idx, alignment) in alignments.iter().enumerate() {
588 if *alignment == ColumnAlignment::Left {
589 continue;
590 }
591 for (row_idx, row) in parsed.iter().enumerate() {
592 if row_idx == 1 {
594 continue;
595 }
596 if let Some(cell) = row.get(col_idx) {
597 if cell.trim().is_empty() {
598 continue;
599 }
600 let left_pad = cell.len() - cell.trim_start().len();
602 let right_pad = cell.len() - cell.trim_end().len();
603
604 match alignment {
605 ColumnAlignment::Center => {
606 if left_pad.abs_diff(right_pad) > 1 {
608 return false;
609 }
610 }
611 ColumnAlignment::Right => {
612 if left_pad < right_pad {
614 return false;
615 }
616 }
617 ColumnAlignment::Left => unreachable!(),
618 }
619 }
620 }
621 }
622 }
623
624 true
625 }
626
627 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
628 if table_lines.is_empty() {
629 return None;
630 }
631
632 let mut is_tight = true;
635 let mut is_compact = true;
636
637 for line in table_lines {
638 let cells = Self::parse_table_row_with_flavor(line, flavor);
639
640 if cells.is_empty() {
641 continue;
642 }
643
644 if Self::is_delimiter_row(&cells) {
646 continue;
647 }
648
649 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
651
652 let row_has_single_space = cells.iter().all(|cell| {
654 let trimmed = cell.trim();
655 cell == &format!(" {trimmed} ")
656 });
657
658 if !row_has_no_padding {
660 is_tight = false;
661 }
662
663 if !row_has_single_space {
665 is_compact = false;
666 }
667
668 if !is_tight && !is_compact {
670 return Some("aligned".to_string());
671 }
672 }
673
674 if is_tight {
676 Some("tight".to_string())
677 } else if is_compact {
678 Some("compact".to_string())
679 } else {
680 Some("aligned".to_string())
681 }
682 }
683
684 fn fix_table_block(
685 &self,
686 lines: &[&str],
687 table_block: &crate::utils::table_utils::TableBlock,
688 flavor: crate::config::MarkdownFlavor,
689 ) -> TableFormatResult {
690 let mut result = Vec::new();
691 let mut auto_compacted = false;
692 let mut aligned_width = None;
693
694 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
695 .chain(std::iter::once(lines[table_block.delimiter_line]))
696 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
697 .collect();
698
699 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
700 return TableFormatResult {
701 lines: table_lines.iter().map(std::string::ToString::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(std::string::ToString::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(std::string::ToString::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(std::string::ToString::to_string).collect(),
868 auto_compacted: false,
869 aligned_width: None,
870 };
871 }
872 }
873
874 let prefixed_result: Vec<String> = result
876 .into_iter()
877 .enumerate()
878 .map(|(i, line)| {
879 if list_context.is_some() {
880 if i == 0 {
881 format!("{blockquote_prefix}{list_prefix}{line}")
883 } else {
884 format!("{blockquote_prefix}{continuation_indent}{line}")
886 }
887 } else {
888 format!("{blockquote_prefix}{line}")
889 }
890 })
891 .collect();
892
893 TableFormatResult {
894 lines: prefixed_result,
895 auto_compacted,
896 aligned_width,
897 }
898 }
899}
900
901impl Rule for MD060TableFormat {
902 fn name(&self) -> &'static str {
903 "MD060"
904 }
905
906 fn description(&self) -> &'static str {
907 "Table columns should be consistently aligned"
908 }
909
910 fn category(&self) -> RuleCategory {
911 RuleCategory::Table
912 }
913
914 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
915 !ctx.likely_has_tables()
916 }
917
918 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
919 let line_index = &ctx.line_index;
920 let mut warnings = Vec::new();
921
922 let lines = ctx.raw_lines();
923 let table_blocks = &ctx.table_blocks;
924
925 for table_block in table_blocks {
926 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
927
928 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
929 .chain(std::iter::once(table_block.delimiter_line))
930 .chain(table_block.content_lines.iter().copied())
931 .collect();
932
933 let table_start_line = table_block.start_line + 1; let table_end_line = table_block.end_line + 1; let mut fixed_table_lines: Vec<String> = Vec::with_capacity(table_line_indices.len());
940 for (i, &line_idx) in table_line_indices.iter().enumerate() {
941 let fixed_line = &format_result.lines[i];
942 if line_idx < lines.len() - 1 {
944 fixed_table_lines.push(format!("{fixed_line}\n"));
945 } else {
946 fixed_table_lines.push(fixed_line.clone());
947 }
948 }
949 let table_replacement = fixed_table_lines.concat();
950 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
951
952 for (i, &line_idx) in table_line_indices.iter().enumerate() {
953 let original = lines[line_idx];
954 let fixed = &format_result.lines[i];
955
956 if original != fixed {
957 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
958
959 let message = if format_result.auto_compacted {
960 if let Some(width) = format_result.aligned_width {
961 format!(
962 "Table too wide for aligned formatting ({} chars > max-width: {})",
963 width,
964 self.effective_max_width()
965 )
966 } else {
967 "Table too wide for aligned formatting".to_string()
968 }
969 } else {
970 "Table columns should be aligned".to_string()
971 };
972
973 warnings.push(LintWarning {
976 rule_name: Some(self.name().to_string()),
977 severity: Severity::Warning,
978 message,
979 line: start_line,
980 column: start_col,
981 end_line,
982 end_column: end_col,
983 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
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 let any_disabled = table_line_indices
1010 .iter()
1011 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1012
1013 if any_disabled {
1014 continue;
1015 }
1016
1017 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1018 result_lines[line_idx].clone_from(&format_result.lines[i]);
1019 }
1020 }
1021
1022 let mut fixed = result_lines.join("\n");
1023 if content.ends_with('\n') && !fixed.ends_with('\n') {
1024 fixed.push('\n');
1025 }
1026 Ok(fixed)
1027 }
1028
1029 fn as_any(&self) -> &dyn std::any::Any {
1030 self
1031 }
1032
1033 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1034 let table = crate::rule_config_serde::config_schema_table(&MD060Config::default())?;
1035 Some((MD060Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1036 }
1037
1038 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1039 where
1040 Self: Sized,
1041 {
1042 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1043 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1044
1045 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1047
1048 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1049 }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use crate::lint_context::LintContext;
1056 use crate::types::LineLength;
1057
1058 fn md013_with_line_length(line_length: usize) -> MD013Config {
1060 MD013Config {
1061 line_length: LineLength::from_const(line_length),
1062 tables: true, ..Default::default()
1064 }
1065 }
1066
1067 #[test]
1068 fn test_md060_align_simple_ascii_table() {
1069 let rule = MD060TableFormat::new(true, "aligned".to_string());
1070
1071 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073
1074 let fixed = rule.fix(&ctx).unwrap();
1075 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1076 assert_eq!(fixed, expected);
1077
1078 let lines: Vec<&str> = fixed.lines().collect();
1080 assert_eq!(lines[0].len(), lines[1].len());
1081 assert_eq!(lines[1].len(), lines[2].len());
1082 }
1083
1084 #[test]
1085 fn test_md060_cjk_characters_aligned_correctly() {
1086 let rule = MD060TableFormat::new(true, "aligned".to_string());
1087
1088 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090
1091 let fixed = rule.fix(&ctx).unwrap();
1092
1093 let lines: Vec<&str> = fixed.lines().collect();
1094 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1095 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1096
1097 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1098 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1099
1100 assert_eq!(width1, width3);
1101 }
1102
1103 #[test]
1104 fn test_md060_basic_emoji() {
1105 let rule = MD060TableFormat::new(true, "aligned".to_string());
1106
1107 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109
1110 let fixed = rule.fix(&ctx).unwrap();
1111 assert!(fixed.contains("Status"));
1112 }
1113
1114 #[test]
1115 fn test_md060_zwj_emoji_skipped() {
1116 let rule = MD060TableFormat::new(true, "aligned".to_string());
1117
1118 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120
1121 let fixed = rule.fix(&ctx).unwrap();
1122 assert_eq!(fixed, content);
1123 }
1124
1125 #[test]
1126 fn test_md060_inline_code_with_escaped_pipes() {
1127 let rule = MD060TableFormat::new(true, "aligned".to_string());
1130
1131 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134
1135 let fixed = rule.fix(&ctx).unwrap();
1136 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1137 }
1138
1139 #[test]
1140 fn test_md060_compact_style() {
1141 let rule = MD060TableFormat::new(true, "compact".to_string());
1142
1143 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145
1146 let fixed = rule.fix(&ctx).unwrap();
1147 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1148 assert_eq!(fixed, expected);
1149 }
1150
1151 #[test]
1152 fn test_md060_tight_style() {
1153 let rule = MD060TableFormat::new(true, "tight".to_string());
1154
1155 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157
1158 let fixed = rule.fix(&ctx).unwrap();
1159 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1160 assert_eq!(fixed, expected);
1161 }
1162
1163 #[test]
1164 fn test_md060_aligned_no_space_style() {
1165 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1167
1168 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170
1171 let fixed = rule.fix(&ctx).unwrap();
1172
1173 let lines: Vec<&str> = fixed.lines().collect();
1175 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1176 assert_eq!(
1177 lines[1], "|-------|-----|",
1178 "Delimiter should have NO spaces around dashes"
1179 );
1180 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1181
1182 assert_eq!(lines[0].len(), lines[1].len());
1184 assert_eq!(lines[1].len(), lines[2].len());
1185 }
1186
1187 #[test]
1188 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1189 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1191
1192 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194
1195 let fixed = rule.fix(&ctx).unwrap();
1196 let lines: Vec<&str> = fixed.lines().collect();
1197
1198 assert!(
1200 fixed.contains("|:"),
1201 "Should have left alignment indicator adjacent to pipe"
1202 );
1203 assert!(
1204 fixed.contains(":|"),
1205 "Should have right alignment indicator adjacent to pipe"
1206 );
1207 assert!(
1209 lines[1].contains(":---") && lines[1].contains("---:"),
1210 "Should have center alignment colons"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_md060_aligned_no_space_three_column_table() {
1216 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1218
1219 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 |";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221
1222 let fixed = rule.fix(&ctx).unwrap();
1223 let lines: Vec<&str> = fixed.lines().collect();
1224
1225 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1227 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1228 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1229 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1230 }
1231
1232 #[test]
1233 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1234 let config = MD060Config {
1236 enabled: true,
1237 style: "aligned-no-space".to_string(),
1238 max_width: LineLength::from_const(50),
1239 column_align: ColumnAlign::Auto,
1240 column_align_header: None,
1241 column_align_body: None,
1242 loose_last_column: false,
1243 };
1244 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1245
1246 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249
1250 let fixed = rule.fix(&ctx).unwrap();
1251
1252 assert!(
1254 fixed.contains("| --- |"),
1255 "Should be compact format when exceeding max-width"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_md060_aligned_no_space_cjk_characters() {
1261 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1263
1264 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266
1267 let fixed = rule.fix(&ctx).unwrap();
1268 let lines: Vec<&str> = fixed.lines().collect();
1269
1270 use unicode_width::UnicodeWidthStr;
1273 assert_eq!(
1274 lines[0].width(),
1275 lines[1].width(),
1276 "Header and delimiter should have same display width"
1277 );
1278 assert_eq!(
1279 lines[1].width(),
1280 lines[2].width(),
1281 "Delimiter and content should have same display width"
1282 );
1283
1284 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1286 }
1287
1288 #[test]
1289 fn test_md060_aligned_no_space_minimum_width() {
1290 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1292
1293 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295
1296 let fixed = rule.fix(&ctx).unwrap();
1297 let lines: Vec<&str> = fixed.lines().collect();
1298
1299 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1301 assert_eq!(lines[0].len(), lines[1].len());
1303 assert_eq!(lines[1].len(), lines[2].len());
1304 }
1305
1306 #[test]
1307 fn test_md060_any_style_consistency() {
1308 let rule = MD060TableFormat::new(true, "any".to_string());
1309
1310 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313
1314 let fixed = rule.fix(&ctx).unwrap();
1315 assert_eq!(fixed, content);
1316
1317 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1319 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1320
1321 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1322 assert_eq!(fixed_aligned, content_aligned);
1323 }
1324
1325 #[test]
1326 fn test_md060_empty_cells() {
1327 let rule = MD060TableFormat::new(true, "aligned".to_string());
1328
1329 let content = "| A | B |\n|---|---|\n| | X |";
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331
1332 let fixed = rule.fix(&ctx).unwrap();
1333 assert!(fixed.contains('|'));
1334 }
1335
1336 #[test]
1337 fn test_md060_mixed_content() {
1338 let rule = MD060TableFormat::new(true, "aligned".to_string());
1339
1340 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1342
1343 let fixed = rule.fix(&ctx).unwrap();
1344 assert!(fixed.contains("δΈζ"));
1345 assert!(fixed.contains("NYC"));
1346 }
1347
1348 #[test]
1349 fn test_md060_preserve_alignment_indicators() {
1350 let rule = MD060TableFormat::new(true, "aligned".to_string());
1351
1352 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354
1355 let fixed = rule.fix(&ctx).unwrap();
1356
1357 assert!(fixed.contains(":---"), "Should contain left alignment");
1358 assert!(fixed.contains(":----:"), "Should contain center alignment");
1359 assert!(fixed.contains("----:"), "Should contain right alignment");
1360 }
1361
1362 #[test]
1363 fn test_md060_minimum_column_width() {
1364 let rule = MD060TableFormat::new(true, "aligned".to_string());
1365
1366 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370
1371 let fixed = rule.fix(&ctx).unwrap();
1372
1373 let lines: Vec<&str> = fixed.lines().collect();
1374 assert_eq!(lines[0].len(), lines[1].len());
1375 assert_eq!(lines[1].len(), lines[2].len());
1376
1377 assert!(fixed.contains("ID "), "Short content should be padded");
1379 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1380 }
1381
1382 #[test]
1383 fn test_md060_auto_compact_exceeds_default_threshold() {
1384 let config = MD060Config {
1386 enabled: true,
1387 style: "aligned".to_string(),
1388 max_width: LineLength::from_const(0),
1389 column_align: ColumnAlign::Auto,
1390 column_align_header: None,
1391 column_align_body: None,
1392 loose_last_column: false,
1393 };
1394 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1395
1396 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401
1402 let fixed = rule.fix(&ctx).unwrap();
1403
1404 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1406 assert!(fixed.contains("| --- | --- | --- |"));
1407 assert!(fixed.contains("| Short | Data | Here |"));
1408
1409 let lines: Vec<&str> = fixed.lines().collect();
1411 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1413 }
1414
1415 #[test]
1416 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1417 let config = MD060Config {
1419 enabled: true,
1420 style: "aligned".to_string(),
1421 max_width: LineLength::from_const(50),
1422 column_align: ColumnAlign::Auto,
1423 column_align_header: None,
1424 column_align_body: None,
1425 loose_last_column: false,
1426 };
1427 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 |";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1434
1435 let fixed = rule.fix(&ctx).unwrap();
1436
1437 assert!(
1439 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1440 );
1441 assert!(fixed.contains("| --- | --- | --- |"));
1442 assert!(fixed.contains("| Data | Data | Data |"));
1443
1444 let lines: Vec<&str> = fixed.lines().collect();
1446 assert!(lines[0].len() != lines[2].len());
1447 }
1448
1449 #[test]
1450 fn test_md060_stays_aligned_under_threshold() {
1451 let config = MD060Config {
1453 enabled: true,
1454 style: "aligned".to_string(),
1455 max_width: LineLength::from_const(100),
1456 column_align: ColumnAlign::Auto,
1457 column_align_header: None,
1458 column_align_body: None,
1459 loose_last_column: false,
1460 };
1461 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1462
1463 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466
1467 let fixed = rule.fix(&ctx).unwrap();
1468
1469 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1471 assert_eq!(fixed, expected);
1472
1473 let lines: Vec<&str> = fixed.lines().collect();
1474 assert_eq!(lines[0].len(), lines[1].len());
1475 assert_eq!(lines[1].len(), lines[2].len());
1476 }
1477
1478 #[test]
1479 fn test_md060_width_calculation_formula() {
1480 let config = MD060Config {
1482 enabled: true,
1483 style: "aligned".to_string(),
1484 max_width: LineLength::from_const(0),
1485 column_align: ColumnAlign::Auto,
1486 column_align_header: None,
1487 column_align_body: None,
1488 loose_last_column: false,
1489 };
1490 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1491
1492 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497
1498 let fixed = rule.fix(&ctx).unwrap();
1499
1500 let lines: Vec<&str> = fixed.lines().collect();
1502 assert_eq!(lines[0].len(), lines[1].len());
1503 assert_eq!(lines[1].len(), lines[2].len());
1504 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1508 enabled: true,
1509 style: "aligned".to_string(),
1510 max_width: LineLength::from_const(24),
1511 column_align: ColumnAlign::Auto,
1512 column_align_header: None,
1513 column_align_body: None,
1514 loose_last_column: false,
1515 };
1516 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1517
1518 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1519
1520 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1522 assert!(fixed_compact.contains("| --- | --- | --- |"));
1523 }
1524
1525 #[test]
1526 fn test_md060_very_wide_table_auto_compacts() {
1527 let config = MD060Config {
1528 enabled: true,
1529 style: "aligned".to_string(),
1530 max_width: LineLength::from_const(0),
1531 column_align: ColumnAlign::Auto,
1532 column_align_header: None,
1533 column_align_body: None,
1534 loose_last_column: false,
1535 };
1536 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1537
1538 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 |";
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1543
1544 let fixed = rule.fix(&ctx).unwrap();
1545
1546 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1548 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1549 }
1550
1551 #[test]
1552 fn test_md060_inherit_from_md013_line_length() {
1553 let config = MD060Config {
1555 enabled: true,
1556 style: "aligned".to_string(),
1557 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1559 column_align_header: None,
1560 column_align_body: None,
1561 loose_last_column: false,
1562 };
1563
1564 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1566 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1567
1568 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571
1572 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1574
1575 let fixed_120 = rule_120.fix(&ctx).unwrap();
1577
1578 let lines_120: Vec<&str> = fixed_120.lines().collect();
1580 assert_eq!(lines_120[0].len(), lines_120[1].len());
1581 assert_eq!(lines_120[1].len(), lines_120[2].len());
1582 }
1583
1584 #[test]
1585 fn test_md060_edge_case_exactly_at_threshold() {
1586 let config = MD060Config {
1590 enabled: true,
1591 style: "aligned".to_string(),
1592 max_width: LineLength::from_const(17),
1593 column_align: ColumnAlign::Auto,
1594 column_align_header: None,
1595 column_align_body: None,
1596 loose_last_column: false,
1597 };
1598 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1599
1600 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602
1603 let fixed = rule.fix(&ctx).unwrap();
1604
1605 let lines: Vec<&str> = fixed.lines().collect();
1607 assert_eq!(lines[0].len(), 17);
1608 assert_eq!(lines[0].len(), lines[1].len());
1609 assert_eq!(lines[1].len(), lines[2].len());
1610
1611 let config_under = MD060Config {
1613 enabled: true,
1614 style: "aligned".to_string(),
1615 max_width: LineLength::from_const(16),
1616 column_align: ColumnAlign::Auto,
1617 column_align_header: None,
1618 column_align_body: None,
1619 loose_last_column: false,
1620 };
1621 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1622
1623 let fixed_compact = rule_under.fix(&ctx).unwrap();
1624
1625 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1627 assert!(fixed_compact.contains("| --- | --- |"));
1628 }
1629
1630 #[test]
1631 fn test_md060_auto_compact_warning_message() {
1632 let config = MD060Config {
1634 enabled: true,
1635 style: "aligned".to_string(),
1636 max_width: LineLength::from_const(50),
1637 column_align: ColumnAlign::Auto,
1638 column_align_header: None,
1639 column_align_body: None,
1640 loose_last_column: false,
1641 };
1642 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1643
1644 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1647
1648 let warnings = rule.check(&ctx).unwrap();
1649
1650 assert!(!warnings.is_empty(), "Should generate warnings");
1652
1653 let auto_compact_warnings: Vec<_> = warnings
1654 .iter()
1655 .filter(|w| w.message.contains("too wide for aligned formatting"))
1656 .collect();
1657
1658 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1659
1660 let first_warning = auto_compact_warnings[0];
1662 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1663 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1664 }
1665
1666 #[test]
1667 fn test_md060_issue_129_detect_style_from_all_rows() {
1668 let rule = MD060TableFormat::new(true, "any".to_string());
1672
1673 let content = "| a long heading | another long heading |\n\
1675 | -------------- | -------------------- |\n\
1676 | a | 1 |\n\
1677 | b b | 2 |\n\
1678 | c c c | 3 |";
1679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680
1681 let fixed = rule.fix(&ctx).unwrap();
1682
1683 assert!(
1685 fixed.contains("| a | 1 |"),
1686 "Should preserve aligned padding in first content row"
1687 );
1688 assert!(
1689 fixed.contains("| b b | 2 |"),
1690 "Should preserve aligned padding in second content row"
1691 );
1692 assert!(
1693 fixed.contains("| c c c | 3 |"),
1694 "Should preserve aligned padding in third content row"
1695 );
1696
1697 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1699 }
1700
1701 #[test]
1702 fn test_md060_regular_alignment_warning_message() {
1703 let config = MD060Config {
1705 enabled: true,
1706 style: "aligned".to_string(),
1707 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1709 column_align_header: None,
1710 column_align_body: None,
1711 loose_last_column: false,
1712 };
1713 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1714
1715 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718
1719 let warnings = rule.check(&ctx).unwrap();
1720
1721 assert!(!warnings.is_empty(), "Should generate warnings");
1723
1724 assert!(warnings[0].message.contains("Table columns should be aligned"));
1726 assert!(!warnings[0].message.contains("too wide"));
1727 assert!(!warnings[0].message.contains("max-width"));
1728 }
1729
1730 #[test]
1733 fn test_md060_unlimited_when_md013_disabled() {
1734 let config = MD060Config {
1736 enabled: true,
1737 style: "aligned".to_string(),
1738 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1740 column_align_header: None,
1741 column_align_body: None,
1742 loose_last_column: false,
1743 };
1744 let md013_config = MD013Config::default();
1745 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1746
1747 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750 let fixed = rule.fix(&ctx).unwrap();
1751
1752 let lines: Vec<&str> = fixed.lines().collect();
1754 assert_eq!(
1756 lines[0].len(),
1757 lines[1].len(),
1758 "Table should be aligned when MD013 is disabled"
1759 );
1760 }
1761
1762 #[test]
1763 fn test_md060_unlimited_when_md013_tables_false() {
1764 let config = MD060Config {
1766 enabled: true,
1767 style: "aligned".to_string(),
1768 max_width: LineLength::from_const(0),
1769 column_align: ColumnAlign::Auto,
1770 column_align_header: None,
1771 column_align_body: None,
1772 loose_last_column: false,
1773 };
1774 let md013_config = MD013Config {
1775 tables: false, line_length: LineLength::from_const(80),
1777 ..Default::default()
1778 };
1779 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1780
1781 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784 let fixed = rule.fix(&ctx).unwrap();
1785
1786 let lines: Vec<&str> = fixed.lines().collect();
1788 assert_eq!(
1789 lines[0].len(),
1790 lines[1].len(),
1791 "Table should be aligned when MD013.tables=false"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_md060_unlimited_when_md013_line_length_zero() {
1797 let config = MD060Config {
1799 enabled: true,
1800 style: "aligned".to_string(),
1801 max_width: LineLength::from_const(0),
1802 column_align: ColumnAlign::Auto,
1803 column_align_header: None,
1804 column_align_body: None,
1805 loose_last_column: false,
1806 };
1807 let md013_config = MD013Config {
1808 tables: true,
1809 line_length: LineLength::from_const(0), ..Default::default()
1811 };
1812 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1813
1814 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let fixed = rule.fix(&ctx).unwrap();
1818
1819 let lines: Vec<&str> = fixed.lines().collect();
1821 assert_eq!(
1822 lines[0].len(),
1823 lines[1].len(),
1824 "Table should be aligned when MD013.line_length=0"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_md060_explicit_max_width_overrides_md013_settings() {
1830 let config = MD060Config {
1832 enabled: true,
1833 style: "aligned".to_string(),
1834 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1836 column_align_header: None,
1837 column_align_body: None,
1838 loose_last_column: false,
1839 };
1840 let md013_config = MD013Config {
1841 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1844 };
1845 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1846
1847 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let fixed = rule.fix(&ctx).unwrap();
1851
1852 assert!(
1854 fixed.contains("| --- |"),
1855 "Should be compact format due to explicit max_width"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1861 let config = MD060Config {
1863 enabled: true,
1864 style: "aligned".to_string(),
1865 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1867 column_align_header: None,
1868 column_align_body: None,
1869 loose_last_column: false,
1870 };
1871 let md013_config = MD013Config {
1872 tables: true,
1873 line_length: LineLength::from_const(50), ..Default::default()
1875 };
1876 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1877
1878 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1881 let fixed = rule.fix(&ctx).unwrap();
1882
1883 assert!(
1885 fixed.contains("| --- |"),
1886 "Should be compact format when inheriting MD013 limit"
1887 );
1888 }
1889
1890 #[test]
1893 fn test_aligned_no_space_reformats_spaced_delimiter() {
1894 let config = MD060Config {
1897 enabled: true,
1898 style: "aligned-no-space".to_string(),
1899 max_width: LineLength::from_const(0),
1900 column_align: ColumnAlign::Auto,
1901 column_align_header: None,
1902 column_align_body: None,
1903 loose_last_column: false,
1904 };
1905 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1906
1907 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let fixed = rule.fix(&ctx).unwrap();
1911
1912 assert!(
1915 !fixed.contains("| ----"),
1916 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1917 );
1918 assert!(
1919 !fixed.contains("---- |"),
1920 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1921 );
1922 assert!(
1924 fixed.contains("|----"),
1925 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1926 );
1927 }
1928
1929 #[test]
1930 fn test_aligned_reformats_compact_delimiter() {
1931 let config = MD060Config {
1934 enabled: true,
1935 style: "aligned".to_string(),
1936 max_width: LineLength::from_const(0),
1937 column_align: ColumnAlign::Auto,
1938 column_align_header: None,
1939 column_align_body: None,
1940 loose_last_column: false,
1941 };
1942 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1943
1944 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let fixed = rule.fix(&ctx).unwrap();
1948
1949 assert!(
1951 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1952 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1953 );
1954 }
1955
1956 #[test]
1957 fn test_aligned_no_space_preserves_matching_table() {
1958 let config = MD060Config {
1960 enabled: true,
1961 style: "aligned-no-space".to_string(),
1962 max_width: LineLength::from_const(0),
1963 column_align: ColumnAlign::Auto,
1964 column_align_header: None,
1965 column_align_body: None,
1966 loose_last_column: false,
1967 };
1968 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1969
1970 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973 let fixed = rule.fix(&ctx).unwrap();
1974
1975 assert_eq!(
1977 fixed, content,
1978 "Table already in aligned-no-space style should be preserved"
1979 );
1980 }
1981
1982 #[test]
1983 fn test_aligned_preserves_matching_table() {
1984 let config = MD060Config {
1986 enabled: true,
1987 style: "aligned".to_string(),
1988 max_width: LineLength::from_const(0),
1989 column_align: ColumnAlign::Auto,
1990 column_align_header: None,
1991 column_align_body: None,
1992 loose_last_column: false,
1993 };
1994 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1995
1996 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 let fixed = rule.fix(&ctx).unwrap();
2000
2001 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2003 }
2004
2005 #[test]
2006 fn test_cjk_table_display_width_consistency() {
2007 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2013
2014 let is_aligned =
2016 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2017 assert!(
2018 !is_aligned,
2019 "Table with uneven raw line lengths should NOT be considered aligned"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_cjk_width_calculation_in_aligned_check() {
2025 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2028 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2029
2030 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2031 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2032
2033 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2035 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2036
2037 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2039 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2041 }
2042
2043 #[test]
2046 fn test_md060_column_align_left() {
2047 let config = MD060Config {
2049 enabled: true,
2050 style: "aligned".to_string(),
2051 max_width: LineLength::from_const(0),
2052 column_align: ColumnAlign::Left,
2053 column_align_header: None,
2054 column_align_body: None,
2055 loose_last_column: false,
2056 };
2057 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2058
2059 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2061
2062 let fixed = rule.fix(&ctx).unwrap();
2063 let lines: Vec<&str> = fixed.lines().collect();
2064
2065 assert!(
2067 lines[2].contains("| Alice "),
2068 "Content should be left-aligned (Alice should have trailing padding)"
2069 );
2070 assert!(
2071 lines[3].contains("| Bob "),
2072 "Content should be left-aligned (Bob should have trailing padding)"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_md060_column_align_center() {
2078 let config = MD060Config {
2080 enabled: true,
2081 style: "aligned".to_string(),
2082 max_width: LineLength::from_const(0),
2083 column_align: ColumnAlign::Center,
2084 column_align_header: None,
2085 column_align_body: None,
2086 loose_last_column: false,
2087 };
2088 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2089
2090 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2092
2093 let fixed = rule.fix(&ctx).unwrap();
2094 let lines: Vec<&str> = fixed.lines().collect();
2095
2096 assert!(
2099 lines[3].contains("| Bob |"),
2100 "Bob should be centered with padding on both sides. Got: {}",
2101 lines[3]
2102 );
2103 }
2104
2105 #[test]
2106 fn test_md060_column_align_right() {
2107 let config = MD060Config {
2109 enabled: true,
2110 style: "aligned".to_string(),
2111 max_width: LineLength::from_const(0),
2112 column_align: ColumnAlign::Right,
2113 column_align_header: None,
2114 column_align_body: None,
2115 loose_last_column: false,
2116 };
2117 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2118
2119 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121
2122 let fixed = rule.fix(&ctx).unwrap();
2123 let lines: Vec<&str> = fixed.lines().collect();
2124
2125 assert!(
2127 lines[3].contains("| Bob |"),
2128 "Bob should be right-aligned with padding on left. Got: {}",
2129 lines[3]
2130 );
2131 }
2132
2133 #[test]
2134 fn test_md060_column_align_auto_respects_delimiter() {
2135 let config = MD060Config {
2137 enabled: true,
2138 style: "aligned".to_string(),
2139 max_width: LineLength::from_const(0),
2140 column_align: ColumnAlign::Auto,
2141 column_align_header: None,
2142 column_align_body: None,
2143 loose_last_column: false,
2144 };
2145 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2146
2147 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2150
2151 let fixed = rule.fix(&ctx).unwrap();
2152
2153 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2155 let lines: Vec<&str> = fixed.lines().collect();
2157 assert!(
2161 lines[2].contains(" C |"),
2162 "Right column should be right-aligned. Got: {}",
2163 lines[2]
2164 );
2165 }
2166
2167 #[test]
2168 fn test_md060_column_align_overrides_delimiter_indicators() {
2169 let config = MD060Config {
2171 enabled: true,
2172 style: "aligned".to_string(),
2173 max_width: LineLength::from_const(0),
2174 column_align: ColumnAlign::Right, column_align_header: None,
2176 column_align_body: None,
2177 loose_last_column: false,
2178 };
2179 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2180
2181 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2184
2185 let fixed = rule.fix(&ctx).unwrap();
2186 let lines: Vec<&str> = fixed.lines().collect();
2187
2188 assert!(
2191 lines[2].contains(" A |") || lines[2].contains(" A |"),
2192 "Even left-indicated column should be right-aligned. Got: {}",
2193 lines[2]
2194 );
2195 }
2196
2197 #[test]
2198 fn test_md060_column_align_with_aligned_no_space() {
2199 let config = MD060Config {
2201 enabled: true,
2202 style: "aligned-no-space".to_string(),
2203 max_width: LineLength::from_const(0),
2204 column_align: ColumnAlign::Center,
2205 column_align_header: None,
2206 column_align_body: None,
2207 loose_last_column: false,
2208 };
2209 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2210
2211 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2213
2214 let fixed = rule.fix(&ctx).unwrap();
2215 let lines: Vec<&str> = fixed.lines().collect();
2216
2217 assert!(
2219 lines[1].contains("|---"),
2220 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2221 lines[1]
2222 );
2223 assert!(
2225 lines[3].contains("| Bob |"),
2226 "Content should be centered. Got: {}",
2227 lines[3]
2228 );
2229 }
2230
2231 #[test]
2232 fn test_md060_column_align_config_parsing() {
2233 let toml_str = r#"
2235enabled = true
2236style = "aligned"
2237column-align = "center"
2238"#;
2239 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2240 assert_eq!(config.column_align, ColumnAlign::Center);
2241
2242 let toml_str = r#"
2243enabled = true
2244style = "aligned"
2245column-align = "right"
2246"#;
2247 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2248 assert_eq!(config.column_align, ColumnAlign::Right);
2249
2250 let toml_str = r#"
2251enabled = true
2252style = "aligned"
2253column-align = "left"
2254"#;
2255 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2256 assert_eq!(config.column_align, ColumnAlign::Left);
2257
2258 let toml_str = r#"
2259enabled = true
2260style = "aligned"
2261column-align = "auto"
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_default_is_auto() {
2269 let toml_str = r#"
2271enabled = true
2272style = "aligned"
2273"#;
2274 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2275 assert_eq!(config.column_align, ColumnAlign::Auto);
2276 }
2277
2278 #[test]
2279 fn test_md060_column_align_reformats_already_aligned_table() {
2280 let config = MD060Config {
2282 enabled: true,
2283 style: "aligned".to_string(),
2284 max_width: LineLength::from_const(0),
2285 column_align: ColumnAlign::Right,
2286 column_align_header: None,
2287 column_align_body: None,
2288 loose_last_column: false,
2289 };
2290 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2291
2292 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2295
2296 let fixed = rule.fix(&ctx).unwrap();
2297 let lines: Vec<&str> = fixed.lines().collect();
2298
2299 assert!(
2301 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2302 "Already aligned table should be reformatted with right alignment. Got: {}",
2303 lines[2]
2304 );
2305 assert!(
2306 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2307 "Bob should be right-aligned. Got: {}",
2308 lines[3]
2309 );
2310 }
2311
2312 #[test]
2313 fn test_md060_column_align_with_cjk_characters() {
2314 let config = MD060Config {
2316 enabled: true,
2317 style: "aligned".to_string(),
2318 max_width: LineLength::from_const(0),
2319 column_align: ColumnAlign::Center,
2320 column_align_header: None,
2321 column_align_body: None,
2322 loose_last_column: false,
2323 };
2324 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2325
2326 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2328
2329 let fixed = rule.fix(&ctx).unwrap();
2330
2331 assert!(fixed.contains("Bob"), "Table should contain Bob");
2334 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2335 }
2336
2337 #[test]
2338 fn test_md060_column_align_ignored_for_compact_style() {
2339 let config = MD060Config {
2341 enabled: true,
2342 style: "compact".to_string(),
2343 max_width: LineLength::from_const(0),
2344 column_align: ColumnAlign::Right, column_align_header: None,
2346 column_align_body: None,
2347 loose_last_column: false,
2348 };
2349 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2350
2351 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2353
2354 let fixed = rule.fix(&ctx).unwrap();
2355
2356 assert!(
2358 fixed.contains("| Alice |"),
2359 "Compact style should have single space padding, not alignment. Got: {fixed}"
2360 );
2361 }
2362
2363 #[test]
2364 fn test_md060_column_align_ignored_for_tight_style() {
2365 let config = MD060Config {
2367 enabled: true,
2368 style: "tight".to_string(),
2369 max_width: LineLength::from_const(0),
2370 column_align: ColumnAlign::Center, column_align_header: None,
2372 column_align_body: None,
2373 loose_last_column: false,
2374 };
2375 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2376
2377 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2379
2380 let fixed = rule.fix(&ctx).unwrap();
2381
2382 assert!(
2384 fixed.contains("|Alice|"),
2385 "Tight style should have no spaces. Got: {fixed}"
2386 );
2387 }
2388
2389 #[test]
2390 fn test_md060_column_align_with_empty_cells() {
2391 let config = MD060Config {
2393 enabled: true,
2394 style: "aligned".to_string(),
2395 max_width: LineLength::from_const(0),
2396 column_align: ColumnAlign::Center,
2397 column_align_header: None,
2398 column_align_body: None,
2399 loose_last_column: false,
2400 };
2401 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2402
2403 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2405
2406 let fixed = rule.fix(&ctx).unwrap();
2407 let lines: Vec<&str> = fixed.lines().collect();
2408
2409 assert!(
2411 lines[3].contains("| |") || lines[3].contains("| |"),
2412 "Empty cell should be padded correctly. Got: {}",
2413 lines[3]
2414 );
2415 }
2416
2417 #[test]
2418 fn test_md060_column_align_auto_preserves_already_aligned() {
2419 let config = MD060Config {
2421 enabled: true,
2422 style: "aligned".to_string(),
2423 max_width: LineLength::from_const(0),
2424 column_align: ColumnAlign::Auto,
2425 column_align_header: None,
2426 column_align_body: None,
2427 loose_last_column: false,
2428 };
2429 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2430
2431 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2434
2435 let fixed = rule.fix(&ctx).unwrap();
2436
2437 assert_eq!(
2439 fixed, content,
2440 "Already aligned table should be preserved with column-align=auto"
2441 );
2442 }
2443
2444 #[test]
2445 fn test_cjk_table_display_aligned_not_flagged() {
2446 use crate::config::MarkdownFlavor;
2450
2451 let table_lines: Vec<&str> = vec![
2453 "| Header | Name |",
2454 "| ------ | ---- |",
2455 "| Hello | Test |",
2456 "| δ½ ε₯½ | Test |",
2457 ];
2458
2459 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2460 assert!(
2461 result,
2462 "Table with CJK characters that is display-aligned should be recognized as aligned"
2463 );
2464 }
2465
2466 #[test]
2467 fn test_cjk_table_not_reformatted_when_aligned() {
2468 let rule = MD060TableFormat::new(true, "aligned".to_string());
2470 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473
2474 let fixed = rule.fix(&ctx).unwrap();
2476 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2477 }
2478}