1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::table_utils::TableUtils;
4use unicode_width::UnicodeWidthStr;
5
6mod md060_config;
7use crate::md013_line_length::MD013Config;
8use md060_config::MD060Config;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11enum ColumnAlignment {
12 Left,
13 Center,
14 Right,
15}
16
17#[derive(Debug, Clone)]
18struct TableFormatResult {
19 lines: Vec<String>,
20 auto_compacted: bool,
21 aligned_width: Option<usize>,
22}
23
24#[derive(Debug, Clone, Default)]
147pub struct MD060TableFormat {
148 config: MD060Config,
149 md013_config: MD013Config,
150 md013_disabled: bool,
151}
152
153impl MD060TableFormat {
154 pub fn new(enabled: bool, style: String) -> Self {
155 use crate::types::LineLength;
156 Self {
157 config: MD060Config {
158 enabled,
159 style,
160 max_width: LineLength::from_const(0),
161 },
162 md013_config: MD013Config::default(),
163 md013_disabled: false,
164 }
165 }
166
167 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
168 Self {
169 config,
170 md013_config,
171 md013_disabled,
172 }
173 }
174
175 fn effective_max_width(&self) -> usize {
185 if !self.config.max_width.is_unlimited() {
187 return self.config.max_width.get();
188 }
189
190 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
195 return usize::MAX; }
197
198 self.md013_config.line_length.get()
200 }
201
202 fn contains_problematic_chars(text: &str) -> bool {
213 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
218
219 fn calculate_cell_display_width(cell_content: &str) -> usize {
220 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
221 masked.trim().width()
222 }
223
224 #[cfg(test)]
227 fn parse_table_row(line: &str) -> Vec<String> {
228 TableUtils::split_table_row(line)
229 }
230
231 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
236 TableUtils::split_table_row_with_flavor(line, flavor)
237 }
238
239 fn is_delimiter_row(row: &[String]) -> bool {
240 if row.is_empty() {
241 return false;
242 }
243 row.iter().all(|cell| {
244 let trimmed = cell.trim();
245 !trimmed.is_empty()
248 && trimmed.contains('-')
249 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
250 })
251 }
252
253 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
254 delimiter_row
255 .iter()
256 .map(|cell| {
257 let trimmed = cell.trim();
258 let has_left_colon = trimmed.starts_with(':');
259 let has_right_colon = trimmed.ends_with(':');
260
261 match (has_left_colon, has_right_colon) {
262 (true, true) => ColumnAlignment::Center,
263 (false, true) => ColumnAlignment::Right,
264 _ => ColumnAlignment::Left,
265 }
266 })
267 .collect()
268 }
269
270 fn calculate_column_widths(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Vec<usize> {
271 let mut column_widths = Vec::new();
272 let mut delimiter_cells: Option<Vec<String>> = None;
273
274 for line in table_lines {
275 let cells = Self::parse_table_row_with_flavor(line, flavor);
276
277 if Self::is_delimiter_row(&cells) {
279 delimiter_cells = Some(cells);
280 continue;
281 }
282
283 for (i, cell) in cells.iter().enumerate() {
284 let width = Self::calculate_cell_display_width(cell);
285 if i >= column_widths.len() {
286 column_widths.push(width);
287 } else {
288 column_widths[i] = column_widths[i].max(width);
289 }
290 }
291 }
292
293 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
296
297 if let Some(delimiter_cells) = delimiter_cells {
300 for (i, cell) in delimiter_cells.iter().enumerate() {
301 if i < final_widths.len() {
302 let trimmed = cell.trim();
303 let has_left_colon = trimmed.starts_with(':');
304 let has_right_colon = trimmed.ends_with(':');
305 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
306
307 let min_width_for_delimiter = 3 + colon_count;
309 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
310 }
311 }
312 }
313
314 final_widths
315 }
316
317 fn format_table_row(
318 cells: &[String],
319 column_widths: &[usize],
320 column_alignments: &[ColumnAlignment],
321 is_delimiter: bool,
322 ) -> String {
323 let formatted_cells: Vec<String> = cells
324 .iter()
325 .enumerate()
326 .map(|(i, cell)| {
327 let target_width = column_widths.get(i).copied().unwrap_or(0);
328 if is_delimiter {
329 let trimmed = cell.trim();
330 let has_left_colon = trimmed.starts_with(':');
331 let has_right_colon = trimmed.ends_with(':');
332
333 let dash_count = if has_left_colon && has_right_colon {
336 target_width.saturating_sub(2)
337 } else if has_left_colon || has_right_colon {
338 target_width.saturating_sub(1)
339 } else {
340 target_width
341 };
342
343 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
345 format!(":{dashes}:")
346 } else if has_left_colon {
347 format!(":{dashes}")
348 } else if has_right_colon {
349 format!("{dashes}:")
350 } else {
351 dashes
352 };
353
354 format!(" {delimiter_content} ")
356 } else {
357 let trimmed = cell.trim();
358 let current_width = Self::calculate_cell_display_width(cell);
359 let padding = target_width.saturating_sub(current_width);
360
361 let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
363 match alignment {
364 ColumnAlignment::Left => {
365 format!(" {trimmed}{} ", " ".repeat(padding))
367 }
368 ColumnAlignment::Center => {
369 let left_padding = padding / 2;
371 let right_padding = padding - left_padding;
372 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
373 }
374 ColumnAlignment::Right => {
375 format!(" {}{trimmed} ", " ".repeat(padding))
377 }
378 }
379 }
380 })
381 .collect();
382
383 format!("|{}|", formatted_cells.join("|"))
384 }
385
386 fn format_table_compact(cells: &[String]) -> String {
387 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
388 format!("|{}|", formatted_cells.join("|"))
389 }
390
391 fn format_table_tight(cells: &[String]) -> String {
392 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
393 format!("|{}|", formatted_cells.join("|"))
394 }
395
396 fn is_table_already_aligned(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> bool {
403 if table_lines.len() < 2 {
404 return false;
405 }
406
407 let first_len = table_lines[0].len();
409 if !table_lines.iter().all(|line| line.len() == first_len) {
410 return false;
411 }
412
413 let parsed: Vec<Vec<String>> = table_lines
415 .iter()
416 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
417 .collect();
418
419 if parsed.is_empty() {
420 return false;
421 }
422
423 let num_columns = parsed[0].len();
424 if !parsed.iter().all(|row| row.len() == num_columns) {
425 return false;
426 }
427
428 if let Some(delimiter_row) = parsed.get(1) {
431 if !Self::is_delimiter_row(delimiter_row) {
432 return false;
433 }
434 for cell in delimiter_row {
436 let trimmed = cell.trim();
437 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
438 if dash_count < 1 {
439 return false;
440 }
441 }
442 }
443
444 for col_idx in 0..num_columns {
446 let mut widths = Vec::new();
447 for (row_idx, row) in parsed.iter().enumerate() {
448 if row_idx == 1 {
450 continue;
451 }
452 if let Some(cell) = row.get(col_idx) {
453 widths.push(cell.len());
454 }
455 }
456 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
458 return false;
459 }
460 }
461
462 true
463 }
464
465 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
466 if table_lines.is_empty() {
467 return None;
468 }
469
470 let mut is_tight = true;
473 let mut is_compact = true;
474
475 for line in table_lines {
476 let cells = Self::parse_table_row_with_flavor(line, flavor);
477
478 if cells.is_empty() {
479 continue;
480 }
481
482 if Self::is_delimiter_row(&cells) {
484 continue;
485 }
486
487 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
489
490 let row_has_single_space = cells.iter().all(|cell| {
492 let trimmed = cell.trim();
493 cell == &format!(" {trimmed} ")
494 });
495
496 if !row_has_no_padding {
498 is_tight = false;
499 }
500
501 if !row_has_single_space {
503 is_compact = false;
504 }
505
506 if !is_tight && !is_compact {
508 return Some("aligned".to_string());
509 }
510 }
511
512 if is_tight {
514 Some("tight".to_string())
515 } else if is_compact {
516 Some("compact".to_string())
517 } else {
518 Some("aligned".to_string())
519 }
520 }
521
522 fn fix_table_block(
523 &self,
524 lines: &[&str],
525 table_block: &crate::utils::table_utils::TableBlock,
526 flavor: crate::config::MarkdownFlavor,
527 ) -> TableFormatResult {
528 let mut result = Vec::new();
529 let mut auto_compacted = false;
530 let mut aligned_width = None;
531
532 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
533 .chain(std::iter::once(lines[table_block.delimiter_line]))
534 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
535 .collect();
536
537 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
538 return TableFormatResult {
539 lines: table_lines.iter().map(|s| s.to_string()).collect(),
540 auto_compacted: false,
541 aligned_width: None,
542 };
543 }
544
545 let style = self.config.style.as_str();
546
547 match style {
548 "any" => {
549 let detected_style = Self::detect_table_style(&table_lines, flavor);
550 if detected_style.is_none() {
551 return TableFormatResult {
552 lines: table_lines.iter().map(|s| s.to_string()).collect(),
553 auto_compacted: false,
554 aligned_width: None,
555 };
556 }
557
558 let target_style = detected_style.unwrap();
559
560 let delimiter_cells = Self::parse_table_row_with_flavor(table_lines[1], flavor);
562 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
563
564 for line in &table_lines {
565 let cells = Self::parse_table_row_with_flavor(line, flavor);
566 match target_style.as_str() {
567 "tight" => result.push(Self::format_table_tight(&cells)),
568 "compact" => result.push(Self::format_table_compact(&cells)),
569 _ => {
570 let column_widths = Self::calculate_column_widths(&table_lines, flavor);
571 let is_delimiter = Self::is_delimiter_row(&cells);
572 result.push(Self::format_table_row(
573 &cells,
574 &column_widths,
575 &column_alignments,
576 is_delimiter,
577 ));
578 }
579 }
580 }
581 }
582 "compact" => {
583 for line in table_lines {
584 let cells = Self::parse_table_row_with_flavor(line, flavor);
585 result.push(Self::format_table_compact(&cells));
586 }
587 }
588 "tight" => {
589 for line in table_lines {
590 let cells = Self::parse_table_row_with_flavor(line, flavor);
591 result.push(Self::format_table_tight(&cells));
592 }
593 }
594 "aligned" => {
595 if Self::is_table_already_aligned(&table_lines, flavor) {
598 return TableFormatResult {
599 lines: table_lines.iter().map(|s| s.to_string()).collect(),
600 auto_compacted: false,
601 aligned_width: None,
602 };
603 }
604
605 let column_widths = Self::calculate_column_widths(&table_lines, flavor);
606
607 let num_columns = column_widths.len();
609 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
610 aligned_width = Some(calc_aligned_width);
611
612 if calc_aligned_width > self.effective_max_width() {
614 auto_compacted = true;
615 for line in table_lines {
616 let cells = Self::parse_table_row_with_flavor(line, flavor);
617 result.push(Self::format_table_compact(&cells));
618 }
619 } else {
620 let delimiter_cells = Self::parse_table_row_with_flavor(table_lines[1], flavor);
622 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
623
624 for line in table_lines {
625 let cells = Self::parse_table_row_with_flavor(line, flavor);
626 let is_delimiter = Self::is_delimiter_row(&cells);
627 result.push(Self::format_table_row(
628 &cells,
629 &column_widths,
630 &column_alignments,
631 is_delimiter,
632 ));
633 }
634 }
635 }
636 _ => {
637 return TableFormatResult {
638 lines: table_lines.iter().map(|s| s.to_string()).collect(),
639 auto_compacted: false,
640 aligned_width: None,
641 };
642 }
643 }
644
645 TableFormatResult {
646 lines: result,
647 auto_compacted,
648 aligned_width,
649 }
650 }
651}
652
653impl Rule for MD060TableFormat {
654 fn name(&self) -> &'static str {
655 "MD060"
656 }
657
658 fn description(&self) -> &'static str {
659 "Table columns should be consistently aligned"
660 }
661
662 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
663 !self.config.enabled || !ctx.likely_has_tables()
664 }
665
666 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
667 if !self.config.enabled {
668 return Ok(Vec::new());
669 }
670
671 let content = ctx.content;
672 let line_index = &ctx.line_index;
673 let mut warnings = Vec::new();
674
675 let lines: Vec<&str> = content.lines().collect();
676 let table_blocks = &ctx.table_blocks;
677
678 for table_block in table_blocks {
679 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
680
681 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
682 .chain(std::iter::once(table_block.delimiter_line))
683 .chain(table_block.content_lines.iter().copied())
684 .collect();
685
686 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());
693 for (i, &line_idx) in table_line_indices.iter().enumerate() {
694 let fixed_line = &format_result.lines[i];
695 if line_idx < lines.len() - 1 {
697 fixed_table_lines.push(format!("{fixed_line}\n"));
698 } else {
699 fixed_table_lines.push(fixed_line.clone());
700 }
701 }
702 let table_replacement = fixed_table_lines.concat();
703 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
704
705 for (i, &line_idx) in table_line_indices.iter().enumerate() {
706 let original = lines[line_idx];
707 let fixed = &format_result.lines[i];
708
709 if original != fixed {
710 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
711
712 let message = if format_result.auto_compacted {
713 if let Some(width) = format_result.aligned_width {
714 format!(
715 "Table too wide for aligned formatting ({} chars > max-width: {})",
716 width,
717 self.effective_max_width()
718 )
719 } else {
720 "Table too wide for aligned formatting".to_string()
721 }
722 } else {
723 "Table columns should be aligned".to_string()
724 };
725
726 warnings.push(LintWarning {
729 rule_name: Some(self.name().to_string()),
730 severity: Severity::Warning,
731 message,
732 line: start_line,
733 column: start_col,
734 end_line,
735 end_column: end_col,
736 fix: Some(crate::rule::Fix {
737 range: table_range.clone(),
738 replacement: table_replacement.clone(),
739 }),
740 });
741 }
742 }
743 }
744
745 Ok(warnings)
746 }
747
748 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
749 if !self.config.enabled {
750 return Ok(ctx.content.to_string());
751 }
752
753 let content = ctx.content;
754 let lines: Vec<&str> = content.lines().collect();
755 let table_blocks = &ctx.table_blocks;
756
757 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
758
759 for table_block in table_blocks {
760 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
761
762 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
763 .chain(std::iter::once(table_block.delimiter_line))
764 .chain(table_block.content_lines.iter().copied())
765 .collect();
766
767 for (i, &line_idx) in table_line_indices.iter().enumerate() {
768 result_lines[line_idx] = format_result.lines[i].clone();
769 }
770 }
771
772 let mut fixed = result_lines.join("\n");
773 if content.ends_with('\n') && !fixed.ends_with('\n') {
774 fixed.push('\n');
775 }
776 Ok(fixed)
777 }
778
779 fn as_any(&self) -> &dyn std::any::Any {
780 self
781 }
782
783 fn default_config_section(&self) -> Option<(String, toml::Value)> {
784 let json_value = serde_json::to_value(&self.config).ok()?;
785 Some((
786 self.name().to_string(),
787 crate::rule_config_serde::json_to_toml_value(&json_value)?,
788 ))
789 }
790
791 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
792 where
793 Self: Sized,
794 {
795 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
796 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
797
798 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
800
801 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::lint_context::LintContext;
809 use crate::types::LineLength;
810
811 fn md013_with_line_length(line_length: usize) -> MD013Config {
813 MD013Config {
814 line_length: LineLength::from_const(line_length),
815 tables: true, ..Default::default()
817 }
818 }
819
820 #[test]
821 fn test_md060_disabled_by_default() {
822 let rule = MD060TableFormat::default();
823 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825
826 let warnings = rule.check(&ctx).unwrap();
827 assert_eq!(warnings.len(), 0);
828
829 let fixed = rule.fix(&ctx).unwrap();
830 assert_eq!(fixed, content);
831 }
832
833 #[test]
834 fn test_md060_align_simple_ascii_table() {
835 let rule = MD060TableFormat::new(true, "aligned".to_string());
836
837 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839
840 let fixed = rule.fix(&ctx).unwrap();
841 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
842 assert_eq!(fixed, expected);
843
844 let lines: Vec<&str> = fixed.lines().collect();
846 assert_eq!(lines[0].len(), lines[1].len());
847 assert_eq!(lines[1].len(), lines[2].len());
848 }
849
850 #[test]
851 fn test_md060_cjk_characters_aligned_correctly() {
852 let rule = MD060TableFormat::new(true, "aligned".to_string());
853
854 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856
857 let fixed = rule.fix(&ctx).unwrap();
858
859 let lines: Vec<&str> = fixed.lines().collect();
860 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
861 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
862
863 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
864 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
865
866 assert_eq!(width1, width3);
867 }
868
869 #[test]
870 fn test_md060_basic_emoji() {
871 let rule = MD060TableFormat::new(true, "aligned".to_string());
872
873 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875
876 let fixed = rule.fix(&ctx).unwrap();
877 assert!(fixed.contains("Status"));
878 }
879
880 #[test]
881 fn test_md060_zwj_emoji_skipped() {
882 let rule = MD060TableFormat::new(true, "aligned".to_string());
883
884 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886
887 let fixed = rule.fix(&ctx).unwrap();
888 assert_eq!(fixed, content);
889 }
890
891 #[test]
892 fn test_md060_inline_code_with_escaped_pipes() {
893 let rule = MD060TableFormat::new(true, "aligned".to_string());
896
897 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900
901 let fixed = rule.fix(&ctx).unwrap();
902 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
903 }
904
905 #[test]
906 fn test_md060_compact_style() {
907 let rule = MD060TableFormat::new(true, "compact".to_string());
908
909 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911
912 let fixed = rule.fix(&ctx).unwrap();
913 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
914 assert_eq!(fixed, expected);
915 }
916
917 #[test]
918 fn test_md060_tight_style() {
919 let rule = MD060TableFormat::new(true, "tight".to_string());
920
921 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923
924 let fixed = rule.fix(&ctx).unwrap();
925 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
926 assert_eq!(fixed, expected);
927 }
928
929 #[test]
930 fn test_md060_any_style_consistency() {
931 let rule = MD060TableFormat::new(true, "any".to_string());
932
933 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936
937 let fixed = rule.fix(&ctx).unwrap();
938 assert_eq!(fixed, content);
939
940 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
942 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
943
944 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
945 assert_eq!(fixed_aligned, content_aligned);
946 }
947
948 #[test]
949 fn test_md060_empty_cells() {
950 let rule = MD060TableFormat::new(true, "aligned".to_string());
951
952 let content = "| A | B |\n|---|---|\n| | X |";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954
955 let fixed = rule.fix(&ctx).unwrap();
956 assert!(fixed.contains("|"));
957 }
958
959 #[test]
960 fn test_md060_mixed_content() {
961 let rule = MD060TableFormat::new(true, "aligned".to_string());
962
963 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965
966 let fixed = rule.fix(&ctx).unwrap();
967 assert!(fixed.contains("δΈζ"));
968 assert!(fixed.contains("NYC"));
969 }
970
971 #[test]
972 fn test_md060_preserve_alignment_indicators() {
973 let rule = MD060TableFormat::new(true, "aligned".to_string());
974
975 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977
978 let fixed = rule.fix(&ctx).unwrap();
979
980 assert!(fixed.contains(":---"), "Should contain left alignment");
981 assert!(fixed.contains(":----:"), "Should contain center alignment");
982 assert!(fixed.contains("----:"), "Should contain right alignment");
983 }
984
985 #[test]
986 fn test_md060_minimum_column_width() {
987 let rule = MD060TableFormat::new(true, "aligned".to_string());
988
989 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993
994 let fixed = rule.fix(&ctx).unwrap();
995
996 let lines: Vec<&str> = fixed.lines().collect();
997 assert_eq!(lines[0].len(), lines[1].len());
998 assert_eq!(lines[1].len(), lines[2].len());
999
1000 assert!(fixed.contains("ID "), "Short content should be padded");
1002 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1003 }
1004
1005 #[test]
1006 fn test_md060_auto_compact_exceeds_default_threshold() {
1007 let config = MD060Config {
1009 enabled: true,
1010 style: "aligned".to_string(),
1011 max_width: LineLength::from_const(0),
1012 };
1013 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1014
1015 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020
1021 let fixed = rule.fix(&ctx).unwrap();
1022
1023 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1025 assert!(fixed.contains("| --- | --- | --- |"));
1026 assert!(fixed.contains("| Short | Data | Here |"));
1027
1028 let lines: Vec<&str> = fixed.lines().collect();
1030 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1032 }
1033
1034 #[test]
1035 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1036 let config = MD060Config {
1038 enabled: true,
1039 style: "aligned".to_string(),
1040 max_width: LineLength::from_const(50),
1041 };
1042 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 |";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049
1050 let fixed = rule.fix(&ctx).unwrap();
1051
1052 assert!(
1054 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1055 );
1056 assert!(fixed.contains("| --- | --- | --- |"));
1057 assert!(fixed.contains("| Data | Data | Data |"));
1058
1059 let lines: Vec<&str> = fixed.lines().collect();
1061 assert!(lines[0].len() != lines[2].len());
1062 }
1063
1064 #[test]
1065 fn test_md060_stays_aligned_under_threshold() {
1066 let config = MD060Config {
1068 enabled: true,
1069 style: "aligned".to_string(),
1070 max_width: LineLength::from_const(100),
1071 };
1072 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1073
1074 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077
1078 let fixed = rule.fix(&ctx).unwrap();
1079
1080 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1082 assert_eq!(fixed, expected);
1083
1084 let lines: Vec<&str> = fixed.lines().collect();
1085 assert_eq!(lines[0].len(), lines[1].len());
1086 assert_eq!(lines[1].len(), lines[2].len());
1087 }
1088
1089 #[test]
1090 fn test_md060_width_calculation_formula() {
1091 let config = MD060Config {
1093 enabled: true,
1094 style: "aligned".to_string(),
1095 max_width: LineLength::from_const(0),
1096 };
1097 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1098
1099 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104
1105 let fixed = rule.fix(&ctx).unwrap();
1106
1107 let lines: Vec<&str> = fixed.lines().collect();
1109 assert_eq!(lines[0].len(), lines[1].len());
1110 assert_eq!(lines[1].len(), lines[2].len());
1111 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1115 enabled: true,
1116 style: "aligned".to_string(),
1117 max_width: LineLength::from_const(24),
1118 };
1119 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1120
1121 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1122
1123 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1125 assert!(fixed_compact.contains("| --- | --- | --- |"));
1126 }
1127
1128 #[test]
1129 fn test_md060_very_wide_table_auto_compacts() {
1130 let config = MD060Config {
1131 enabled: true,
1132 style: "aligned".to_string(),
1133 max_width: LineLength::from_const(0),
1134 };
1135 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1136
1137 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 |";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142
1143 let fixed = rule.fix(&ctx).unwrap();
1144
1145 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1147 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1148 }
1149
1150 #[test]
1151 fn test_md060_inherit_from_md013_line_length() {
1152 let config = MD060Config {
1154 enabled: true,
1155 style: "aligned".to_string(),
1156 max_width: LineLength::from_const(0), };
1158
1159 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1161 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1162
1163 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166
1167 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1169
1170 let fixed_120 = rule_120.fix(&ctx).unwrap();
1172
1173 let lines_120: Vec<&str> = fixed_120.lines().collect();
1175 assert_eq!(lines_120[0].len(), lines_120[1].len());
1176 assert_eq!(lines_120[1].len(), lines_120[2].len());
1177 }
1178
1179 #[test]
1180 fn test_md060_edge_case_exactly_at_threshold() {
1181 let config = MD060Config {
1185 enabled: true,
1186 style: "aligned".to_string(),
1187 max_width: LineLength::from_const(17),
1188 };
1189 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1190
1191 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193
1194 let fixed = rule.fix(&ctx).unwrap();
1195
1196 let lines: Vec<&str> = fixed.lines().collect();
1198 assert_eq!(lines[0].len(), 17);
1199 assert_eq!(lines[0].len(), lines[1].len());
1200 assert_eq!(lines[1].len(), lines[2].len());
1201
1202 let config_under = MD060Config {
1204 enabled: true,
1205 style: "aligned".to_string(),
1206 max_width: LineLength::from_const(16),
1207 };
1208 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1209
1210 let fixed_compact = rule_under.fix(&ctx).unwrap();
1211
1212 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1214 assert!(fixed_compact.contains("| --- | --- |"));
1215 }
1216
1217 #[test]
1218 fn test_md060_auto_compact_warning_message() {
1219 let config = MD060Config {
1221 enabled: true,
1222 style: "aligned".to_string(),
1223 max_width: LineLength::from_const(50),
1224 };
1225 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1226
1227 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230
1231 let warnings = rule.check(&ctx).unwrap();
1232
1233 assert!(!warnings.is_empty(), "Should generate warnings");
1235
1236 let auto_compact_warnings: Vec<_> = warnings
1237 .iter()
1238 .filter(|w| w.message.contains("too wide for aligned formatting"))
1239 .collect();
1240
1241 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1242
1243 let first_warning = auto_compact_warnings[0];
1245 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1246 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1247 }
1248
1249 #[test]
1250 fn test_md060_issue_129_detect_style_from_all_rows() {
1251 let rule = MD060TableFormat::new(true, "any".to_string());
1255
1256 let content = "| a long heading | another long heading |\n\
1258 | -------------- | -------------------- |\n\
1259 | a | 1 |\n\
1260 | b b | 2 |\n\
1261 | c c c | 3 |";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263
1264 let fixed = rule.fix(&ctx).unwrap();
1265
1266 assert!(
1268 fixed.contains("| a | 1 |"),
1269 "Should preserve aligned padding in first content row"
1270 );
1271 assert!(
1272 fixed.contains("| b b | 2 |"),
1273 "Should preserve aligned padding in second content row"
1274 );
1275 assert!(
1276 fixed.contains("| c c c | 3 |"),
1277 "Should preserve aligned padding in third content row"
1278 );
1279
1280 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1282 }
1283
1284 #[test]
1285 fn test_md060_regular_alignment_warning_message() {
1286 let config = MD060Config {
1288 enabled: true,
1289 style: "aligned".to_string(),
1290 max_width: LineLength::from_const(100), };
1292 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1293
1294 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297
1298 let warnings = rule.check(&ctx).unwrap();
1299
1300 assert!(!warnings.is_empty(), "Should generate warnings");
1302
1303 assert!(warnings[0].message.contains("Table columns should be aligned"));
1305 assert!(!warnings[0].message.contains("too wide"));
1306 assert!(!warnings[0].message.contains("max-width"));
1307 }
1308
1309 #[test]
1312 fn test_md060_unlimited_when_md013_disabled() {
1313 let config = MD060Config {
1315 enabled: true,
1316 style: "aligned".to_string(),
1317 max_width: LineLength::from_const(0), };
1319 let md013_config = MD013Config::default();
1320 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1321
1322 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let fixed = rule.fix(&ctx).unwrap();
1326
1327 let lines: Vec<&str> = fixed.lines().collect();
1329 assert_eq!(
1331 lines[0].len(),
1332 lines[1].len(),
1333 "Table should be aligned when MD013 is disabled"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_md060_unlimited_when_md013_tables_false() {
1339 let config = MD060Config {
1341 enabled: true,
1342 style: "aligned".to_string(),
1343 max_width: LineLength::from_const(0),
1344 };
1345 let md013_config = MD013Config {
1346 tables: false, line_length: LineLength::from_const(80),
1348 ..Default::default()
1349 };
1350 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1351
1352 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let fixed = rule.fix(&ctx).unwrap();
1356
1357 let lines: Vec<&str> = fixed.lines().collect();
1359 assert_eq!(
1360 lines[0].len(),
1361 lines[1].len(),
1362 "Table should be aligned when MD013.tables=false"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_md060_unlimited_when_md013_line_length_zero() {
1368 let config = MD060Config {
1370 enabled: true,
1371 style: "aligned".to_string(),
1372 max_width: LineLength::from_const(0),
1373 };
1374 let md013_config = MD013Config {
1375 tables: true,
1376 line_length: LineLength::from_const(0), ..Default::default()
1378 };
1379 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1380
1381 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384 let fixed = rule.fix(&ctx).unwrap();
1385
1386 let lines: Vec<&str> = fixed.lines().collect();
1388 assert_eq!(
1389 lines[0].len(),
1390 lines[1].len(),
1391 "Table should be aligned when MD013.line_length=0"
1392 );
1393 }
1394
1395 #[test]
1396 fn test_md060_explicit_max_width_overrides_md013_settings() {
1397 let config = MD060Config {
1399 enabled: true,
1400 style: "aligned".to_string(),
1401 max_width: LineLength::from_const(50), };
1403 let md013_config = MD013Config {
1404 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1407 };
1408 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1409
1410 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 let fixed = rule.fix(&ctx).unwrap();
1414
1415 assert!(
1417 fixed.contains("| --- |"),
1418 "Should be compact format due to explicit max_width"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1424 let config = MD060Config {
1426 enabled: true,
1427 style: "aligned".to_string(),
1428 max_width: LineLength::from_const(0), };
1430 let md013_config = MD013Config {
1431 tables: true,
1432 line_length: LineLength::from_const(50), ..Default::default()
1434 };
1435 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1436
1437 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let fixed = rule.fix(&ctx).unwrap();
1441
1442 assert!(
1444 fixed.contains("| --- |"),
1445 "Should be compact format when inheriting MD013 limit"
1446 );
1447 }
1448}