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 aligned_delimiter: false,
195 },
196 md013_config: MD013Config::default(),
197 md013_disabled: false,
198 }
199 }
200
201 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
202 Self {
203 config,
204 md013_config,
205 md013_disabled,
206 }
207 }
208
209 fn effective_max_width(&self) -> usize {
219 if !self.config.max_width.is_unlimited() {
221 return self.config.max_width.get();
222 }
223
224 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
229 return usize::MAX; }
231
232 self.md013_config.line_length.get()
234 }
235
236 fn contains_problematic_chars(text: &str) -> bool {
247 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
252
253 fn calculate_cell_display_width(cell_content: &str) -> usize {
254 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
255 masked.trim().width()
256 }
257
258 #[cfg(test)]
261 fn parse_table_row(line: &str) -> Vec<String> {
262 TableUtils::split_table_row(line)
263 }
264
265 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
269 TableUtils::split_table_row_with_flavor(line, flavor)
270 }
271
272 fn is_delimiter_row(row: &[String]) -> bool {
273 if row.is_empty() {
274 return false;
275 }
276 row.iter().all(|cell| {
277 let trimmed = cell.trim();
278 !trimmed.is_empty()
281 && trimmed.contains('-')
282 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
283 })
284 }
285
286 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
289 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
290 (&line[..m.end()], &line[m.end()..])
291 } else {
292 ("", line)
293 }
294 }
295
296 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
297 delimiter_row
298 .iter()
299 .map(|cell| {
300 let trimmed = cell.trim();
301 let has_left_colon = trimmed.starts_with(':');
302 let has_right_colon = trimmed.ends_with(':');
303
304 match (has_left_colon, has_right_colon) {
305 (true, true) => ColumnAlignment::Center,
306 (false, true) => ColumnAlignment::Right,
307 _ => ColumnAlignment::Left,
308 }
309 })
310 .collect()
311 }
312
313 fn calculate_column_widths(
314 table_lines: &[&str],
315 flavor: crate::config::MarkdownFlavor,
316 loose_last_column: bool,
317 ) -> Vec<usize> {
318 let mut column_widths = Vec::new();
319 let mut delimiter_cells: Option<Vec<String>> = None;
320 let mut is_header = true;
321 let mut header_last_col_width: Option<usize> = None;
322
323 for line in table_lines {
324 let cells = Self::parse_table_row_with_flavor(line, flavor);
325
326 if Self::is_delimiter_row(&cells) {
328 delimiter_cells = Some(cells);
329 is_header = false;
330 continue;
331 }
332
333 for (i, cell) in cells.iter().enumerate() {
334 let width = Self::calculate_cell_display_width(cell);
335 if i >= column_widths.len() {
336 column_widths.push(width);
337 } else {
338 column_widths[i] = column_widths[i].max(width);
339 }
340 }
341
342 if is_header && !cells.is_empty() {
344 let last_idx = cells.len() - 1;
345 header_last_col_width = Some(Self::calculate_cell_display_width(&cells[last_idx]));
346 is_header = false;
347 }
348 }
349
350 if loose_last_column
352 && let Some(header_width) = header_last_col_width
353 && let Some(last) = column_widths.last_mut()
354 {
355 *last = header_width;
356 }
357
358 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
361
362 if let Some(delimiter_cells) = delimiter_cells {
365 for (i, cell) in delimiter_cells.iter().enumerate() {
366 if i < final_widths.len() {
367 let trimmed = cell.trim();
368 let has_left_colon = trimmed.starts_with(':');
369 let has_right_colon = trimmed.ends_with(':');
370 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
371
372 let min_width_for_delimiter = 3 + colon_count;
374 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
375 }
376 }
377 }
378
379 final_widths
380 }
381
382 fn format_table_row(
383 cells: &[String],
384 column_widths: &[usize],
385 column_alignments: &[ColumnAlignment],
386 options: &RowFormatOptions,
387 ) -> String {
388 let formatted_cells: Vec<String> = cells
389 .iter()
390 .enumerate()
391 .map(|(i, cell)| {
392 let target_width = column_widths.get(i).copied().unwrap_or(0);
393
394 match options.row_type {
395 RowType::Delimiter => {
396 let trimmed = cell.trim();
397 let has_left_colon = trimmed.starts_with(':');
398 let has_right_colon = trimmed.ends_with(':');
399
400 let extra_width = if options.compact_delimiter { 2 } else { 0 };
404 let dash_count = if has_left_colon && has_right_colon {
405 (target_width + extra_width).saturating_sub(2)
406 } else if has_left_colon || has_right_colon {
407 (target_width + extra_width).saturating_sub(1)
408 } else {
409 target_width + extra_width
410 };
411
412 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
414 format!(":{dashes}:")
415 } else if has_left_colon {
416 format!(":{dashes}")
417 } else if has_right_colon {
418 format!("{dashes}:")
419 } else {
420 dashes
421 };
422
423 if options.compact_delimiter {
425 delimiter_content
426 } else {
427 format!(" {delimiter_content} ")
428 }
429 }
430 RowType::Header | RowType::Body => {
431 let trimmed = cell.trim();
432 let current_width = Self::calculate_cell_display_width(cell);
433 let padding = target_width.saturating_sub(current_width);
434
435 let effective_align = match options.row_type {
437 RowType::Header => options.column_align_header.unwrap_or(options.column_align),
438 RowType::Body => options.column_align_body.unwrap_or(options.column_align),
439 RowType::Delimiter => unreachable!(),
440 };
441
442 let alignment = match effective_align {
444 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
445 ColumnAlign::Left => ColumnAlignment::Left,
446 ColumnAlign::Center => ColumnAlignment::Center,
447 ColumnAlign::Right => ColumnAlignment::Right,
448 };
449
450 match alignment {
451 ColumnAlignment::Left => {
452 format!(" {trimmed}{} ", " ".repeat(padding))
454 }
455 ColumnAlignment::Center => {
456 let left_padding = padding / 2;
458 let right_padding = padding - left_padding;
459 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
460 }
461 ColumnAlignment::Right => {
462 format!(" {}{trimmed} ", " ".repeat(padding))
464 }
465 }
466 }
467 }
468 })
469 .collect();
470
471 format!("|{}|", formatted_cells.join("|"))
472 }
473
474 fn format_table_compact(cells: &[String]) -> String {
475 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
476 format!("|{}|", formatted_cells.join("|"))
477 }
478
479 fn format_table_tight(cells: &[String]) -> String {
480 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
481 format!("|{}|", formatted_cells.join("|"))
482 }
483
484 fn format_delimiter_aligned_to_header(delim_cells: &[String], header_widths: &[usize], compact: bool) -> String {
493 let formatted_cells: Vec<String> = delim_cells
494 .iter()
495 .enumerate()
496 .map(|(i, cell)| {
497 let target_width = header_widths.get(i).copied().unwrap_or(0);
498 let trimmed = cell.trim();
499 let has_left_colon = trimmed.starts_with(':');
500 let has_right_colon = trimmed.ends_with(':');
501 let colon_count = usize::from(has_left_colon) + usize::from(has_right_colon);
502
503 let dash_count = target_width.saturating_sub(colon_count).max(1);
505 let dashes = "-".repeat(dash_count);
506 let delimiter_content = match (has_left_colon, has_right_colon) {
507 (true, true) => format!(":{dashes}:"),
508 (true, false) => format!(":{dashes}"),
509 (false, true) => format!("{dashes}:"),
510 (false, false) => dashes,
511 };
512 if compact {
513 format!(" {delimiter_content} ")
514 } else {
515 delimiter_content
516 }
517 })
518 .collect();
519
520 format!("|{}|", formatted_cells.join("|"))
521 }
522
523 fn header_cell_widths(header_cells: &[String]) -> Vec<usize> {
526 header_cells
527 .iter()
528 .map(|c| Self::calculate_cell_display_width(c))
529 .collect()
530 }
531
532 fn is_table_already_aligned(
544 table_lines: &[&str],
545 flavor: crate::config::MarkdownFlavor,
546 compact_delimiter: bool,
547 ) -> bool {
548 if table_lines.len() < 2 {
549 return false;
550 }
551
552 let first_width = UnicodeWidthStr::width(table_lines[0]);
556 if !table_lines
557 .iter()
558 .all(|line| UnicodeWidthStr::width(*line) == first_width)
559 {
560 return false;
561 }
562
563 let parsed: Vec<Vec<String>> = table_lines
565 .iter()
566 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
567 .collect();
568
569 if parsed.is_empty() {
570 return false;
571 }
572
573 let num_columns = parsed[0].len();
574 if !parsed.iter().all(|row| row.len() == num_columns) {
575 return false;
576 }
577
578 if let Some(delimiter_row) = parsed.get(1) {
581 if !Self::is_delimiter_row(delimiter_row) {
582 return false;
583 }
584 for cell in delimiter_row {
586 let trimmed = cell.trim();
587 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
588 if dash_count < 1 {
589 return false;
590 }
591 }
592
593 let delimiter_has_spaces = delimiter_row
597 .iter()
598 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
599
600 if compact_delimiter && delimiter_has_spaces {
603 return false;
604 }
605 if !compact_delimiter && !delimiter_has_spaces {
606 return false;
607 }
608 }
609
610 for col_idx in 0..num_columns {
614 let mut widths = Vec::new();
615 for (row_idx, row) in parsed.iter().enumerate() {
616 if row_idx == 1 {
618 continue;
619 }
620 if let Some(cell) = row.get(col_idx) {
621 widths.push(cell.width());
622 }
623 }
624 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
626 return false;
627 }
628 }
629
630 if let Some(delimiter_row) = parsed.get(1) {
635 let alignments = Self::parse_column_alignments(delimiter_row);
636 for (col_idx, alignment) in alignments.iter().enumerate() {
637 if *alignment == ColumnAlignment::Left {
638 continue;
639 }
640 for (row_idx, row) in parsed.iter().enumerate() {
641 if row_idx == 1 {
643 continue;
644 }
645 if let Some(cell) = row.get(col_idx) {
646 if cell.trim().is_empty() {
647 continue;
648 }
649 let left_pad = cell.len() - cell.trim_start().len();
651 let right_pad = cell.len() - cell.trim_end().len();
652
653 match alignment {
654 ColumnAlignment::Center => {
655 if left_pad.abs_diff(right_pad) > 1 {
657 return false;
658 }
659 }
660 ColumnAlignment::Right => {
661 if left_pad < right_pad {
663 return false;
664 }
665 }
666 ColumnAlignment::Left => unreachable!(),
667 }
668 }
669 }
670 }
671 }
672
673 true
674 }
675
676 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
677 if table_lines.is_empty() {
678 return None;
679 }
680
681 let mut is_tight = true;
684 let mut is_compact = true;
685
686 for line in table_lines {
687 let cells = Self::parse_table_row_with_flavor(line, flavor);
688
689 if cells.is_empty() {
690 continue;
691 }
692
693 if Self::is_delimiter_row(&cells) {
695 continue;
696 }
697
698 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
700
701 let row_has_single_space = cells.iter().all(|cell| {
703 let trimmed = cell.trim();
704 cell == &format!(" {trimmed} ")
705 });
706
707 if !row_has_no_padding {
709 is_tight = false;
710 }
711
712 if !row_has_single_space {
714 is_compact = false;
715 }
716
717 if !is_tight && !is_compact {
719 return Some("aligned".to_string());
720 }
721 }
722
723 if is_tight {
725 Some("tight".to_string())
726 } else if is_compact {
727 Some("compact".to_string())
728 } else {
729 Some("aligned".to_string())
730 }
731 }
732
733 fn fix_table_block(
734 &self,
735 lines: &[&str],
736 table_block: &crate::utils::table_utils::TableBlock,
737 flavor: crate::config::MarkdownFlavor,
738 ) -> TableFormatResult {
739 let mut result = Vec::new();
740 let mut auto_compacted = false;
741 let mut aligned_width = None;
742
743 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
744 .chain(std::iter::once(lines[table_block.delimiter_line]))
745 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
746 .collect();
747
748 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
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 (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
759
760 let list_context = &table_block.list_context;
762 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
763 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
764 } else {
765 ("", String::new())
766 };
767
768 let stripped_lines: Vec<&str> = table_lines
770 .iter()
771 .enumerate()
772 .map(|(i, line)| {
773 let after_blockquote = Self::extract_blockquote_prefix(line).1;
774 if list_context.is_some() {
775 if i == 0 {
776 after_blockquote.strip_prefix(list_prefix).unwrap_or_else(|| {
778 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
779 })
780 } else {
781 after_blockquote
783 .strip_prefix(&continuation_indent)
784 .unwrap_or(after_blockquote.trim_start())
785 }
786 } else {
787 after_blockquote
788 }
789 })
790 .collect();
791
792 let style = self.config.style.as_str();
793
794 match style {
795 "any" => {
796 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
797 if detected_style.is_none() {
798 return TableFormatResult {
799 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
800 auto_compacted: false,
801 aligned_width: None,
802 };
803 }
804
805 let target_style = detected_style.unwrap();
806
807 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
809 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
810
811 for (row_idx, line) in stripped_lines.iter().enumerate() {
812 let cells = Self::parse_table_row_with_flavor(line, flavor);
813 match target_style.as_str() {
814 "tight" => result.push(Self::format_table_tight(&cells)),
815 "compact" => result.push(Self::format_table_compact(&cells)),
816 _ => {
817 let column_widths =
818 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
819 let row_type = match row_idx {
820 0 => RowType::Header,
821 1 => RowType::Delimiter,
822 _ => RowType::Body,
823 };
824 let options = RowFormatOptions {
825 row_type,
826 compact_delimiter: false,
827 column_align: self.config.column_align,
828 column_align_header: self.config.column_align_header,
829 column_align_body: self.config.column_align_body,
830 };
831 result.push(Self::format_table_row(
832 &cells,
833 &column_widths,
834 &column_alignments,
835 &options,
836 ));
837 }
838 }
839 }
840 }
841 "compact" | "tight" => {
842 let compact = style == "compact";
843 let header_widths = if self.config.aligned_delimiter && stripped_lines.len() >= 2 {
844 let header_cells = Self::parse_table_row_with_flavor(stripped_lines[0], flavor);
845 Some(Self::header_cell_widths(&header_cells))
846 } else {
847 None
848 };
849
850 for (row_idx, line) in stripped_lines.iter().enumerate() {
851 let cells = Self::parse_table_row_with_flavor(line, flavor);
852 if row_idx == 1
853 && let Some(widths) = &header_widths
854 {
855 result.push(Self::format_delimiter_aligned_to_header(&cells, widths, compact));
856 continue;
857 }
858 result.push(if compact {
859 Self::format_table_compact(&cells)
860 } else {
861 Self::format_table_tight(&cells)
862 });
863 }
864 }
865 "aligned" | "aligned-no-space" => {
866 let compact_delimiter = style == "aligned-no-space";
867
868 let needs_reformat = self.config.column_align != ColumnAlign::Auto
871 || self.config.column_align_header.is_some()
872 || self.config.column_align_body.is_some()
873 || self.config.loose_last_column;
874
875 if !needs_reformat && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
876 return TableFormatResult {
877 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
878 auto_compacted: false,
879 aligned_width: None,
880 };
881 }
882
883 let column_widths =
884 Self::calculate_column_widths(&stripped_lines, flavor, self.config.loose_last_column);
885
886 let num_columns = column_widths.len();
888 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
889 aligned_width = Some(calc_aligned_width);
890
891 if calc_aligned_width > self.effective_max_width() {
893 auto_compacted = true;
894 for line in &stripped_lines {
895 let cells = Self::parse_table_row_with_flavor(line, flavor);
896 result.push(Self::format_table_compact(&cells));
897 }
898 } else {
899 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
901 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
902
903 for (row_idx, line) in stripped_lines.iter().enumerate() {
904 let cells = Self::parse_table_row_with_flavor(line, flavor);
905 let row_type = match row_idx {
906 0 => RowType::Header,
907 1 => RowType::Delimiter,
908 _ => RowType::Body,
909 };
910 let options = RowFormatOptions {
911 row_type,
912 compact_delimiter,
913 column_align: self.config.column_align,
914 column_align_header: self.config.column_align_header,
915 column_align_body: self.config.column_align_body,
916 };
917 result.push(Self::format_table_row(
918 &cells,
919 &column_widths,
920 &column_alignments,
921 &options,
922 ));
923 }
924 }
925 }
926 _ => {
927 return TableFormatResult {
928 lines: table_lines.iter().map(std::string::ToString::to_string).collect(),
929 auto_compacted: false,
930 aligned_width: None,
931 };
932 }
933 }
934
935 let prefixed_result: Vec<String> = result
937 .into_iter()
938 .enumerate()
939 .map(|(i, line)| {
940 if list_context.is_some() {
941 if i == 0 {
942 format!("{blockquote_prefix}{list_prefix}{line}")
944 } else {
945 format!("{blockquote_prefix}{continuation_indent}{line}")
947 }
948 } else {
949 format!("{blockquote_prefix}{line}")
950 }
951 })
952 .collect();
953
954 TableFormatResult {
955 lines: prefixed_result,
956 auto_compacted,
957 aligned_width,
958 }
959 }
960}
961
962impl Rule for MD060TableFormat {
963 fn name(&self) -> &'static str {
964 "MD060"
965 }
966
967 fn description(&self) -> &'static str {
968 "Table columns should be consistently aligned"
969 }
970
971 fn category(&self) -> RuleCategory {
972 RuleCategory::Table
973 }
974
975 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
976 !ctx.likely_has_tables()
977 }
978
979 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
980 let line_index = &ctx.line_index;
981 let mut warnings = Vec::new();
982
983 let lines = ctx.raw_lines();
984 let table_blocks = &ctx.table_blocks;
985
986 for table_block in table_blocks {
987 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
988
989 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
990 .chain(std::iter::once(table_block.delimiter_line))
991 .chain(table_block.content_lines.iter().copied())
992 .collect();
993
994 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());
1001 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1002 let fixed_line = &format_result.lines[i];
1003 if line_idx < lines.len() - 1 {
1005 fixed_table_lines.push(format!("{fixed_line}\n"));
1006 } else {
1007 fixed_table_lines.push(fixed_line.clone());
1008 }
1009 }
1010 let table_replacement = fixed_table_lines.concat();
1011 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
1012
1013 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1014 let original = lines[line_idx];
1015 let fixed = &format_result.lines[i];
1016
1017 if original != fixed {
1018 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
1019
1020 let message = if format_result.auto_compacted {
1021 if let Some(width) = format_result.aligned_width {
1022 format!(
1023 "Table too wide for aligned formatting ({} chars > max-width: {})",
1024 width,
1025 self.effective_max_width()
1026 )
1027 } else {
1028 "Table too wide for aligned formatting".to_string()
1029 }
1030 } else {
1031 "Table columns should be aligned".to_string()
1032 };
1033
1034 warnings.push(LintWarning {
1037 rule_name: Some(self.name().to_string()),
1038 severity: Severity::Warning,
1039 message,
1040 line: start_line,
1041 column: start_col,
1042 end_line,
1043 end_column: end_col,
1044 fix: Some(crate::rule::Fix::new(table_range.clone(), table_replacement.clone())),
1045 });
1046 }
1047 }
1048 }
1049
1050 Ok(warnings)
1051 }
1052
1053 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1054 let content = ctx.content;
1055 let lines = ctx.raw_lines();
1056 let table_blocks = &ctx.table_blocks;
1057
1058 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
1059
1060 for table_block in table_blocks {
1061 let format_result = self.fix_table_block(lines, table_block, ctx.flavor);
1062
1063 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
1064 .chain(std::iter::once(table_block.delimiter_line))
1065 .chain(table_block.content_lines.iter().copied())
1066 .collect();
1067
1068 let any_disabled = table_line_indices
1071 .iter()
1072 .any(|&line_idx| ctx.inline_config().is_rule_disabled(self.name(), line_idx + 1));
1073
1074 if any_disabled {
1075 continue;
1076 }
1077
1078 for (i, &line_idx) in table_line_indices.iter().enumerate() {
1079 result_lines[line_idx].clone_from(&format_result.lines[i]);
1080 }
1081 }
1082
1083 let mut fixed = result_lines.join("\n");
1084 if content.ends_with('\n') && !fixed.ends_with('\n') {
1085 fixed.push('\n');
1086 }
1087 Ok(fixed)
1088 }
1089
1090 fn as_any(&self) -> &dyn std::any::Any {
1091 self
1092 }
1093
1094 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1095 let table = crate::rule_config_serde::config_schema_table(&MD060Config::default())?;
1096 Some((MD060Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1097 }
1098
1099 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1100 where
1101 Self: Sized,
1102 {
1103 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
1104 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
1105
1106 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
1108
1109 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116 use crate::lint_context::LintContext;
1117 use crate::types::LineLength;
1118
1119 fn md013_with_line_length(line_length: usize) -> MD013Config {
1121 MD013Config {
1122 line_length: LineLength::from_const(line_length),
1123 tables: true, ..Default::default()
1125 }
1126 }
1127
1128 #[test]
1129 fn test_md060_align_simple_ascii_table() {
1130 let rule = MD060TableFormat::new(true, "aligned".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 let lines: Vec<&str> = fixed.lines().collect();
1141 assert_eq!(lines[0].len(), lines[1].len());
1142 assert_eq!(lines[1].len(), lines[2].len());
1143 }
1144
1145 #[test]
1146 fn test_md060_cjk_characters_aligned_correctly() {
1147 let rule = MD060TableFormat::new(true, "aligned".to_string());
1148
1149 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151
1152 let fixed = rule.fix(&ctx).unwrap();
1153
1154 let lines: Vec<&str> = fixed.lines().collect();
1155 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
1156 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
1157
1158 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
1159 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
1160
1161 assert_eq!(width1, width3);
1162 }
1163
1164 #[test]
1165 fn test_md060_basic_emoji() {
1166 let rule = MD060TableFormat::new(true, "aligned".to_string());
1167
1168 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170
1171 let fixed = rule.fix(&ctx).unwrap();
1172 assert!(fixed.contains("Status"));
1173 }
1174
1175 #[test]
1176 fn test_md060_zwj_emoji_skipped() {
1177 let rule = MD060TableFormat::new(true, "aligned".to_string());
1178
1179 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181
1182 let fixed = rule.fix(&ctx).unwrap();
1183 assert_eq!(fixed, content);
1184 }
1185
1186 #[test]
1187 fn test_md060_inline_code_with_escaped_pipes() {
1188 let rule = MD060TableFormat::new(true, "aligned".to_string());
1191
1192 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195
1196 let fixed = rule.fix(&ctx).unwrap();
1197 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1198 }
1199
1200 #[test]
1201 fn test_md060_compact_style() {
1202 let rule = MD060TableFormat::new(true, "compact".to_string());
1203
1204 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206
1207 let fixed = rule.fix(&ctx).unwrap();
1208 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1209 assert_eq!(fixed, expected);
1210 }
1211
1212 #[test]
1213 fn test_md060_tight_style() {
1214 let rule = MD060TableFormat::new(true, "tight".to_string());
1215
1216 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218
1219 let fixed = rule.fix(&ctx).unwrap();
1220 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1221 assert_eq!(fixed, expected);
1222 }
1223
1224 #[test]
1225 fn test_md060_aligned_no_space_style() {
1226 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1228
1229 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231
1232 let fixed = rule.fix(&ctx).unwrap();
1233
1234 let lines: Vec<&str> = fixed.lines().collect();
1236 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1237 assert_eq!(
1238 lines[1], "|-------|-----|",
1239 "Delimiter should have NO spaces around dashes"
1240 );
1241 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1242
1243 assert_eq!(lines[0].len(), lines[1].len());
1245 assert_eq!(lines[1].len(), lines[2].len());
1246 }
1247
1248 #[test]
1249 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1250 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1252
1253 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
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 assert!(
1261 fixed.contains("|:"),
1262 "Should have left alignment indicator adjacent to pipe"
1263 );
1264 assert!(
1265 fixed.contains(":|"),
1266 "Should have right alignment indicator adjacent to pipe"
1267 );
1268 assert!(
1270 lines[1].contains(":---") && lines[1].contains("---:"),
1271 "Should have center alignment colons"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_md060_aligned_no_space_three_column_table() {
1277 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1279
1280 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 |";
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282
1283 let fixed = rule.fix(&ctx).unwrap();
1284 let lines: Vec<&str> = fixed.lines().collect();
1285
1286 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1288 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1289 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1290 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1291 }
1292
1293 #[test]
1294 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1295 let config = MD060Config {
1297 enabled: true,
1298 style: "aligned-no-space".to_string(),
1299 max_width: LineLength::from_const(50),
1300 column_align: ColumnAlign::Auto,
1301 column_align_header: None,
1302 column_align_body: None,
1303 loose_last_column: false,
1304 aligned_delimiter: false,
1305 };
1306 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1307
1308 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311
1312 let fixed = rule.fix(&ctx).unwrap();
1313
1314 assert!(
1316 fixed.contains("| --- |"),
1317 "Should be compact format when exceeding max-width"
1318 );
1319 }
1320
1321 #[test]
1322 fn test_md060_aligned_no_space_cjk_characters() {
1323 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1325
1326 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328
1329 let fixed = rule.fix(&ctx).unwrap();
1330 let lines: Vec<&str> = fixed.lines().collect();
1331
1332 use unicode_width::UnicodeWidthStr;
1335 assert_eq!(
1336 lines[0].width(),
1337 lines[1].width(),
1338 "Header and delimiter should have same display width"
1339 );
1340 assert_eq!(
1341 lines[1].width(),
1342 lines[2].width(),
1343 "Delimiter and content should have same display width"
1344 );
1345
1346 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1348 }
1349
1350 #[test]
1351 fn test_md060_aligned_no_space_minimum_width() {
1352 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1354
1355 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357
1358 let fixed = rule.fix(&ctx).unwrap();
1359 let lines: Vec<&str> = fixed.lines().collect();
1360
1361 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1363 assert_eq!(lines[0].len(), lines[1].len());
1365 assert_eq!(lines[1].len(), lines[2].len());
1366 }
1367
1368 #[test]
1369 fn test_md060_any_style_consistency() {
1370 let rule = MD060TableFormat::new(true, "any".to_string());
1371
1372 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375
1376 let fixed = rule.fix(&ctx).unwrap();
1377 assert_eq!(fixed, content);
1378
1379 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1381 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1382
1383 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1384 assert_eq!(fixed_aligned, content_aligned);
1385 }
1386
1387 #[test]
1388 fn test_md060_empty_cells() {
1389 let rule = MD060TableFormat::new(true, "aligned".to_string());
1390
1391 let content = "| A | B |\n|---|---|\n| | X |";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393
1394 let fixed = rule.fix(&ctx).unwrap();
1395 assert!(fixed.contains('|'));
1396 }
1397
1398 #[test]
1399 fn test_md060_mixed_content() {
1400 let rule = MD060TableFormat::new(true, "aligned".to_string());
1401
1402 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404
1405 let fixed = rule.fix(&ctx).unwrap();
1406 assert!(fixed.contains("δΈζ"));
1407 assert!(fixed.contains("NYC"));
1408 }
1409
1410 #[test]
1411 fn test_md060_preserve_alignment_indicators() {
1412 let rule = MD060TableFormat::new(true, "aligned".to_string());
1413
1414 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416
1417 let fixed = rule.fix(&ctx).unwrap();
1418
1419 assert!(fixed.contains(":---"), "Should contain left alignment");
1420 assert!(fixed.contains(":----:"), "Should contain center alignment");
1421 assert!(fixed.contains("----:"), "Should contain right alignment");
1422 }
1423
1424 #[test]
1425 fn test_md060_minimum_column_width() {
1426 let rule = MD060TableFormat::new(true, "aligned".to_string());
1427
1428 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432
1433 let fixed = rule.fix(&ctx).unwrap();
1434
1435 let lines: Vec<&str> = fixed.lines().collect();
1436 assert_eq!(lines[0].len(), lines[1].len());
1437 assert_eq!(lines[1].len(), lines[2].len());
1438
1439 assert!(fixed.contains("ID "), "Short content should be padded");
1441 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1442 }
1443
1444 #[test]
1445 fn test_md060_auto_compact_exceeds_default_threshold() {
1446 let config = MD060Config {
1448 enabled: true,
1449 style: "aligned".to_string(),
1450 max_width: LineLength::from_const(0),
1451 column_align: ColumnAlign::Auto,
1452 column_align_header: None,
1453 column_align_body: None,
1454 loose_last_column: false,
1455 aligned_delimiter: false,
1456 };
1457 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1458
1459 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1464
1465 let fixed = rule.fix(&ctx).unwrap();
1466
1467 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1469 assert!(fixed.contains("| --- | --- | --- |"));
1470 assert!(fixed.contains("| Short | Data | Here |"));
1471
1472 let lines: Vec<&str> = fixed.lines().collect();
1474 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1476 }
1477
1478 #[test]
1479 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1480 let config = MD060Config {
1482 enabled: true,
1483 style: "aligned".to_string(),
1484 max_width: LineLength::from_const(50),
1485 column_align: ColumnAlign::Auto,
1486 column_align_header: None,
1487 column_align_body: None,
1488 loose_last_column: false,
1489 aligned_delimiter: false,
1490 };
1491 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 |";
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498
1499 let fixed = rule.fix(&ctx).unwrap();
1500
1501 assert!(
1503 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1504 );
1505 assert!(fixed.contains("| --- | --- | --- |"));
1506 assert!(fixed.contains("| Data | Data | Data |"));
1507
1508 let lines: Vec<&str> = fixed.lines().collect();
1510 assert!(lines[0].len() != lines[2].len());
1511 }
1512
1513 #[test]
1514 fn test_md060_stays_aligned_under_threshold() {
1515 let config = MD060Config {
1517 enabled: true,
1518 style: "aligned".to_string(),
1519 max_width: LineLength::from_const(100),
1520 column_align: ColumnAlign::Auto,
1521 column_align_header: None,
1522 column_align_body: None,
1523 loose_last_column: false,
1524 aligned_delimiter: false,
1525 };
1526 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1527
1528 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531
1532 let fixed = rule.fix(&ctx).unwrap();
1533
1534 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1536 assert_eq!(fixed, expected);
1537
1538 let lines: Vec<&str> = fixed.lines().collect();
1539 assert_eq!(lines[0].len(), lines[1].len());
1540 assert_eq!(lines[1].len(), lines[2].len());
1541 }
1542
1543 #[test]
1544 fn test_md060_width_calculation_formula() {
1545 let config = MD060Config {
1547 enabled: true,
1548 style: "aligned".to_string(),
1549 max_width: LineLength::from_const(0),
1550 column_align: ColumnAlign::Auto,
1551 column_align_header: None,
1552 column_align_body: None,
1553 loose_last_column: false,
1554 aligned_delimiter: false,
1555 };
1556 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1557
1558 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563
1564 let fixed = rule.fix(&ctx).unwrap();
1565
1566 let lines: Vec<&str> = fixed.lines().collect();
1568 assert_eq!(lines[0].len(), lines[1].len());
1569 assert_eq!(lines[1].len(), lines[2].len());
1570 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1574 enabled: true,
1575 style: "aligned".to_string(),
1576 max_width: LineLength::from_const(24),
1577 column_align: ColumnAlign::Auto,
1578 column_align_header: None,
1579 column_align_body: None,
1580 loose_last_column: false,
1581 aligned_delimiter: false,
1582 };
1583 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1584
1585 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1586
1587 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1589 assert!(fixed_compact.contains("| --- | --- | --- |"));
1590 }
1591
1592 #[test]
1593 fn test_md060_very_wide_table_auto_compacts() {
1594 let config = MD060Config {
1595 enabled: true,
1596 style: "aligned".to_string(),
1597 max_width: LineLength::from_const(0),
1598 column_align: ColumnAlign::Auto,
1599 column_align_header: None,
1600 column_align_body: None,
1601 loose_last_column: false,
1602 aligned_delimiter: false,
1603 };
1604 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1605
1606 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 |";
1610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1611
1612 let fixed = rule.fix(&ctx).unwrap();
1613
1614 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1616 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1617 }
1618
1619 #[test]
1620 fn test_md060_inherit_from_md013_line_length() {
1621 let config = MD060Config {
1623 enabled: true,
1624 style: "aligned".to_string(),
1625 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1627 column_align_header: None,
1628 column_align_body: None,
1629 loose_last_column: false,
1630 aligned_delimiter: false,
1631 };
1632
1633 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1635 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1636
1637 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640
1641 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1643
1644 let fixed_120 = rule_120.fix(&ctx).unwrap();
1646
1647 let lines_120: Vec<&str> = fixed_120.lines().collect();
1649 assert_eq!(lines_120[0].len(), lines_120[1].len());
1650 assert_eq!(lines_120[1].len(), lines_120[2].len());
1651 }
1652
1653 #[test]
1654 fn test_md060_edge_case_exactly_at_threshold() {
1655 let config = MD060Config {
1659 enabled: true,
1660 style: "aligned".to_string(),
1661 max_width: LineLength::from_const(17),
1662 column_align: ColumnAlign::Auto,
1663 column_align_header: None,
1664 column_align_body: None,
1665 loose_last_column: false,
1666 aligned_delimiter: false,
1667 };
1668 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1669
1670 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672
1673 let fixed = rule.fix(&ctx).unwrap();
1674
1675 let lines: Vec<&str> = fixed.lines().collect();
1677 assert_eq!(lines[0].len(), 17);
1678 assert_eq!(lines[0].len(), lines[1].len());
1679 assert_eq!(lines[1].len(), lines[2].len());
1680
1681 let config_under = MD060Config {
1683 enabled: true,
1684 style: "aligned".to_string(),
1685 max_width: LineLength::from_const(16),
1686 column_align: ColumnAlign::Auto,
1687 column_align_header: None,
1688 column_align_body: None,
1689 loose_last_column: false,
1690 aligned_delimiter: false,
1691 };
1692 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1693
1694 let fixed_compact = rule_under.fix(&ctx).unwrap();
1695
1696 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1698 assert!(fixed_compact.contains("| --- | --- |"));
1699 }
1700
1701 #[test]
1702 fn test_md060_auto_compact_warning_message() {
1703 let config = MD060Config {
1705 enabled: true,
1706 style: "aligned".to_string(),
1707 max_width: LineLength::from_const(50),
1708 column_align: ColumnAlign::Auto,
1709 column_align_header: None,
1710 column_align_body: None,
1711 loose_last_column: false,
1712 aligned_delimiter: false,
1713 };
1714 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1715
1716 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719
1720 let warnings = rule.check(&ctx).unwrap();
1721
1722 assert!(!warnings.is_empty(), "Should generate warnings");
1724
1725 let auto_compact_warnings: Vec<_> = warnings
1726 .iter()
1727 .filter(|w| w.message.contains("too wide for aligned formatting"))
1728 .collect();
1729
1730 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1731
1732 let first_warning = auto_compact_warnings[0];
1734 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1735 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1736 }
1737
1738 #[test]
1739 fn test_md060_issue_129_detect_style_from_all_rows() {
1740 let rule = MD060TableFormat::new(true, "any".to_string());
1744
1745 let content = "| a long heading | another long heading |\n\
1747 | -------------- | -------------------- |\n\
1748 | a | 1 |\n\
1749 | b b | 2 |\n\
1750 | c c c | 3 |";
1751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1752
1753 let fixed = rule.fix(&ctx).unwrap();
1754
1755 assert!(
1757 fixed.contains("| a | 1 |"),
1758 "Should preserve aligned padding in first content row"
1759 );
1760 assert!(
1761 fixed.contains("| b b | 2 |"),
1762 "Should preserve aligned padding in second content row"
1763 );
1764 assert!(
1765 fixed.contains("| c c c | 3 |"),
1766 "Should preserve aligned padding in third content row"
1767 );
1768
1769 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1771 }
1772
1773 #[test]
1774 fn test_md060_regular_alignment_warning_message() {
1775 let config = MD060Config {
1777 enabled: true,
1778 style: "aligned".to_string(),
1779 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1781 column_align_header: None,
1782 column_align_body: None,
1783 loose_last_column: false,
1784 aligned_delimiter: false,
1785 };
1786 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1787
1788 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791
1792 let warnings = rule.check(&ctx).unwrap();
1793
1794 assert!(!warnings.is_empty(), "Should generate warnings");
1796
1797 assert!(warnings[0].message.contains("Table columns should be aligned"));
1799 assert!(!warnings[0].message.contains("too wide"));
1800 assert!(!warnings[0].message.contains("max-width"));
1801 }
1802
1803 #[test]
1806 fn test_md060_unlimited_when_md013_disabled() {
1807 let config = MD060Config {
1809 enabled: true,
1810 style: "aligned".to_string(),
1811 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1813 column_align_header: None,
1814 column_align_body: None,
1815 loose_last_column: false,
1816 aligned_delimiter: false,
1817 };
1818 let md013_config = MD013Config::default();
1819 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1820
1821 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1824 let fixed = rule.fix(&ctx).unwrap();
1825
1826 let lines: Vec<&str> = fixed.lines().collect();
1828 assert_eq!(
1830 lines[0].len(),
1831 lines[1].len(),
1832 "Table should be aligned when MD013 is disabled"
1833 );
1834 }
1835
1836 #[test]
1837 fn test_md060_unlimited_when_md013_tables_false() {
1838 let config = MD060Config {
1840 enabled: true,
1841 style: "aligned".to_string(),
1842 max_width: LineLength::from_const(0),
1843 column_align: ColumnAlign::Auto,
1844 column_align_header: None,
1845 column_align_body: None,
1846 loose_last_column: false,
1847 aligned_delimiter: false,
1848 };
1849 let md013_config = MD013Config {
1850 tables: false, line_length: LineLength::from_const(80),
1852 ..Default::default()
1853 };
1854 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1855
1856 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let fixed = rule.fix(&ctx).unwrap();
1860
1861 let lines: Vec<&str> = fixed.lines().collect();
1863 assert_eq!(
1864 lines[0].len(),
1865 lines[1].len(),
1866 "Table should be aligned when MD013.tables=false"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_md060_unlimited_when_md013_line_length_zero() {
1872 let config = MD060Config {
1874 enabled: true,
1875 style: "aligned".to_string(),
1876 max_width: LineLength::from_const(0),
1877 column_align: ColumnAlign::Auto,
1878 column_align_header: None,
1879 column_align_body: None,
1880 loose_last_column: false,
1881 aligned_delimiter: false,
1882 };
1883 let md013_config = MD013Config {
1884 tables: true,
1885 line_length: LineLength::from_const(0), ..Default::default()
1887 };
1888 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1889
1890 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let fixed = rule.fix(&ctx).unwrap();
1894
1895 let lines: Vec<&str> = fixed.lines().collect();
1897 assert_eq!(
1898 lines[0].len(),
1899 lines[1].len(),
1900 "Table should be aligned when MD013.line_length=0"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_md060_explicit_max_width_overrides_md013_settings() {
1906 let config = MD060Config {
1908 enabled: true,
1909 style: "aligned".to_string(),
1910 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1912 column_align_header: None,
1913 column_align_body: None,
1914 loose_last_column: false,
1915 aligned_delimiter: false,
1916 };
1917 let md013_config = MD013Config {
1918 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1921 };
1922 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1923
1924 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1927 let fixed = rule.fix(&ctx).unwrap();
1928
1929 assert!(
1931 fixed.contains("| --- |"),
1932 "Should be compact format due to explicit max_width"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1938 let config = MD060Config {
1940 enabled: true,
1941 style: "aligned".to_string(),
1942 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1944 column_align_header: None,
1945 column_align_body: None,
1946 loose_last_column: false,
1947 aligned_delimiter: false,
1948 };
1949 let md013_config = MD013Config {
1950 tables: true,
1951 line_length: LineLength::from_const(50), ..Default::default()
1953 };
1954 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1955
1956 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959 let fixed = rule.fix(&ctx).unwrap();
1960
1961 assert!(
1963 fixed.contains("| --- |"),
1964 "Should be compact format when inheriting MD013 limit"
1965 );
1966 }
1967
1968 #[test]
1971 fn test_aligned_no_space_reformats_spaced_delimiter() {
1972 let config = MD060Config {
1975 enabled: true,
1976 style: "aligned-no-space".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 aligned_delimiter: false,
1983 };
1984 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1985
1986 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1989 let fixed = rule.fix(&ctx).unwrap();
1990
1991 assert!(
1994 !fixed.contains("| ----"),
1995 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1996 );
1997 assert!(
1998 !fixed.contains("---- |"),
1999 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
2000 );
2001 assert!(
2003 fixed.contains("|----"),
2004 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_aligned_reformats_compact_delimiter() {
2010 let config = MD060Config {
2013 enabled: true,
2014 style: "aligned".to_string(),
2015 max_width: LineLength::from_const(0),
2016 column_align: ColumnAlign::Auto,
2017 column_align_header: None,
2018 column_align_body: None,
2019 loose_last_column: false,
2020 aligned_delimiter: false,
2021 };
2022 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2023
2024 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2027 let fixed = rule.fix(&ctx).unwrap();
2028
2029 assert!(
2031 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
2032 "Delimiter should have spaces around dashes. Got:\n{fixed}"
2033 );
2034 }
2035
2036 #[test]
2037 fn test_aligned_no_space_preserves_matching_table() {
2038 let config = MD060Config {
2040 enabled: true,
2041 style: "aligned-no-space".to_string(),
2042 max_width: LineLength::from_const(0),
2043 column_align: ColumnAlign::Auto,
2044 column_align_header: None,
2045 column_align_body: None,
2046 loose_last_column: false,
2047 aligned_delimiter: false,
2048 };
2049 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2050
2051 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let fixed = rule.fix(&ctx).unwrap();
2055
2056 assert_eq!(
2058 fixed, content,
2059 "Table already in aligned-no-space style should be preserved"
2060 );
2061 }
2062
2063 #[test]
2064 fn test_aligned_preserves_matching_table() {
2065 let config = MD060Config {
2067 enabled: true,
2068 style: "aligned".to_string(),
2069 max_width: LineLength::from_const(0),
2070 column_align: ColumnAlign::Auto,
2071 column_align_header: None,
2072 column_align_body: None,
2073 loose_last_column: false,
2074 aligned_delimiter: false,
2075 };
2076 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2077
2078 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
2080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2081 let fixed = rule.fix(&ctx).unwrap();
2082
2083 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
2085 }
2086
2087 #[test]
2088 fn test_cjk_table_display_width_consistency() {
2089 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
2095
2096 let is_aligned =
2098 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
2099 assert!(
2100 !is_aligned,
2101 "Table with uneven raw line lengths should NOT be considered aligned"
2102 );
2103 }
2104
2105 #[test]
2106 fn test_cjk_width_calculation_in_aligned_check() {
2107 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
2110 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
2111
2112 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
2113 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
2114
2115 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
2117 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
2118
2119 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
2121 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
2123 }
2124
2125 #[test]
2128 fn test_md060_column_align_left() {
2129 let config = MD060Config {
2131 enabled: true,
2132 style: "aligned".to_string(),
2133 max_width: LineLength::from_const(0),
2134 column_align: ColumnAlign::Left,
2135 column_align_header: None,
2136 column_align_body: None,
2137 loose_last_column: false,
2138 aligned_delimiter: false,
2139 };
2140 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2141
2142 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2144
2145 let fixed = rule.fix(&ctx).unwrap();
2146 let lines: Vec<&str> = fixed.lines().collect();
2147
2148 assert!(
2150 lines[2].contains("| Alice "),
2151 "Content should be left-aligned (Alice should have trailing padding)"
2152 );
2153 assert!(
2154 lines[3].contains("| Bob "),
2155 "Content should be left-aligned (Bob should have trailing padding)"
2156 );
2157 }
2158
2159 #[test]
2160 fn test_md060_column_align_center() {
2161 let config = MD060Config {
2163 enabled: true,
2164 style: "aligned".to_string(),
2165 max_width: LineLength::from_const(0),
2166 column_align: ColumnAlign::Center,
2167 column_align_header: None,
2168 column_align_body: None,
2169 loose_last_column: false,
2170 aligned_delimiter: false,
2171 };
2172 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2173
2174 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176
2177 let fixed = rule.fix(&ctx).unwrap();
2178 let lines: Vec<&str> = fixed.lines().collect();
2179
2180 assert!(
2183 lines[3].contains("| Bob |"),
2184 "Bob should be centered with padding on both sides. Got: {}",
2185 lines[3]
2186 );
2187 }
2188
2189 #[test]
2190 fn test_md060_column_align_right() {
2191 let config = MD060Config {
2193 enabled: true,
2194 style: "aligned".to_string(),
2195 max_width: LineLength::from_const(0),
2196 column_align: ColumnAlign::Right,
2197 column_align_header: None,
2198 column_align_body: None,
2199 loose_last_column: false,
2200 aligned_delimiter: false,
2201 };
2202 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2203
2204 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
2205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2206
2207 let fixed = rule.fix(&ctx).unwrap();
2208 let lines: Vec<&str> = fixed.lines().collect();
2209
2210 assert!(
2212 lines[3].contains("| Bob |"),
2213 "Bob should be right-aligned with padding on left. Got: {}",
2214 lines[3]
2215 );
2216 }
2217
2218 #[test]
2219 fn test_md060_column_align_auto_respects_delimiter() {
2220 let config = MD060Config {
2222 enabled: true,
2223 style: "aligned".to_string(),
2224 max_width: LineLength::from_const(0),
2225 column_align: ColumnAlign::Auto,
2226 column_align_header: None,
2227 column_align_body: None,
2228 loose_last_column: false,
2229 aligned_delimiter: false,
2230 };
2231 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2232
2233 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2236
2237 let fixed = rule.fix(&ctx).unwrap();
2238
2239 assert!(fixed.contains("| A "), "Left column should be left-aligned");
2241 let lines: Vec<&str> = fixed.lines().collect();
2243 assert!(
2247 lines[2].contains(" C |"),
2248 "Right column should be right-aligned. Got: {}",
2249 lines[2]
2250 );
2251 }
2252
2253 #[test]
2254 fn test_md060_column_align_overrides_delimiter_indicators() {
2255 let config = MD060Config {
2257 enabled: true,
2258 style: "aligned".to_string(),
2259 max_width: LineLength::from_const(0),
2260 column_align: ColumnAlign::Right, column_align_header: None,
2262 column_align_body: None,
2263 loose_last_column: false,
2264 aligned_delimiter: false,
2265 };
2266 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2267
2268 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
2270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2271
2272 let fixed = rule.fix(&ctx).unwrap();
2273 let lines: Vec<&str> = fixed.lines().collect();
2274
2275 assert!(
2278 lines[2].contains(" A |") || lines[2].contains(" A |"),
2279 "Even left-indicated column should be right-aligned. Got: {}",
2280 lines[2]
2281 );
2282 }
2283
2284 #[test]
2285 fn test_md060_column_align_with_aligned_no_space() {
2286 let config = MD060Config {
2288 enabled: true,
2289 style: "aligned-no-space".to_string(),
2290 max_width: LineLength::from_const(0),
2291 column_align: ColumnAlign::Center,
2292 column_align_header: None,
2293 column_align_body: None,
2294 loose_last_column: false,
2295 aligned_delimiter: false,
2296 };
2297 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2298
2299 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2301
2302 let fixed = rule.fix(&ctx).unwrap();
2303 let lines: Vec<&str> = fixed.lines().collect();
2304
2305 assert!(
2307 lines[1].contains("|---"),
2308 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2309 lines[1]
2310 );
2311 assert!(
2313 lines[3].contains("| Bob |"),
2314 "Content should be centered. Got: {}",
2315 lines[3]
2316 );
2317 }
2318
2319 #[test]
2320 fn test_md060_column_align_config_parsing() {
2321 let toml_str = r#"
2323enabled = true
2324style = "aligned"
2325column-align = "center"
2326"#;
2327 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2328 assert_eq!(config.column_align, ColumnAlign::Center);
2329
2330 let toml_str = r#"
2331enabled = true
2332style = "aligned"
2333column-align = "right"
2334"#;
2335 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2336 assert_eq!(config.column_align, ColumnAlign::Right);
2337
2338 let toml_str = r#"
2339enabled = true
2340style = "aligned"
2341column-align = "left"
2342"#;
2343 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2344 assert_eq!(config.column_align, ColumnAlign::Left);
2345
2346 let toml_str = r#"
2347enabled = true
2348style = "aligned"
2349column-align = "auto"
2350"#;
2351 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2352 assert_eq!(config.column_align, ColumnAlign::Auto);
2353 }
2354
2355 #[test]
2356 fn test_md060_column_align_default_is_auto() {
2357 let toml_str = r#"
2359enabled = true
2360style = "aligned"
2361"#;
2362 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2363 assert_eq!(config.column_align, ColumnAlign::Auto);
2364 }
2365
2366 #[test]
2367 fn test_md060_column_align_reformats_already_aligned_table() {
2368 let config = MD060Config {
2370 enabled: true,
2371 style: "aligned".to_string(),
2372 max_width: LineLength::from_const(0),
2373 column_align: ColumnAlign::Right,
2374 column_align_header: None,
2375 column_align_body: None,
2376 loose_last_column: false,
2377 aligned_delimiter: false,
2378 };
2379 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2380
2381 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2384
2385 let fixed = rule.fix(&ctx).unwrap();
2386 let lines: Vec<&str> = fixed.lines().collect();
2387
2388 assert!(
2390 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2391 "Already aligned table should be reformatted with right alignment. Got: {}",
2392 lines[2]
2393 );
2394 assert!(
2395 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2396 "Bob should be right-aligned. Got: {}",
2397 lines[3]
2398 );
2399 }
2400
2401 #[test]
2402 fn test_md060_column_align_with_cjk_characters() {
2403 let config = MD060Config {
2405 enabled: true,
2406 style: "aligned".to_string(),
2407 max_width: LineLength::from_const(0),
2408 column_align: ColumnAlign::Center,
2409 column_align_header: None,
2410 column_align_body: None,
2411 loose_last_column: false,
2412 aligned_delimiter: false,
2413 };
2414 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2415
2416 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2418
2419 let fixed = rule.fix(&ctx).unwrap();
2420
2421 assert!(fixed.contains("Bob"), "Table should contain Bob");
2424 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2425 }
2426
2427 #[test]
2428 fn test_md060_column_align_ignored_for_compact_style() {
2429 let config = MD060Config {
2431 enabled: true,
2432 style: "compact".to_string(),
2433 max_width: LineLength::from_const(0),
2434 column_align: ColumnAlign::Right, column_align_header: None,
2436 column_align_body: None,
2437 loose_last_column: false,
2438 aligned_delimiter: false,
2439 };
2440 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2441
2442 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2444
2445 let fixed = rule.fix(&ctx).unwrap();
2446
2447 assert!(
2449 fixed.contains("| Alice |"),
2450 "Compact style should have single space padding, not alignment. Got: {fixed}"
2451 );
2452 }
2453
2454 #[test]
2455 fn test_md060_column_align_ignored_for_tight_style() {
2456 let config = MD060Config {
2458 enabled: true,
2459 style: "tight".to_string(),
2460 max_width: LineLength::from_const(0),
2461 column_align: ColumnAlign::Center, column_align_header: None,
2463 column_align_body: None,
2464 loose_last_column: false,
2465 aligned_delimiter: false,
2466 };
2467 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2468
2469 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2471
2472 let fixed = rule.fix(&ctx).unwrap();
2473
2474 assert!(
2476 fixed.contains("|Alice|"),
2477 "Tight style should have no spaces. Got: {fixed}"
2478 );
2479 }
2480
2481 #[test]
2482 fn test_md060_column_align_with_empty_cells() {
2483 let config = MD060Config {
2485 enabled: true,
2486 style: "aligned".to_string(),
2487 max_width: LineLength::from_const(0),
2488 column_align: ColumnAlign::Center,
2489 column_align_header: None,
2490 column_align_body: None,
2491 loose_last_column: false,
2492 aligned_delimiter: false,
2493 };
2494 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2495
2496 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2498
2499 let fixed = rule.fix(&ctx).unwrap();
2500 let lines: Vec<&str> = fixed.lines().collect();
2501
2502 assert!(
2504 lines[3].contains("| |") || lines[3].contains("| |"),
2505 "Empty cell should be padded correctly. Got: {}",
2506 lines[3]
2507 );
2508 }
2509
2510 #[test]
2511 fn test_md060_column_align_auto_preserves_already_aligned() {
2512 let config = MD060Config {
2514 enabled: true,
2515 style: "aligned".to_string(),
2516 max_width: LineLength::from_const(0),
2517 column_align: ColumnAlign::Auto,
2518 column_align_header: None,
2519 column_align_body: None,
2520 loose_last_column: false,
2521 aligned_delimiter: false,
2522 };
2523 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2524
2525 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2528
2529 let fixed = rule.fix(&ctx).unwrap();
2530
2531 assert_eq!(
2533 fixed, content,
2534 "Already aligned table should be preserved with column-align=auto"
2535 );
2536 }
2537
2538 #[test]
2539 fn test_cjk_table_display_aligned_not_flagged() {
2540 use crate::config::MarkdownFlavor;
2544
2545 let table_lines: Vec<&str> = vec![
2547 "| Header | Name |",
2548 "| ------ | ---- |",
2549 "| Hello | Test |",
2550 "| δ½ ε₯½ | Test |",
2551 ];
2552
2553 let result = MD060TableFormat::is_table_already_aligned(&table_lines, MarkdownFlavor::Standard, false);
2554 assert!(
2555 result,
2556 "Table with CJK characters that is display-aligned should be recognized as aligned"
2557 );
2558 }
2559
2560 #[test]
2561 fn test_cjk_table_not_reformatted_when_aligned() {
2562 let rule = MD060TableFormat::new(true, "aligned".to_string());
2564 let content = "| Header | Name |\n| ------ | ---- |\n| Hello | Test |\n| δ½ ε₯½ | Test |\n";
2566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2567
2568 let fixed = rule.fix(&ctx).unwrap();
2570 assert_eq!(fixed, content, "Display-aligned CJK table should not be reformatted");
2571 }
2572}