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 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());
680 for (i, &line_idx) in table_line_indices.iter().enumerate() {
681 let fixed_line = &format_result.lines[i];
682 if line_idx < lines.len() - 1 {
684 fixed_table_lines.push(format!("{fixed_line}\n"));
685 } else {
686 fixed_table_lines.push(fixed_line.clone());
687 }
688 }
689 let table_replacement = fixed_table_lines.concat();
690 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
691
692 for (i, &line_idx) in table_line_indices.iter().enumerate() {
693 let original = lines[line_idx];
694 let fixed = &format_result.lines[i];
695
696 if original != fixed {
697 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
698
699 let message = if format_result.auto_compacted {
700 if let Some(width) = format_result.aligned_width {
701 format!(
702 "Table too wide for aligned formatting ({} chars > max-width: {})",
703 width,
704 self.effective_max_width()
705 )
706 } else {
707 "Table too wide for aligned formatting".to_string()
708 }
709 } else {
710 "Table columns should be aligned".to_string()
711 };
712
713 warnings.push(LintWarning {
716 rule_name: Some(self.name().to_string()),
717 severity: Severity::Warning,
718 message,
719 line: start_line,
720 column: start_col,
721 end_line,
722 end_column: end_col,
723 fix: Some(crate::rule::Fix {
724 range: table_range.clone(),
725 replacement: table_replacement.clone(),
726 }),
727 });
728 }
729 }
730 }
731
732 Ok(warnings)
733 }
734
735 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
736 if !self.config.enabled {
737 return Ok(ctx.content.to_string());
738 }
739
740 let content = ctx.content;
741 let lines: Vec<&str> = content.lines().collect();
742 let table_blocks = &ctx.table_blocks;
743
744 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
745
746 for table_block in table_blocks {
747 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
748
749 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
750 .chain(std::iter::once(table_block.delimiter_line))
751 .chain(table_block.content_lines.iter().copied())
752 .collect();
753
754 for (i, &line_idx) in table_line_indices.iter().enumerate() {
755 result_lines[line_idx] = format_result.lines[i].clone();
756 }
757 }
758
759 let mut fixed = result_lines.join("\n");
760 if content.ends_with('\n') && !fixed.ends_with('\n') {
761 fixed.push('\n');
762 }
763 Ok(fixed)
764 }
765
766 fn as_any(&self) -> &dyn std::any::Any {
767 self
768 }
769
770 fn default_config_section(&self) -> Option<(String, toml::Value)> {
771 let json_value = serde_json::to_value(&self.config).ok()?;
772 Some((
773 self.name().to_string(),
774 crate::rule_config_serde::json_to_toml_value(&json_value)?,
775 ))
776 }
777
778 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
779 where
780 Self: Sized,
781 {
782 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
783 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
784 Box::new(Self::from_config_struct(rule_config, md013_config.line_length.get()))
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use crate::lint_context::LintContext;
792 use crate::types::LineLength;
793
794 #[test]
795 fn test_md060_disabled_by_default() {
796 let rule = MD060TableFormat::default();
797 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
799
800 let warnings = rule.check(&ctx).unwrap();
801 assert_eq!(warnings.len(), 0);
802
803 let fixed = rule.fix(&ctx).unwrap();
804 assert_eq!(fixed, content);
805 }
806
807 #[test]
808 fn test_md060_align_simple_ascii_table() {
809 let rule = MD060TableFormat::new(true, "aligned".to_string());
810
811 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
813
814 let fixed = rule.fix(&ctx).unwrap();
815 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
816 assert_eq!(fixed, expected);
817
818 let lines: Vec<&str> = fixed.lines().collect();
820 assert_eq!(lines[0].len(), lines[1].len());
821 assert_eq!(lines[1].len(), lines[2].len());
822 }
823
824 #[test]
825 fn test_md060_cjk_characters_aligned_correctly() {
826 let rule = MD060TableFormat::new(true, "aligned".to_string());
827
828 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830
831 let fixed = rule.fix(&ctx).unwrap();
832
833 let lines: Vec<&str> = fixed.lines().collect();
834 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
835 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
836
837 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
838 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
839
840 assert_eq!(width1, width3);
841 }
842
843 #[test]
844 fn test_md060_basic_emoji() {
845 let rule = MD060TableFormat::new(true, "aligned".to_string());
846
847 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
849
850 let fixed = rule.fix(&ctx).unwrap();
851 assert!(fixed.contains("Status"));
852 }
853
854 #[test]
855 fn test_md060_zwj_emoji_skipped() {
856 let rule = MD060TableFormat::new(true, "aligned".to_string());
857
858 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
860
861 let fixed = rule.fix(&ctx).unwrap();
862 assert_eq!(fixed, content);
863 }
864
865 #[test]
866 fn test_md060_inline_code_with_escaped_pipes() {
867 let rule = MD060TableFormat::new(true, "aligned".to_string());
870
871 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
874
875 let fixed = rule.fix(&ctx).unwrap();
876 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
877 }
878
879 #[test]
880 fn test_md060_compact_style() {
881 let rule = MD060TableFormat::new(true, "compact".to_string());
882
883 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
885
886 let fixed = rule.fix(&ctx).unwrap();
887 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
888 assert_eq!(fixed, expected);
889 }
890
891 #[test]
892 fn test_md060_tight_style() {
893 let rule = MD060TableFormat::new(true, "tight".to_string());
894
895 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
897
898 let fixed = rule.fix(&ctx).unwrap();
899 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
900 assert_eq!(fixed, expected);
901 }
902
903 #[test]
904 fn test_md060_any_style_consistency() {
905 let rule = MD060TableFormat::new(true, "any".to_string());
906
907 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
910
911 let fixed = rule.fix(&ctx).unwrap();
912 assert_eq!(fixed, content);
913
914 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
916 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard);
917
918 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
919 assert_eq!(fixed_aligned, content_aligned);
920 }
921
922 #[test]
923 fn test_md060_empty_cells() {
924 let rule = MD060TableFormat::new(true, "aligned".to_string());
925
926 let content = "| A | B |\n|---|---|\n| | X |";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
928
929 let fixed = rule.fix(&ctx).unwrap();
930 assert!(fixed.contains("|"));
931 }
932
933 #[test]
934 fn test_md060_mixed_content() {
935 let rule = MD060TableFormat::new(true, "aligned".to_string());
936
937 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
939
940 let fixed = rule.fix(&ctx).unwrap();
941 assert!(fixed.contains("δΈζ"));
942 assert!(fixed.contains("NYC"));
943 }
944
945 #[test]
946 fn test_md060_preserve_alignment_indicators() {
947 let rule = MD060TableFormat::new(true, "aligned".to_string());
948
949 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
951
952 let fixed = rule.fix(&ctx).unwrap();
953
954 assert!(fixed.contains(":---"), "Should contain left alignment");
955 assert!(fixed.contains(":----:"), "Should contain center alignment");
956 assert!(fixed.contains("----:"), "Should contain right alignment");
957 }
958
959 #[test]
960 fn test_md060_minimum_column_width() {
961 let rule = MD060TableFormat::new(true, "aligned".to_string());
962
963 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
967
968 let fixed = rule.fix(&ctx).unwrap();
969
970 let lines: Vec<&str> = fixed.lines().collect();
971 assert_eq!(lines[0].len(), lines[1].len());
972 assert_eq!(lines[1].len(), lines[2].len());
973
974 assert!(fixed.contains("ID "), "Short content should be padded");
976 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
977 }
978
979 #[test]
980 fn test_md060_auto_compact_exceeds_default_threshold() {
981 let config = MD060Config {
983 enabled: true,
984 style: "aligned".to_string(),
985 max_width: LineLength::from_const(0),
986 };
987 let rule = MD060TableFormat::from_config_struct(config, 80);
988
989 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
994
995 let fixed = rule.fix(&ctx).unwrap();
996
997 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
999 assert!(fixed.contains("| --- | --- | --- |"));
1000 assert!(fixed.contains("| Short | Data | Here |"));
1001
1002 let lines: Vec<&str> = fixed.lines().collect();
1004 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1006 }
1007
1008 #[test]
1009 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1010 let config = MD060Config {
1012 enabled: true,
1013 style: "aligned".to_string(),
1014 max_width: LineLength::from_const(50),
1015 };
1016 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 |";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1023
1024 let fixed = rule.fix(&ctx).unwrap();
1025
1026 assert!(
1028 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1029 );
1030 assert!(fixed.contains("| --- | --- | --- |"));
1031 assert!(fixed.contains("| Data | Data | Data |"));
1032
1033 let lines: Vec<&str> = fixed.lines().collect();
1035 assert!(lines[0].len() != lines[2].len());
1036 }
1037
1038 #[test]
1039 fn test_md060_stays_aligned_under_threshold() {
1040 let config = MD060Config {
1042 enabled: true,
1043 style: "aligned".to_string(),
1044 max_width: LineLength::from_const(100),
1045 };
1046 let rule = MD060TableFormat::from_config_struct(config, 80);
1047
1048 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1051
1052 let fixed = rule.fix(&ctx).unwrap();
1053
1054 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1056 assert_eq!(fixed, expected);
1057
1058 let lines: Vec<&str> = fixed.lines().collect();
1059 assert_eq!(lines[0].len(), lines[1].len());
1060 assert_eq!(lines[1].len(), lines[2].len());
1061 }
1062
1063 #[test]
1064 fn test_md060_width_calculation_formula() {
1065 let config = MD060Config {
1067 enabled: true,
1068 style: "aligned".to_string(),
1069 max_width: LineLength::from_const(0),
1070 };
1071 let rule = MD060TableFormat::from_config_struct(config, 30);
1072
1073 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1078
1079 let fixed = rule.fix(&ctx).unwrap();
1080
1081 let lines: Vec<&str> = fixed.lines().collect();
1083 assert_eq!(lines[0].len(), lines[1].len());
1084 assert_eq!(lines[1].len(), lines[2].len());
1085 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1089 enabled: true,
1090 style: "aligned".to_string(),
1091 max_width: LineLength::from_const(24),
1092 };
1093 let rule_tight = MD060TableFormat::from_config_struct(config_tight, 80);
1094
1095 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1096
1097 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1099 assert!(fixed_compact.contains("| --- | --- | --- |"));
1100 }
1101
1102 #[test]
1103 fn test_md060_very_wide_table_auto_compacts() {
1104 let config = MD060Config {
1105 enabled: true,
1106 style: "aligned".to_string(),
1107 max_width: LineLength::from_const(0),
1108 };
1109 let rule = MD060TableFormat::from_config_struct(config, 80);
1110
1111 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 |";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1116
1117 let fixed = rule.fix(&ctx).unwrap();
1118
1119 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1121 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1122 }
1123
1124 #[test]
1125 fn test_md060_inherit_from_md013_line_length() {
1126 let config = MD060Config {
1128 enabled: true,
1129 style: "aligned".to_string(),
1130 max_width: LineLength::from_const(0), };
1132
1133 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), 80);
1135 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), 120);
1136
1137 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1140
1141 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1143
1144 let fixed_120 = rule_120.fix(&ctx).unwrap();
1146
1147 let lines_120: Vec<&str> = fixed_120.lines().collect();
1149 assert_eq!(lines_120[0].len(), lines_120[1].len());
1150 assert_eq!(lines_120[1].len(), lines_120[2].len());
1151 }
1152
1153 #[test]
1154 fn test_md060_edge_case_exactly_at_threshold() {
1155 let config = MD060Config {
1159 enabled: true,
1160 style: "aligned".to_string(),
1161 max_width: LineLength::from_const(17),
1162 };
1163 let rule = MD060TableFormat::from_config_struct(config, 80);
1164
1165 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1167
1168 let fixed = rule.fix(&ctx).unwrap();
1169
1170 let lines: Vec<&str> = fixed.lines().collect();
1172 assert_eq!(lines[0].len(), 17);
1173 assert_eq!(lines[0].len(), lines[1].len());
1174 assert_eq!(lines[1].len(), lines[2].len());
1175
1176 let config_under = MD060Config {
1178 enabled: true,
1179 style: "aligned".to_string(),
1180 max_width: LineLength::from_const(16),
1181 };
1182 let rule_under = MD060TableFormat::from_config_struct(config_under, 80);
1183
1184 let fixed_compact = rule_under.fix(&ctx).unwrap();
1185
1186 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1188 assert!(fixed_compact.contains("| --- | --- |"));
1189 }
1190
1191 #[test]
1192 fn test_md060_auto_compact_warning_message() {
1193 let config = MD060Config {
1195 enabled: true,
1196 style: "aligned".to_string(),
1197 max_width: LineLength::from_const(50),
1198 };
1199 let rule = MD060TableFormat::from_config_struct(config, 80);
1200
1201 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1204
1205 let warnings = rule.check(&ctx).unwrap();
1206
1207 assert!(!warnings.is_empty(), "Should generate warnings");
1209
1210 let auto_compact_warnings: Vec<_> = warnings
1211 .iter()
1212 .filter(|w| w.message.contains("too wide for aligned formatting"))
1213 .collect();
1214
1215 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1216
1217 let first_warning = auto_compact_warnings[0];
1219 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1220 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1221 }
1222
1223 #[test]
1224 fn test_md060_issue_129_detect_style_from_all_rows() {
1225 let rule = MD060TableFormat::new(true, "any".to_string());
1229
1230 let content = "| a long heading | another long heading |\n\
1232 | -------------- | -------------------- |\n\
1233 | a | 1 |\n\
1234 | b b | 2 |\n\
1235 | c c c | 3 |";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1237
1238 let fixed = rule.fix(&ctx).unwrap();
1239
1240 assert!(
1242 fixed.contains("| a | 1 |"),
1243 "Should preserve aligned padding in first content row"
1244 );
1245 assert!(
1246 fixed.contains("| b b | 2 |"),
1247 "Should preserve aligned padding in second content row"
1248 );
1249 assert!(
1250 fixed.contains("| c c c | 3 |"),
1251 "Should preserve aligned padding in third content row"
1252 );
1253
1254 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1256 }
1257
1258 #[test]
1259 fn test_md060_regular_alignment_warning_message() {
1260 let config = MD060Config {
1262 enabled: true,
1263 style: "aligned".to_string(),
1264 max_width: LineLength::from_const(100), };
1266 let rule = MD060TableFormat::from_config_struct(config, 80);
1267
1268 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1271
1272 let warnings = rule.check(&ctx).unwrap();
1273
1274 assert!(!warnings.is_empty(), "Should generate warnings");
1276
1277 assert!(warnings[0].message.contains("Table columns should be aligned"));
1279 assert!(!warnings[0].message.contains("too wide"));
1280 assert!(!warnings[0].message.contains("max-width"));
1281 }
1282}