1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::range_utils::calculate_line_range;
3use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
4use crate::utils::table_utils::TableUtils;
5use unicode_width::UnicodeWidthStr;
6
7mod md060_config;
8use crate::md013_line_length::MD013Config;
9use md060_config::MD060Config;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
12enum ColumnAlignment {
13 Left,
14 Center,
15 Right,
16}
17
18#[derive(Debug, Clone)]
19struct TableFormatResult {
20 lines: Vec<String>,
21 auto_compacted: bool,
22 aligned_width: Option<usize>,
23}
24
25#[derive(Debug, Clone, Default)]
148pub struct MD060TableFormat {
149 config: MD060Config,
150 md013_config: MD013Config,
151 md013_disabled: bool,
152}
153
154impl MD060TableFormat {
155 pub fn new(enabled: bool, style: String) -> Self {
156 use crate::types::LineLength;
157 Self {
158 config: MD060Config {
159 enabled,
160 style,
161 max_width: LineLength::from_const(0),
162 },
163 md013_config: MD013Config::default(),
164 md013_disabled: false,
165 }
166 }
167
168 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
169 Self {
170 config,
171 md013_config,
172 md013_disabled,
173 }
174 }
175
176 fn effective_max_width(&self) -> usize {
186 if !self.config.max_width.is_unlimited() {
188 return self.config.max_width.get();
189 }
190
191 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
196 return usize::MAX; }
198
199 self.md013_config.line_length.get()
201 }
202
203 fn contains_problematic_chars(text: &str) -> bool {
214 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
219
220 fn calculate_cell_display_width(cell_content: &str) -> usize {
221 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
222 masked.trim().width()
223 }
224
225 #[cfg(test)]
228 fn parse_table_row(line: &str) -> Vec<String> {
229 TableUtils::split_table_row(line)
230 }
231
232 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
237 TableUtils::split_table_row_with_flavor(line, flavor)
238 }
239
240 fn is_delimiter_row(row: &[String]) -> bool {
241 if row.is_empty() {
242 return false;
243 }
244 row.iter().all(|cell| {
245 let trimmed = cell.trim();
246 !trimmed.is_empty()
249 && trimmed.contains('-')
250 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
251 })
252 }
253
254 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
257 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
258 (&line[..m.end()], &line[m.end()..])
259 } else {
260 ("", line)
261 }
262 }
263
264 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
265 delimiter_row
266 .iter()
267 .map(|cell| {
268 let trimmed = cell.trim();
269 let has_left_colon = trimmed.starts_with(':');
270 let has_right_colon = trimmed.ends_with(':');
271
272 match (has_left_colon, has_right_colon) {
273 (true, true) => ColumnAlignment::Center,
274 (false, true) => ColumnAlignment::Right,
275 _ => ColumnAlignment::Left,
276 }
277 })
278 .collect()
279 }
280
281 fn calculate_column_widths(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Vec<usize> {
282 let mut column_widths = Vec::new();
283 let mut delimiter_cells: Option<Vec<String>> = None;
284
285 for line in table_lines {
286 let cells = Self::parse_table_row_with_flavor(line, flavor);
287
288 if Self::is_delimiter_row(&cells) {
290 delimiter_cells = Some(cells);
291 continue;
292 }
293
294 for (i, cell) in cells.iter().enumerate() {
295 let width = Self::calculate_cell_display_width(cell);
296 if i >= column_widths.len() {
297 column_widths.push(width);
298 } else {
299 column_widths[i] = column_widths[i].max(width);
300 }
301 }
302 }
303
304 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
307
308 if let Some(delimiter_cells) = delimiter_cells {
311 for (i, cell) in delimiter_cells.iter().enumerate() {
312 if i < final_widths.len() {
313 let trimmed = cell.trim();
314 let has_left_colon = trimmed.starts_with(':');
315 let has_right_colon = trimmed.ends_with(':');
316 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
317
318 let min_width_for_delimiter = 3 + colon_count;
320 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
321 }
322 }
323 }
324
325 final_widths
326 }
327
328 fn format_table_row(
329 cells: &[String],
330 column_widths: &[usize],
331 column_alignments: &[ColumnAlignment],
332 is_delimiter: bool,
333 compact_delimiter: bool,
334 ) -> String {
335 let formatted_cells: Vec<String> = cells
336 .iter()
337 .enumerate()
338 .map(|(i, cell)| {
339 let target_width = column_widths.get(i).copied().unwrap_or(0);
340 if is_delimiter {
341 let trimmed = cell.trim();
342 let has_left_colon = trimmed.starts_with(':');
343 let has_right_colon = trimmed.ends_with(':');
344
345 let extra_width = if compact_delimiter { 2 } else { 0 };
349 let dash_count = if has_left_colon && has_right_colon {
350 (target_width + extra_width).saturating_sub(2)
351 } else if has_left_colon || has_right_colon {
352 (target_width + extra_width).saturating_sub(1)
353 } else {
354 target_width + extra_width
355 };
356
357 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
359 format!(":{dashes}:")
360 } else if has_left_colon {
361 format!(":{dashes}")
362 } else if has_right_colon {
363 format!("{dashes}:")
364 } else {
365 dashes
366 };
367
368 if compact_delimiter {
370 delimiter_content
371 } else {
372 format!(" {delimiter_content} ")
373 }
374 } else {
375 let trimmed = cell.trim();
376 let current_width = Self::calculate_cell_display_width(cell);
377 let padding = target_width.saturating_sub(current_width);
378
379 let alignment = column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left);
381 match alignment {
382 ColumnAlignment::Left => {
383 format!(" {trimmed}{} ", " ".repeat(padding))
385 }
386 ColumnAlignment::Center => {
387 let left_padding = padding / 2;
389 let right_padding = padding - left_padding;
390 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
391 }
392 ColumnAlignment::Right => {
393 format!(" {}{trimmed} ", " ".repeat(padding))
395 }
396 }
397 }
398 })
399 .collect();
400
401 format!("|{}|", formatted_cells.join("|"))
402 }
403
404 fn format_table_compact(cells: &[String]) -> String {
405 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
406 format!("|{}|", formatted_cells.join("|"))
407 }
408
409 fn format_table_tight(cells: &[String]) -> String {
410 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
411 format!("|{}|", formatted_cells.join("|"))
412 }
413
414 fn is_table_already_aligned(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> bool {
421 if table_lines.len() < 2 {
422 return false;
423 }
424
425 let first_len = table_lines[0].len();
427 if !table_lines.iter().all(|line| line.len() == first_len) {
428 return false;
429 }
430
431 let parsed: Vec<Vec<String>> = table_lines
433 .iter()
434 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
435 .collect();
436
437 if parsed.is_empty() {
438 return false;
439 }
440
441 let num_columns = parsed[0].len();
442 if !parsed.iter().all(|row| row.len() == num_columns) {
443 return false;
444 }
445
446 if let Some(delimiter_row) = parsed.get(1) {
449 if !Self::is_delimiter_row(delimiter_row) {
450 return false;
451 }
452 for cell in delimiter_row {
454 let trimmed = cell.trim();
455 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
456 if dash_count < 1 {
457 return false;
458 }
459 }
460 }
461
462 for col_idx in 0..num_columns {
464 let mut widths = Vec::new();
465 for (row_idx, row) in parsed.iter().enumerate() {
466 if row_idx == 1 {
468 continue;
469 }
470 if let Some(cell) = row.get(col_idx) {
471 widths.push(cell.len());
472 }
473 }
474 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
476 return false;
477 }
478 }
479
480 true
481 }
482
483 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
484 if table_lines.is_empty() {
485 return None;
486 }
487
488 let mut is_tight = true;
491 let mut is_compact = true;
492
493 for line in table_lines {
494 let cells = Self::parse_table_row_with_flavor(line, flavor);
495
496 if cells.is_empty() {
497 continue;
498 }
499
500 if Self::is_delimiter_row(&cells) {
502 continue;
503 }
504
505 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
507
508 let row_has_single_space = cells.iter().all(|cell| {
510 let trimmed = cell.trim();
511 cell == &format!(" {trimmed} ")
512 });
513
514 if !row_has_no_padding {
516 is_tight = false;
517 }
518
519 if !row_has_single_space {
521 is_compact = false;
522 }
523
524 if !is_tight && !is_compact {
526 return Some("aligned".to_string());
527 }
528 }
529
530 if is_tight {
532 Some("tight".to_string())
533 } else if is_compact {
534 Some("compact".to_string())
535 } else {
536 Some("aligned".to_string())
537 }
538 }
539
540 fn fix_table_block(
541 &self,
542 lines: &[&str],
543 table_block: &crate::utils::table_utils::TableBlock,
544 flavor: crate::config::MarkdownFlavor,
545 ) -> TableFormatResult {
546 let mut result = Vec::new();
547 let mut auto_compacted = false;
548 let mut aligned_width = None;
549
550 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
551 .chain(std::iter::once(lines[table_block.delimiter_line]))
552 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
553 .collect();
554
555 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
556 return TableFormatResult {
557 lines: table_lines.iter().map(|s| s.to_string()).collect(),
558 auto_compacted: false,
559 aligned_width: None,
560 };
561 }
562
563 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
566
567 let stripped_lines: Vec<&str> = table_lines
569 .iter()
570 .map(|line| Self::extract_blockquote_prefix(line).1)
571 .collect();
572
573 let style = self.config.style.as_str();
574
575 match style {
576 "any" => {
577 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
578 if detected_style.is_none() {
579 return TableFormatResult {
580 lines: table_lines.iter().map(|s| s.to_string()).collect(),
581 auto_compacted: false,
582 aligned_width: None,
583 };
584 }
585
586 let target_style = detected_style.unwrap();
587
588 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
590 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
591
592 for line in &stripped_lines {
593 let cells = Self::parse_table_row_with_flavor(line, flavor);
594 match target_style.as_str() {
595 "tight" => result.push(Self::format_table_tight(&cells)),
596 "compact" => result.push(Self::format_table_compact(&cells)),
597 _ => {
598 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
599 let is_delimiter = Self::is_delimiter_row(&cells);
600 result.push(Self::format_table_row(
601 &cells,
602 &column_widths,
603 &column_alignments,
604 is_delimiter,
605 false,
606 ));
607 }
608 }
609 }
610 }
611 "compact" => {
612 for line in &stripped_lines {
613 let cells = Self::parse_table_row_with_flavor(line, flavor);
614 result.push(Self::format_table_compact(&cells));
615 }
616 }
617 "tight" => {
618 for line in &stripped_lines {
619 let cells = Self::parse_table_row_with_flavor(line, flavor);
620 result.push(Self::format_table_tight(&cells));
621 }
622 }
623 "aligned" | "aligned-no-space" => {
624 let compact_delimiter = style == "aligned-no-space";
625
626 if Self::is_table_already_aligned(&stripped_lines, flavor) {
629 return TableFormatResult {
630 lines: table_lines.iter().map(|s| s.to_string()).collect(),
631 auto_compacted: false,
632 aligned_width: None,
633 };
634 }
635
636 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
637
638 let num_columns = column_widths.len();
640 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
641 aligned_width = Some(calc_aligned_width);
642
643 if calc_aligned_width > self.effective_max_width() {
645 auto_compacted = true;
646 for line in &stripped_lines {
647 let cells = Self::parse_table_row_with_flavor(line, flavor);
648 result.push(Self::format_table_compact(&cells));
649 }
650 } else {
651 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
653 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
654
655 for line in &stripped_lines {
656 let cells = Self::parse_table_row_with_flavor(line, flavor);
657 let is_delimiter = Self::is_delimiter_row(&cells);
658 result.push(Self::format_table_row(
659 &cells,
660 &column_widths,
661 &column_alignments,
662 is_delimiter,
663 compact_delimiter,
664 ));
665 }
666 }
667 }
668 _ => {
669 return TableFormatResult {
670 lines: table_lines.iter().map(|s| s.to_string()).collect(),
671 auto_compacted: false,
672 aligned_width: None,
673 };
674 }
675 }
676
677 let prefixed_result: Vec<String> = result
679 .into_iter()
680 .map(|line| format!("{blockquote_prefix}{line}"))
681 .collect();
682
683 TableFormatResult {
684 lines: prefixed_result,
685 auto_compacted,
686 aligned_width,
687 }
688 }
689}
690
691impl Rule for MD060TableFormat {
692 fn name(&self) -> &'static str {
693 "MD060"
694 }
695
696 fn description(&self) -> &'static str {
697 "Table columns should be consistently aligned"
698 }
699
700 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
701 !self.config.enabled || !ctx.likely_has_tables()
702 }
703
704 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
705 if !self.config.enabled {
706 return Ok(Vec::new());
707 }
708
709 let content = ctx.content;
710 let line_index = &ctx.line_index;
711 let mut warnings = Vec::new();
712
713 let lines: Vec<&str> = content.lines().collect();
714 let table_blocks = &ctx.table_blocks;
715
716 for table_block in table_blocks {
717 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
718
719 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
720 .chain(std::iter::once(table_block.delimiter_line))
721 .chain(table_block.content_lines.iter().copied())
722 .collect();
723
724 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());
731 for (i, &line_idx) in table_line_indices.iter().enumerate() {
732 let fixed_line = &format_result.lines[i];
733 if line_idx < lines.len() - 1 {
735 fixed_table_lines.push(format!("{fixed_line}\n"));
736 } else {
737 fixed_table_lines.push(fixed_line.clone());
738 }
739 }
740 let table_replacement = fixed_table_lines.concat();
741 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
742
743 for (i, &line_idx) in table_line_indices.iter().enumerate() {
744 let original = lines[line_idx];
745 let fixed = &format_result.lines[i];
746
747 if original != fixed {
748 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
749
750 let message = if format_result.auto_compacted {
751 if let Some(width) = format_result.aligned_width {
752 format!(
753 "Table too wide for aligned formatting ({} chars > max-width: {})",
754 width,
755 self.effective_max_width()
756 )
757 } else {
758 "Table too wide for aligned formatting".to_string()
759 }
760 } else {
761 "Table columns should be aligned".to_string()
762 };
763
764 warnings.push(LintWarning {
767 rule_name: Some(self.name().to_string()),
768 severity: Severity::Warning,
769 message,
770 line: start_line,
771 column: start_col,
772 end_line,
773 end_column: end_col,
774 fix: Some(crate::rule::Fix {
775 range: table_range.clone(),
776 replacement: table_replacement.clone(),
777 }),
778 });
779 }
780 }
781 }
782
783 Ok(warnings)
784 }
785
786 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
787 if !self.config.enabled {
788 return Ok(ctx.content.to_string());
789 }
790
791 let content = ctx.content;
792 let lines: Vec<&str> = content.lines().collect();
793 let table_blocks = &ctx.table_blocks;
794
795 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
796
797 for table_block in table_blocks {
798 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
799
800 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
801 .chain(std::iter::once(table_block.delimiter_line))
802 .chain(table_block.content_lines.iter().copied())
803 .collect();
804
805 for (i, &line_idx) in table_line_indices.iter().enumerate() {
806 result_lines[line_idx] = format_result.lines[i].clone();
807 }
808 }
809
810 let mut fixed = result_lines.join("\n");
811 if content.ends_with('\n') && !fixed.ends_with('\n') {
812 fixed.push('\n');
813 }
814 Ok(fixed)
815 }
816
817 fn as_any(&self) -> &dyn std::any::Any {
818 self
819 }
820
821 fn default_config_section(&self) -> Option<(String, toml::Value)> {
822 let json_value = serde_json::to_value(&self.config).ok()?;
823 Some((
824 self.name().to_string(),
825 crate::rule_config_serde::json_to_toml_value(&json_value)?,
826 ))
827 }
828
829 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
830 where
831 Self: Sized,
832 {
833 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
834 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
835
836 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
838
839 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846 use crate::lint_context::LintContext;
847 use crate::types::LineLength;
848
849 fn md013_with_line_length(line_length: usize) -> MD013Config {
851 MD013Config {
852 line_length: LineLength::from_const(line_length),
853 tables: true, ..Default::default()
855 }
856 }
857
858 #[test]
859 fn test_md060_disabled_by_default() {
860 let rule = MD060TableFormat::default();
861 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863
864 let warnings = rule.check(&ctx).unwrap();
865 assert_eq!(warnings.len(), 0);
866
867 let fixed = rule.fix(&ctx).unwrap();
868 assert_eq!(fixed, content);
869 }
870
871 #[test]
872 fn test_md060_align_simple_ascii_table() {
873 let rule = MD060TableFormat::new(true, "aligned".to_string());
874
875 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877
878 let fixed = rule.fix(&ctx).unwrap();
879 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
880 assert_eq!(fixed, expected);
881
882 let lines: Vec<&str> = fixed.lines().collect();
884 assert_eq!(lines[0].len(), lines[1].len());
885 assert_eq!(lines[1].len(), lines[2].len());
886 }
887
888 #[test]
889 fn test_md060_cjk_characters_aligned_correctly() {
890 let rule = MD060TableFormat::new(true, "aligned".to_string());
891
892 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894
895 let fixed = rule.fix(&ctx).unwrap();
896
897 let lines: Vec<&str> = fixed.lines().collect();
898 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
899 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
900
901 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
902 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
903
904 assert_eq!(width1, width3);
905 }
906
907 #[test]
908 fn test_md060_basic_emoji() {
909 let rule = MD060TableFormat::new(true, "aligned".to_string());
910
911 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913
914 let fixed = rule.fix(&ctx).unwrap();
915 assert!(fixed.contains("Status"));
916 }
917
918 #[test]
919 fn test_md060_zwj_emoji_skipped() {
920 let rule = MD060TableFormat::new(true, "aligned".to_string());
921
922 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924
925 let fixed = rule.fix(&ctx).unwrap();
926 assert_eq!(fixed, content);
927 }
928
929 #[test]
930 fn test_md060_inline_code_with_escaped_pipes() {
931 let rule = MD060TableFormat::new(true, "aligned".to_string());
934
935 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938
939 let fixed = rule.fix(&ctx).unwrap();
940 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
941 }
942
943 #[test]
944 fn test_md060_compact_style() {
945 let rule = MD060TableFormat::new(true, "compact".to_string());
946
947 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949
950 let fixed = rule.fix(&ctx).unwrap();
951 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
952 assert_eq!(fixed, expected);
953 }
954
955 #[test]
956 fn test_md060_tight_style() {
957 let rule = MD060TableFormat::new(true, "tight".to_string());
958
959 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961
962 let fixed = rule.fix(&ctx).unwrap();
963 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
964 assert_eq!(fixed, expected);
965 }
966
967 #[test]
968 fn test_md060_aligned_no_space_style() {
969 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
971
972 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974
975 let fixed = rule.fix(&ctx).unwrap();
976
977 let lines: Vec<&str> = fixed.lines().collect();
979 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
980 assert_eq!(
981 lines[1], "|-------|-----|",
982 "Delimiter should have NO spaces around dashes"
983 );
984 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
985
986 assert_eq!(lines[0].len(), lines[1].len());
988 assert_eq!(lines[1].len(), lines[2].len());
989 }
990
991 #[test]
992 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
993 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
995
996 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998
999 let fixed = rule.fix(&ctx).unwrap();
1000 let lines: Vec<&str> = fixed.lines().collect();
1001
1002 assert!(
1004 fixed.contains("|:"),
1005 "Should have left alignment indicator adjacent to pipe"
1006 );
1007 assert!(
1008 fixed.contains(":|"),
1009 "Should have right alignment indicator adjacent to pipe"
1010 );
1011 assert!(
1013 lines[1].contains(":---") && lines[1].contains("---:"),
1014 "Should have center alignment colons"
1015 );
1016 }
1017
1018 #[test]
1019 fn test_md060_aligned_no_space_three_column_table() {
1020 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1022
1023 let content = "| Header 1 | Header 2 | Header 3 |\n|---|---|---|\n| Row 1, Col 1 | Row 1, Col 2 | Row 1, Col 3 |\n| Row 2, Col 1 | Row 2, Col 2 | Row 2, Col 3 |";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025
1026 let fixed = rule.fix(&ctx).unwrap();
1027 let lines: Vec<&str> = fixed.lines().collect();
1028
1029 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1031 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1032 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1033 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1034 }
1035
1036 #[test]
1037 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1038 let config = MD060Config {
1040 enabled: true,
1041 style: "aligned-no-space".to_string(),
1042 max_width: LineLength::from_const(50),
1043 };
1044 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1045
1046 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
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("| --- |"),
1055 "Should be compact format when exceeding max-width"
1056 );
1057 }
1058
1059 #[test]
1060 fn test_md060_aligned_no_space_cjk_characters() {
1061 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1063
1064 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066
1067 let fixed = rule.fix(&ctx).unwrap();
1068 let lines: Vec<&str> = fixed.lines().collect();
1069
1070 use unicode_width::UnicodeWidthStr;
1073 assert_eq!(
1074 lines[0].width(),
1075 lines[1].width(),
1076 "Header and delimiter should have same display width"
1077 );
1078 assert_eq!(
1079 lines[1].width(),
1080 lines[2].width(),
1081 "Delimiter and content should have same display width"
1082 );
1083
1084 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1086 }
1087
1088 #[test]
1089 fn test_md060_aligned_no_space_minimum_width() {
1090 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1092
1093 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095
1096 let fixed = rule.fix(&ctx).unwrap();
1097 let lines: Vec<&str> = fixed.lines().collect();
1098
1099 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1101 assert_eq!(lines[0].len(), lines[1].len());
1103 assert_eq!(lines[1].len(), lines[2].len());
1104 }
1105
1106 #[test]
1107 fn test_md060_any_style_consistency() {
1108 let rule = MD060TableFormat::new(true, "any".to_string());
1109
1110 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113
1114 let fixed = rule.fix(&ctx).unwrap();
1115 assert_eq!(fixed, content);
1116
1117 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1119 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1120
1121 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1122 assert_eq!(fixed_aligned, content_aligned);
1123 }
1124
1125 #[test]
1126 fn test_md060_empty_cells() {
1127 let rule = MD060TableFormat::new(true, "aligned".to_string());
1128
1129 let content = "| A | B |\n|---|---|\n| | X |";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131
1132 let fixed = rule.fix(&ctx).unwrap();
1133 assert!(fixed.contains("|"));
1134 }
1135
1136 #[test]
1137 fn test_md060_mixed_content() {
1138 let rule = MD060TableFormat::new(true, "aligned".to_string());
1139
1140 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142
1143 let fixed = rule.fix(&ctx).unwrap();
1144 assert!(fixed.contains("δΈζ"));
1145 assert!(fixed.contains("NYC"));
1146 }
1147
1148 #[test]
1149 fn test_md060_preserve_alignment_indicators() {
1150 let rule = MD060TableFormat::new(true, "aligned".to_string());
1151
1152 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154
1155 let fixed = rule.fix(&ctx).unwrap();
1156
1157 assert!(fixed.contains(":---"), "Should contain left alignment");
1158 assert!(fixed.contains(":----:"), "Should contain center alignment");
1159 assert!(fixed.contains("----:"), "Should contain right alignment");
1160 }
1161
1162 #[test]
1163 fn test_md060_minimum_column_width() {
1164 let rule = MD060TableFormat::new(true, "aligned".to_string());
1165
1166 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170
1171 let fixed = rule.fix(&ctx).unwrap();
1172
1173 let lines: Vec<&str> = fixed.lines().collect();
1174 assert_eq!(lines[0].len(), lines[1].len());
1175 assert_eq!(lines[1].len(), lines[2].len());
1176
1177 assert!(fixed.contains("ID "), "Short content should be padded");
1179 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1180 }
1181
1182 #[test]
1183 fn test_md060_auto_compact_exceeds_default_threshold() {
1184 let config = MD060Config {
1186 enabled: true,
1187 style: "aligned".to_string(),
1188 max_width: LineLength::from_const(0),
1189 };
1190 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1191
1192 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1197
1198 let fixed = rule.fix(&ctx).unwrap();
1199
1200 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1202 assert!(fixed.contains("| --- | --- | --- |"));
1203 assert!(fixed.contains("| Short | Data | Here |"));
1204
1205 let lines: Vec<&str> = fixed.lines().collect();
1207 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1209 }
1210
1211 #[test]
1212 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1213 let config = MD060Config {
1215 enabled: true,
1216 style: "aligned".to_string(),
1217 max_width: LineLength::from_const(50),
1218 };
1219 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 |";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226
1227 let fixed = rule.fix(&ctx).unwrap();
1228
1229 assert!(
1231 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1232 );
1233 assert!(fixed.contains("| --- | --- | --- |"));
1234 assert!(fixed.contains("| Data | Data | Data |"));
1235
1236 let lines: Vec<&str> = fixed.lines().collect();
1238 assert!(lines[0].len() != lines[2].len());
1239 }
1240
1241 #[test]
1242 fn test_md060_stays_aligned_under_threshold() {
1243 let config = MD060Config {
1245 enabled: true,
1246 style: "aligned".to_string(),
1247 max_width: LineLength::from_const(100),
1248 };
1249 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1250
1251 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254
1255 let fixed = rule.fix(&ctx).unwrap();
1256
1257 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1259 assert_eq!(fixed, expected);
1260
1261 let lines: Vec<&str> = fixed.lines().collect();
1262 assert_eq!(lines[0].len(), lines[1].len());
1263 assert_eq!(lines[1].len(), lines[2].len());
1264 }
1265
1266 #[test]
1267 fn test_md060_width_calculation_formula() {
1268 let config = MD060Config {
1270 enabled: true,
1271 style: "aligned".to_string(),
1272 max_width: LineLength::from_const(0),
1273 };
1274 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1275
1276 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281
1282 let fixed = rule.fix(&ctx).unwrap();
1283
1284 let lines: Vec<&str> = fixed.lines().collect();
1286 assert_eq!(lines[0].len(), lines[1].len());
1287 assert_eq!(lines[1].len(), lines[2].len());
1288 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1292 enabled: true,
1293 style: "aligned".to_string(),
1294 max_width: LineLength::from_const(24),
1295 };
1296 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1297
1298 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1299
1300 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1302 assert!(fixed_compact.contains("| --- | --- | --- |"));
1303 }
1304
1305 #[test]
1306 fn test_md060_very_wide_table_auto_compacts() {
1307 let config = MD060Config {
1308 enabled: true,
1309 style: "aligned".to_string(),
1310 max_width: LineLength::from_const(0),
1311 };
1312 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1313
1314 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 |";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319
1320 let fixed = rule.fix(&ctx).unwrap();
1321
1322 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1324 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1325 }
1326
1327 #[test]
1328 fn test_md060_inherit_from_md013_line_length() {
1329 let config = MD060Config {
1331 enabled: true,
1332 style: "aligned".to_string(),
1333 max_width: LineLength::from_const(0), };
1335
1336 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1338 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1339
1340 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343
1344 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1346
1347 let fixed_120 = rule_120.fix(&ctx).unwrap();
1349
1350 let lines_120: Vec<&str> = fixed_120.lines().collect();
1352 assert_eq!(lines_120[0].len(), lines_120[1].len());
1353 assert_eq!(lines_120[1].len(), lines_120[2].len());
1354 }
1355
1356 #[test]
1357 fn test_md060_edge_case_exactly_at_threshold() {
1358 let config = MD060Config {
1362 enabled: true,
1363 style: "aligned".to_string(),
1364 max_width: LineLength::from_const(17),
1365 };
1366 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1367
1368 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370
1371 let fixed = rule.fix(&ctx).unwrap();
1372
1373 let lines: Vec<&str> = fixed.lines().collect();
1375 assert_eq!(lines[0].len(), 17);
1376 assert_eq!(lines[0].len(), lines[1].len());
1377 assert_eq!(lines[1].len(), lines[2].len());
1378
1379 let config_under = MD060Config {
1381 enabled: true,
1382 style: "aligned".to_string(),
1383 max_width: LineLength::from_const(16),
1384 };
1385 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1386
1387 let fixed_compact = rule_under.fix(&ctx).unwrap();
1388
1389 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1391 assert!(fixed_compact.contains("| --- | --- |"));
1392 }
1393
1394 #[test]
1395 fn test_md060_auto_compact_warning_message() {
1396 let config = MD060Config {
1398 enabled: true,
1399 style: "aligned".to_string(),
1400 max_width: LineLength::from_const(50),
1401 };
1402 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1403
1404 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407
1408 let warnings = rule.check(&ctx).unwrap();
1409
1410 assert!(!warnings.is_empty(), "Should generate warnings");
1412
1413 let auto_compact_warnings: Vec<_> = warnings
1414 .iter()
1415 .filter(|w| w.message.contains("too wide for aligned formatting"))
1416 .collect();
1417
1418 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1419
1420 let first_warning = auto_compact_warnings[0];
1422 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1423 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1424 }
1425
1426 #[test]
1427 fn test_md060_issue_129_detect_style_from_all_rows() {
1428 let rule = MD060TableFormat::new(true, "any".to_string());
1432
1433 let content = "| a long heading | another long heading |\n\
1435 | -------------- | -------------------- |\n\
1436 | a | 1 |\n\
1437 | b b | 2 |\n\
1438 | c c c | 3 |";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440
1441 let fixed = rule.fix(&ctx).unwrap();
1442
1443 assert!(
1445 fixed.contains("| a | 1 |"),
1446 "Should preserve aligned padding in first content row"
1447 );
1448 assert!(
1449 fixed.contains("| b b | 2 |"),
1450 "Should preserve aligned padding in second content row"
1451 );
1452 assert!(
1453 fixed.contains("| c c c | 3 |"),
1454 "Should preserve aligned padding in third content row"
1455 );
1456
1457 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1459 }
1460
1461 #[test]
1462 fn test_md060_regular_alignment_warning_message() {
1463 let config = MD060Config {
1465 enabled: true,
1466 style: "aligned".to_string(),
1467 max_width: LineLength::from_const(100), };
1469 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1470
1471 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474
1475 let warnings = rule.check(&ctx).unwrap();
1476
1477 assert!(!warnings.is_empty(), "Should generate warnings");
1479
1480 assert!(warnings[0].message.contains("Table columns should be aligned"));
1482 assert!(!warnings[0].message.contains("too wide"));
1483 assert!(!warnings[0].message.contains("max-width"));
1484 }
1485
1486 #[test]
1489 fn test_md060_unlimited_when_md013_disabled() {
1490 let config = MD060Config {
1492 enabled: true,
1493 style: "aligned".to_string(),
1494 max_width: LineLength::from_const(0), };
1496 let md013_config = MD013Config::default();
1497 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1498
1499 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let fixed = rule.fix(&ctx).unwrap();
1503
1504 let lines: Vec<&str> = fixed.lines().collect();
1506 assert_eq!(
1508 lines[0].len(),
1509 lines[1].len(),
1510 "Table should be aligned when MD013 is disabled"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_md060_unlimited_when_md013_tables_false() {
1516 let config = MD060Config {
1518 enabled: true,
1519 style: "aligned".to_string(),
1520 max_width: LineLength::from_const(0),
1521 };
1522 let md013_config = MD013Config {
1523 tables: false, line_length: LineLength::from_const(80),
1525 ..Default::default()
1526 };
1527 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1528
1529 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532 let fixed = rule.fix(&ctx).unwrap();
1533
1534 let lines: Vec<&str> = fixed.lines().collect();
1536 assert_eq!(
1537 lines[0].len(),
1538 lines[1].len(),
1539 "Table should be aligned when MD013.tables=false"
1540 );
1541 }
1542
1543 #[test]
1544 fn test_md060_unlimited_when_md013_line_length_zero() {
1545 let config = MD060Config {
1547 enabled: true,
1548 style: "aligned".to_string(),
1549 max_width: LineLength::from_const(0),
1550 };
1551 let md013_config = MD013Config {
1552 tables: true,
1553 line_length: LineLength::from_const(0), ..Default::default()
1555 };
1556 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1557
1558 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561 let fixed = rule.fix(&ctx).unwrap();
1562
1563 let lines: Vec<&str> = fixed.lines().collect();
1565 assert_eq!(
1566 lines[0].len(),
1567 lines[1].len(),
1568 "Table should be aligned when MD013.line_length=0"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_md060_explicit_max_width_overrides_md013_settings() {
1574 let config = MD060Config {
1576 enabled: true,
1577 style: "aligned".to_string(),
1578 max_width: LineLength::from_const(50), };
1580 let md013_config = MD013Config {
1581 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1584 };
1585 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1586
1587 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let fixed = rule.fix(&ctx).unwrap();
1591
1592 assert!(
1594 fixed.contains("| --- |"),
1595 "Should be compact format due to explicit max_width"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1601 let config = MD060Config {
1603 enabled: true,
1604 style: "aligned".to_string(),
1605 max_width: LineLength::from_const(0), };
1607 let md013_config = MD013Config {
1608 tables: true,
1609 line_length: LineLength::from_const(50), ..Default::default()
1611 };
1612 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1613
1614 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617 let fixed = rule.fix(&ctx).unwrap();
1618
1619 assert!(
1621 fixed.contains("| --- |"),
1622 "Should be compact format when inheriting MD013 limit"
1623 );
1624 }
1625}