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)]
143pub struct MD060TableFormat {
144 config: MD060Config,
145 md013_line_length: usize,
146}
147
148impl Default for MD060TableFormat {
149 fn default() -> Self {
150 Self {
151 config: MD060Config::default(),
152 md013_line_length: 80,
153 }
154 }
155}
156
157impl MD060TableFormat {
158 pub fn new(enabled: bool, style: String) -> Self {
159 use crate::types::LineLength;
160 Self {
161 config: MD060Config {
162 enabled,
163 style,
164 max_width: LineLength::from_const(0),
165 },
166 md013_line_length: 80, }
168 }
169
170 pub fn from_config_struct(config: MD060Config, md013_line_length: usize) -> Self {
171 Self {
172 config,
173 md013_line_length,
174 }
175 }
176
177 fn effective_max_width(&self) -> usize {
182 if self.config.max_width.is_unlimited() {
183 self.md013_line_length
184 } else {
185 self.config.max_width.get()
186 }
187 }
188
189 fn contains_problematic_chars(text: &str) -> bool {
200 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
205
206 fn calculate_cell_display_width(cell_content: &str) -> usize {
207 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
208 masked.trim().width()
209 }
210
211 #[cfg(test)]
214 fn parse_table_row(line: &str) -> Vec<String> {
215 TableUtils::split_table_row(line)
216 }
217
218 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
223 TableUtils::split_table_row_with_flavor(line, flavor)
224 }
225
226 fn is_delimiter_row(row: &[String]) -> bool {
227 if row.is_empty() {
228 return false;
229 }
230 row.iter().all(|cell| {
231 let trimmed = cell.trim();
232 !trimmed.is_empty()
235 && trimmed.contains('-')
236 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
237 })
238 }
239
240 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
241 delimiter_row
242 .iter()
243 .map(|cell| {
244 let trimmed = cell.trim();
245 let has_left_colon = trimmed.starts_with(':');
246 let has_right_colon = trimmed.ends_with(':');
247
248 match (has_left_colon, has_right_colon) {
249 (true, true) => ColumnAlignment::Center,
250 (false, true) => ColumnAlignment::Right,
251 _ => ColumnAlignment::Left,
252 }
253 })
254 .collect()
255 }
256
257 fn calculate_column_widths(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Vec<usize> {
258 let mut column_widths = Vec::new();
259 let mut delimiter_cells: Option<Vec<String>> = None;
260
261 for line in table_lines {
262 let cells = Self::parse_table_row_with_flavor(line, flavor);
263
264 if Self::is_delimiter_row(&cells) {
266 delimiter_cells = Some(cells);
267 continue;
268 }
269
270 for (i, cell) in cells.iter().enumerate() {
271 let width = Self::calculate_cell_display_width(cell);
272 if i >= column_widths.len() {
273 column_widths.push(width);
274 } else {
275 column_widths[i] = column_widths[i].max(width);
276 }
277 }
278 }
279
280 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
283
284 if let Some(delimiter_cells) = delimiter_cells {
287 for (i, cell) in delimiter_cells.iter().enumerate() {
288 if i < final_widths.len() {
289 let trimmed = cell.trim();
290 let has_left_colon = trimmed.starts_with(':');
291 let has_right_colon = trimmed.ends_with(':');
292 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
293
294 let min_width_for_delimiter = 3 + colon_count;
296 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
297 }
298 }
299 }
300
301 final_widths
302 }
303
304 fn format_table_row(
305 cells: &[String],
306 column_widths: &[usize],
307 column_alignments: &[ColumnAlignment],
308 is_delimiter: bool,
309 ) -> String {
310 let formatted_cells: Vec<String> = cells
311 .iter()
312 .enumerate()
313 .map(|(i, cell)| {
314 let target_width = column_widths.get(i).copied().unwrap_or(0);
315 if is_delimiter {
316 let trimmed = cell.trim();
317 let has_left_colon = trimmed.starts_with(':');
318 let has_right_colon = trimmed.ends_with(':');
319
320 let dash_count = if has_left_colon && has_right_colon {
323 target_width.saturating_sub(2)
324 } else if has_left_colon || has_right_colon {
325 target_width.saturating_sub(1)
326 } else {
327 target_width
328 };
329
330 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
332 format!(":{dashes}:")
333 } else if has_left_colon {
334 format!(":{dashes}")
335 } else if has_right_colon {
336 format!("{dashes}:")
337 } else {
338 dashes
339 };
340
341 format!(" {delimiter_content} ")
343 } else {
344 let trimmed = cell.trim();
345 let current_width = Self::calculate_cell_display_width(cell);
346 let padding = target_width.saturating_sub(current_width);
347
348 let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
350 match alignment {
351 ColumnAlignment::Left => {
352 format!(" {trimmed}{} ", " ".repeat(padding))
354 }
355 ColumnAlignment::Center => {
356 let left_padding = padding / 2;
358 let right_padding = padding - left_padding;
359 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
360 }
361 ColumnAlignment::Right => {
362 format!(" {}{trimmed} ", " ".repeat(padding))
364 }
365 }
366 }
367 })
368 .collect();
369
370 format!("|{}|", formatted_cells.join("|"))
371 }
372
373 fn format_table_compact(cells: &[String]) -> String {
374 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
375 format!("|{}|", formatted_cells.join("|"))
376 }
377
378 fn format_table_tight(cells: &[String]) -> String {
379 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
380 format!("|{}|", formatted_cells.join("|"))
381 }
382
383 fn is_table_already_aligned(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> bool {
390 if table_lines.len() < 2 {
391 return false;
392 }
393
394 let first_len = table_lines[0].len();
396 if !table_lines.iter().all(|line| line.len() == first_len) {
397 return false;
398 }
399
400 let parsed: Vec<Vec<String>> = table_lines
402 .iter()
403 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
404 .collect();
405
406 if parsed.is_empty() {
407 return false;
408 }
409
410 let num_columns = parsed[0].len();
411 if !parsed.iter().all(|row| row.len() == num_columns) {
412 return false;
413 }
414
415 if let Some(delimiter_row) = parsed.get(1) {
418 if !Self::is_delimiter_row(delimiter_row) {
419 return false;
420 }
421 for cell in delimiter_row {
423 let trimmed = cell.trim();
424 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
425 if dash_count < 1 {
426 return false;
427 }
428 }
429 }
430
431 for col_idx in 0..num_columns {
433 let mut widths = Vec::new();
434 for (row_idx, row) in parsed.iter().enumerate() {
435 if row_idx == 1 {
437 continue;
438 }
439 if let Some(cell) = row.get(col_idx) {
440 widths.push(cell.len());
441 }
442 }
443 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
445 return false;
446 }
447 }
448
449 true
450 }
451
452 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
453 if table_lines.is_empty() {
454 return None;
455 }
456
457 let mut is_tight = true;
460 let mut is_compact = true;
461
462 for line in table_lines {
463 let cells = Self::parse_table_row_with_flavor(line, flavor);
464
465 if cells.is_empty() {
466 continue;
467 }
468
469 if Self::is_delimiter_row(&cells) {
471 continue;
472 }
473
474 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
476
477 let row_has_single_space = cells.iter().all(|cell| {
479 let trimmed = cell.trim();
480 cell == &format!(" {trimmed} ")
481 });
482
483 if !row_has_no_padding {
485 is_tight = false;
486 }
487
488 if !row_has_single_space {
490 is_compact = false;
491 }
492
493 if !is_tight && !is_compact {
495 return Some("aligned".to_string());
496 }
497 }
498
499 if is_tight {
501 Some("tight".to_string())
502 } else if is_compact {
503 Some("compact".to_string())
504 } else {
505 Some("aligned".to_string())
506 }
507 }
508
509 fn fix_table_block(
510 &self,
511 lines: &[&str],
512 table_block: &crate::utils::table_utils::TableBlock,
513 flavor: crate::config::MarkdownFlavor,
514 ) -> TableFormatResult {
515 let mut result = Vec::new();
516 let mut auto_compacted = false;
517 let mut aligned_width = None;
518
519 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
520 .chain(std::iter::once(lines[table_block.delimiter_line]))
521 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
522 .collect();
523
524 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
525 return TableFormatResult {
526 lines: table_lines.iter().map(|s| s.to_string()).collect(),
527 auto_compacted: false,
528 aligned_width: None,
529 };
530 }
531
532 let style = self.config.style.as_str();
533
534 match style {
535 "any" => {
536 let detected_style = Self::detect_table_style(&table_lines, flavor);
537 if detected_style.is_none() {
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 target_style = detected_style.unwrap();
546
547 let delimiter_cells = Self::parse_table_row_with_flavor(table_lines[1], flavor);
549 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
550
551 for line in &table_lines {
552 let cells = Self::parse_table_row_with_flavor(line, flavor);
553 match target_style.as_str() {
554 "tight" => result.push(Self::format_table_tight(&cells)),
555 "compact" => result.push(Self::format_table_compact(&cells)),
556 _ => {
557 let column_widths = Self::calculate_column_widths(&table_lines, flavor);
558 let is_delimiter = Self::is_delimiter_row(&cells);
559 result.push(Self::format_table_row(
560 &cells,
561 &column_widths,
562 &column_alignments,
563 is_delimiter,
564 ));
565 }
566 }
567 }
568 }
569 "compact" => {
570 for line in table_lines {
571 let cells = Self::parse_table_row_with_flavor(line, flavor);
572 result.push(Self::format_table_compact(&cells));
573 }
574 }
575 "tight" => {
576 for line in table_lines {
577 let cells = Self::parse_table_row_with_flavor(line, flavor);
578 result.push(Self::format_table_tight(&cells));
579 }
580 }
581 "aligned" => {
582 if Self::is_table_already_aligned(&table_lines, flavor) {
585 return TableFormatResult {
586 lines: table_lines.iter().map(|s| s.to_string()).collect(),
587 auto_compacted: false,
588 aligned_width: None,
589 };
590 }
591
592 let column_widths = Self::calculate_column_widths(&table_lines, flavor);
593
594 let num_columns = column_widths.len();
596 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
597 aligned_width = Some(calc_aligned_width);
598
599 if calc_aligned_width > self.effective_max_width() {
601 auto_compacted = true;
602 for line in table_lines {
603 let cells = Self::parse_table_row_with_flavor(line, flavor);
604 result.push(Self::format_table_compact(&cells));
605 }
606 } else {
607 let delimiter_cells = Self::parse_table_row_with_flavor(table_lines[1], flavor);
609 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
610
611 for line in table_lines {
612 let cells = Self::parse_table_row_with_flavor(line, flavor);
613 let is_delimiter = Self::is_delimiter_row(&cells);
614 result.push(Self::format_table_row(
615 &cells,
616 &column_widths,
617 &column_alignments,
618 is_delimiter,
619 ));
620 }
621 }
622 }
623 _ => {
624 return TableFormatResult {
625 lines: table_lines.iter().map(|s| s.to_string()).collect(),
626 auto_compacted: false,
627 aligned_width: None,
628 };
629 }
630 }
631
632 TableFormatResult {
633 lines: result,
634 auto_compacted,
635 aligned_width,
636 }
637 }
638}
639
640impl Rule for MD060TableFormat {
641 fn name(&self) -> &'static str {
642 "MD060"
643 }
644
645 fn description(&self) -> &'static str {
646 "Table columns should be consistently aligned"
647 }
648
649 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
650 !self.config.enabled || !ctx.likely_has_tables()
651 }
652
653 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
654 if !self.config.enabled {
655 return Ok(Vec::new());
656 }
657
658 let content = ctx.content;
659 let line_index = &ctx.line_index;
660 let mut warnings = Vec::new();
661
662 let lines: Vec<&str> = content.lines().collect();
663 let table_blocks = &ctx.table_blocks;
664
665 for table_block in table_blocks {
666 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
667
668 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
669 .chain(std::iter::once(table_block.delimiter_line))
670 .chain(table_block.content_lines.iter().copied())
671 .collect();
672
673 for (i, &line_idx) in table_line_indices.iter().enumerate() {
674 let original = lines[line_idx];
675 let fixed = &format_result.lines[i];
676
677 if original != fixed {
678 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
679
680 let message = if format_result.auto_compacted {
681 if let Some(width) = format_result.aligned_width {
682 format!(
683 "Table too wide for aligned formatting ({} chars > max-width: {})",
684 width,
685 self.effective_max_width()
686 )
687 } else {
688 "Table too wide for aligned formatting".to_string()
689 }
690 } else {
691 "Table columns should be aligned".to_string()
692 };
693
694 warnings.push(LintWarning {
695 rule_name: Some(self.name().to_string()),
696 severity: Severity::Warning,
697 message,
698 line: start_line,
699 column: start_col,
700 end_line,
701 end_column: end_col,
702 fix: Some(crate::rule::Fix {
703 range: line_index.whole_line_range(line_idx + 1),
704 replacement: if line_idx < lines.len() - 1 {
705 format!("{fixed}\n")
706 } else {
707 fixed.clone()
708 },
709 }),
710 });
711 }
712 }
713 }
714
715 Ok(warnings)
716 }
717
718 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
719 if !self.config.enabled {
720 return Ok(ctx.content.to_string());
721 }
722
723 let content = ctx.content;
724 let lines: Vec<&str> = content.lines().collect();
725 let table_blocks = &ctx.table_blocks;
726
727 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
728
729 for table_block in table_blocks {
730 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
731
732 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
733 .chain(std::iter::once(table_block.delimiter_line))
734 .chain(table_block.content_lines.iter().copied())
735 .collect();
736
737 for (i, &line_idx) in table_line_indices.iter().enumerate() {
738 result_lines[line_idx] = format_result.lines[i].clone();
739 }
740 }
741
742 let mut fixed = result_lines.join("\n");
743 if content.ends_with('\n') && !fixed.ends_with('\n') {
744 fixed.push('\n');
745 }
746 Ok(fixed)
747 }
748
749 fn as_any(&self) -> &dyn std::any::Any {
750 self
751 }
752
753 fn default_config_section(&self) -> Option<(String, toml::Value)> {
754 let json_value = serde_json::to_value(&self.config).ok()?;
755 Some((
756 self.name().to_string(),
757 crate::rule_config_serde::json_to_toml_value(&json_value)?,
758 ))
759 }
760
761 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
762 where
763 Self: Sized,
764 {
765 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
766 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
767 Box::new(Self::from_config_struct(rule_config, md013_config.line_length.get()))
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use crate::lint_context::LintContext;
775 use crate::types::LineLength;
776
777 #[test]
778 fn test_md060_disabled_by_default() {
779 let rule = MD060TableFormat::default();
780 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
782
783 let warnings = rule.check(&ctx).unwrap();
784 assert_eq!(warnings.len(), 0);
785
786 let fixed = rule.fix(&ctx).unwrap();
787 assert_eq!(fixed, content);
788 }
789
790 #[test]
791 fn test_md060_align_simple_ascii_table() {
792 let rule = MD060TableFormat::new(true, "aligned".to_string());
793
794 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
796
797 let fixed = rule.fix(&ctx).unwrap();
798 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
799 assert_eq!(fixed, expected);
800
801 let lines: Vec<&str> = fixed.lines().collect();
803 assert_eq!(lines[0].len(), lines[1].len());
804 assert_eq!(lines[1].len(), lines[2].len());
805 }
806
807 #[test]
808 fn test_md060_cjk_characters_aligned_correctly() {
809 let rule = MD060TableFormat::new(true, "aligned".to_string());
810
811 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
813
814 let fixed = rule.fix(&ctx).unwrap();
815
816 let lines: Vec<&str> = fixed.lines().collect();
817 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
818 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
819
820 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
821 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
822
823 assert_eq!(width1, width3);
824 }
825
826 #[test]
827 fn test_md060_basic_emoji() {
828 let rule = MD060TableFormat::new(true, "aligned".to_string());
829
830 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
832
833 let fixed = rule.fix(&ctx).unwrap();
834 assert!(fixed.contains("Status"));
835 }
836
837 #[test]
838 fn test_md060_zwj_emoji_skipped() {
839 let rule = MD060TableFormat::new(true, "aligned".to_string());
840
841 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
843
844 let fixed = rule.fix(&ctx).unwrap();
845 assert_eq!(fixed, content);
846 }
847
848 #[test]
849 fn test_md060_inline_code_with_escaped_pipes() {
850 let rule = MD060TableFormat::new(true, "aligned".to_string());
853
854 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
857
858 let fixed = rule.fix(&ctx).unwrap();
859 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
860 }
861
862 #[test]
863 fn test_md060_compact_style() {
864 let rule = MD060TableFormat::new(true, "compact".to_string());
865
866 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
868
869 let fixed = rule.fix(&ctx).unwrap();
870 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
871 assert_eq!(fixed, expected);
872 }
873
874 #[test]
875 fn test_md060_tight_style() {
876 let rule = MD060TableFormat::new(true, "tight".to_string());
877
878 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
880
881 let fixed = rule.fix(&ctx).unwrap();
882 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
883 assert_eq!(fixed, expected);
884 }
885
886 #[test]
887 fn test_md060_any_style_consistency() {
888 let rule = MD060TableFormat::new(true, "any".to_string());
889
890 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
893
894 let fixed = rule.fix(&ctx).unwrap();
895 assert_eq!(fixed, content);
896
897 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
899 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard);
900
901 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
902 assert_eq!(fixed_aligned, content_aligned);
903 }
904
905 #[test]
906 fn test_md060_empty_cells() {
907 let rule = MD060TableFormat::new(true, "aligned".to_string());
908
909 let content = "| A | B |\n|---|---|\n| | X |";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911
912 let fixed = rule.fix(&ctx).unwrap();
913 assert!(fixed.contains("|"));
914 }
915
916 #[test]
917 fn test_md060_mixed_content() {
918 let rule = MD060TableFormat::new(true, "aligned".to_string());
919
920 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
922
923 let fixed = rule.fix(&ctx).unwrap();
924 assert!(fixed.contains("δΈζ"));
925 assert!(fixed.contains("NYC"));
926 }
927
928 #[test]
929 fn test_md060_preserve_alignment_indicators() {
930 let rule = MD060TableFormat::new(true, "aligned".to_string());
931
932 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
934
935 let fixed = rule.fix(&ctx).unwrap();
936
937 assert!(fixed.contains(":---"), "Should contain left alignment");
938 assert!(fixed.contains(":----:"), "Should contain center alignment");
939 assert!(fixed.contains("----:"), "Should contain right alignment");
940 }
941
942 #[test]
943 fn test_md060_minimum_column_width() {
944 let rule = MD060TableFormat::new(true, "aligned".to_string());
945
946 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
950
951 let fixed = rule.fix(&ctx).unwrap();
952
953 let lines: Vec<&str> = fixed.lines().collect();
954 assert_eq!(lines[0].len(), lines[1].len());
955 assert_eq!(lines[1].len(), lines[2].len());
956
957 assert!(fixed.contains("ID "), "Short content should be padded");
959 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
960 }
961
962 #[test]
963 fn test_md060_auto_compact_exceeds_default_threshold() {
964 let config = MD060Config {
966 enabled: true,
967 style: "aligned".to_string(),
968 max_width: LineLength::from_const(0),
969 };
970 let rule = MD060TableFormat::from_config_struct(config, 80);
971
972 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
977
978 let fixed = rule.fix(&ctx).unwrap();
979
980 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
982 assert!(fixed.contains("| --- | --- | --- |"));
983 assert!(fixed.contains("| Short | Data | Here |"));
984
985 let lines: Vec<&str> = fixed.lines().collect();
987 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
989 }
990
991 #[test]
992 fn test_md060_auto_compact_exceeds_explicit_threshold() {
993 let config = MD060Config {
995 enabled: true,
996 style: "aligned".to_string(),
997 max_width: LineLength::from_const(50),
998 };
999 let rule = MD060TableFormat::from_config_struct(config, 80); let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1006
1007 let fixed = rule.fix(&ctx).unwrap();
1008
1009 assert!(
1011 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1012 );
1013 assert!(fixed.contains("| --- | --- | --- |"));
1014 assert!(fixed.contains("| Data | Data | Data |"));
1015
1016 let lines: Vec<&str> = fixed.lines().collect();
1018 assert!(lines[0].len() != lines[2].len());
1019 }
1020
1021 #[test]
1022 fn test_md060_stays_aligned_under_threshold() {
1023 let config = MD060Config {
1025 enabled: true,
1026 style: "aligned".to_string(),
1027 max_width: LineLength::from_const(100),
1028 };
1029 let rule = MD060TableFormat::from_config_struct(config, 80);
1030
1031 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1034
1035 let fixed = rule.fix(&ctx).unwrap();
1036
1037 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1039 assert_eq!(fixed, expected);
1040
1041 let lines: Vec<&str> = fixed.lines().collect();
1042 assert_eq!(lines[0].len(), lines[1].len());
1043 assert_eq!(lines[1].len(), lines[2].len());
1044 }
1045
1046 #[test]
1047 fn test_md060_width_calculation_formula() {
1048 let config = MD060Config {
1050 enabled: true,
1051 style: "aligned".to_string(),
1052 max_width: LineLength::from_const(0),
1053 };
1054 let rule = MD060TableFormat::from_config_struct(config, 30);
1055
1056 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1061
1062 let fixed = rule.fix(&ctx).unwrap();
1063
1064 let lines: Vec<&str> = fixed.lines().collect();
1066 assert_eq!(lines[0].len(), lines[1].len());
1067 assert_eq!(lines[1].len(), lines[2].len());
1068 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1072 enabled: true,
1073 style: "aligned".to_string(),
1074 max_width: LineLength::from_const(24),
1075 };
1076 let rule_tight = MD060TableFormat::from_config_struct(config_tight, 80);
1077
1078 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1079
1080 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1082 assert!(fixed_compact.contains("| --- | --- | --- |"));
1083 }
1084
1085 #[test]
1086 fn test_md060_very_wide_table_auto_compacts() {
1087 let config = MD060Config {
1088 enabled: true,
1089 style: "aligned".to_string(),
1090 max_width: LineLength::from_const(0),
1091 };
1092 let rule = MD060TableFormat::from_config_struct(config, 80);
1093
1094 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 |";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1099
1100 let fixed = rule.fix(&ctx).unwrap();
1101
1102 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1104 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1105 }
1106
1107 #[test]
1108 fn test_md060_inherit_from_md013_line_length() {
1109 let config = MD060Config {
1111 enabled: true,
1112 style: "aligned".to_string(),
1113 max_width: LineLength::from_const(0), };
1115
1116 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), 80);
1118 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), 120);
1119
1120 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1123
1124 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1126
1127 let fixed_120 = rule_120.fix(&ctx).unwrap();
1129
1130 let lines_120: Vec<&str> = fixed_120.lines().collect();
1132 assert_eq!(lines_120[0].len(), lines_120[1].len());
1133 assert_eq!(lines_120[1].len(), lines_120[2].len());
1134 }
1135
1136 #[test]
1137 fn test_md060_edge_case_exactly_at_threshold() {
1138 let config = MD060Config {
1142 enabled: true,
1143 style: "aligned".to_string(),
1144 max_width: LineLength::from_const(17),
1145 };
1146 let rule = MD060TableFormat::from_config_struct(config, 80);
1147
1148 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1150
1151 let fixed = rule.fix(&ctx).unwrap();
1152
1153 let lines: Vec<&str> = fixed.lines().collect();
1155 assert_eq!(lines[0].len(), 17);
1156 assert_eq!(lines[0].len(), lines[1].len());
1157 assert_eq!(lines[1].len(), lines[2].len());
1158
1159 let config_under = MD060Config {
1161 enabled: true,
1162 style: "aligned".to_string(),
1163 max_width: LineLength::from_const(16),
1164 };
1165 let rule_under = MD060TableFormat::from_config_struct(config_under, 80);
1166
1167 let fixed_compact = rule_under.fix(&ctx).unwrap();
1168
1169 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1171 assert!(fixed_compact.contains("| --- | --- |"));
1172 }
1173
1174 #[test]
1175 fn test_md060_auto_compact_warning_message() {
1176 let config = MD060Config {
1178 enabled: true,
1179 style: "aligned".to_string(),
1180 max_width: LineLength::from_const(50),
1181 };
1182 let rule = MD060TableFormat::from_config_struct(config, 80);
1183
1184 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1187
1188 let warnings = rule.check(&ctx).unwrap();
1189
1190 assert!(!warnings.is_empty(), "Should generate warnings");
1192
1193 let auto_compact_warnings: Vec<_> = warnings
1194 .iter()
1195 .filter(|w| w.message.contains("too wide for aligned formatting"))
1196 .collect();
1197
1198 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1199
1200 let first_warning = auto_compact_warnings[0];
1202 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1203 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1204 }
1205
1206 #[test]
1207 fn test_md060_issue_129_detect_style_from_all_rows() {
1208 let rule = MD060TableFormat::new(true, "any".to_string());
1212
1213 let content = "| a long heading | another long heading |\n\
1215 | -------------- | -------------------- |\n\
1216 | a | 1 |\n\
1217 | b b | 2 |\n\
1218 | c c c | 3 |";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1220
1221 let fixed = rule.fix(&ctx).unwrap();
1222
1223 assert!(
1225 fixed.contains("| a | 1 |"),
1226 "Should preserve aligned padding in first content row"
1227 );
1228 assert!(
1229 fixed.contains("| b b | 2 |"),
1230 "Should preserve aligned padding in second content row"
1231 );
1232 assert!(
1233 fixed.contains("| c c c | 3 |"),
1234 "Should preserve aligned padding in third content row"
1235 );
1236
1237 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1239 }
1240
1241 #[test]
1242 fn test_md060_regular_alignment_warning_message() {
1243 let config = MD060Config {
1245 enabled: true,
1246 style: "aligned".to_string(),
1247 max_width: LineLength::from_const(100), };
1249 let rule = MD060TableFormat::from_config_struct(config, 80);
1250
1251 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1254
1255 let warnings = rule.check(&ctx).unwrap();
1256
1257 assert!(!warnings.is_empty(), "Should generate warnings");
1259
1260 assert!(warnings[0].message.contains("Table columns should be aligned"));
1262 assert!(!warnings[0].message.contains("too wide"));
1263 assert!(!warnings[0].message.contains("max-width"));
1264 }
1265}