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;
9pub use md060_config::ColumnAlign;
10use md060_config::MD060Config;
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13enum ColumnAlignment {
14 Left,
15 Center,
16 Right,
17}
18
19#[derive(Debug, Clone)]
20struct TableFormatResult {
21 lines: Vec<String>,
22 auto_compacted: bool,
23 aligned_width: Option<usize>,
24}
25
26#[derive(Debug, Clone, Default)]
149pub struct MD060TableFormat {
150 config: MD060Config,
151 md013_config: MD013Config,
152 md013_disabled: bool,
153}
154
155impl MD060TableFormat {
156 pub fn new(enabled: bool, style: String) -> Self {
157 use crate::types::LineLength;
158 Self {
159 config: MD060Config {
160 enabled,
161 style,
162 max_width: LineLength::from_const(0),
163 column_align: ColumnAlign::Auto,
164 },
165 md013_config: MD013Config::default(),
166 md013_disabled: false,
167 }
168 }
169
170 pub fn from_config_struct(config: MD060Config, md013_config: MD013Config, md013_disabled: bool) -> Self {
171 Self {
172 config,
173 md013_config,
174 md013_disabled,
175 }
176 }
177
178 fn effective_max_width(&self) -> usize {
188 if !self.config.max_width.is_unlimited() {
190 return self.config.max_width.get();
191 }
192
193 if self.md013_disabled || !self.md013_config.tables || self.md013_config.line_length.is_unlimited() {
198 return usize::MAX; }
200
201 self.md013_config.line_length.get()
203 }
204
205 fn contains_problematic_chars(text: &str) -> bool {
216 text.contains('\u{200D}') || text.contains('\u{200B}') || text.contains('\u{200C}') || text.contains('\u{2060}') }
221
222 fn calculate_cell_display_width(cell_content: &str) -> usize {
223 let masked = TableUtils::mask_pipes_in_inline_code(cell_content);
224 masked.trim().width()
225 }
226
227 #[cfg(test)]
230 fn parse_table_row(line: &str) -> Vec<String> {
231 TableUtils::split_table_row(line)
232 }
233
234 fn parse_table_row_with_flavor(line: &str, flavor: crate::config::MarkdownFlavor) -> Vec<String> {
239 TableUtils::split_table_row_with_flavor(line, flavor)
240 }
241
242 fn is_delimiter_row(row: &[String]) -> bool {
243 if row.is_empty() {
244 return false;
245 }
246 row.iter().all(|cell| {
247 let trimmed = cell.trim();
248 !trimmed.is_empty()
251 && trimmed.contains('-')
252 && trimmed.chars().all(|c| c == '-' || c == ':' || c.is_whitespace())
253 })
254 }
255
256 fn extract_blockquote_prefix(line: &str) -> (&str, &str) {
259 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
260 (&line[..m.end()], &line[m.end()..])
261 } else {
262 ("", line)
263 }
264 }
265
266 fn parse_column_alignments(delimiter_row: &[String]) -> Vec<ColumnAlignment> {
267 delimiter_row
268 .iter()
269 .map(|cell| {
270 let trimmed = cell.trim();
271 let has_left_colon = trimmed.starts_with(':');
272 let has_right_colon = trimmed.ends_with(':');
273
274 match (has_left_colon, has_right_colon) {
275 (true, true) => ColumnAlignment::Center,
276 (false, true) => ColumnAlignment::Right,
277 _ => ColumnAlignment::Left,
278 }
279 })
280 .collect()
281 }
282
283 fn calculate_column_widths(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Vec<usize> {
284 let mut column_widths = Vec::new();
285 let mut delimiter_cells: Option<Vec<String>> = None;
286
287 for line in table_lines {
288 let cells = Self::parse_table_row_with_flavor(line, flavor);
289
290 if Self::is_delimiter_row(&cells) {
292 delimiter_cells = Some(cells);
293 continue;
294 }
295
296 for (i, cell) in cells.iter().enumerate() {
297 let width = Self::calculate_cell_display_width(cell);
298 if i >= column_widths.len() {
299 column_widths.push(width);
300 } else {
301 column_widths[i] = column_widths[i].max(width);
302 }
303 }
304 }
305
306 let mut final_widths: Vec<usize> = column_widths.iter().map(|&w| w.max(3)).collect();
309
310 if let Some(delimiter_cells) = delimiter_cells {
313 for (i, cell) in delimiter_cells.iter().enumerate() {
314 if i < final_widths.len() {
315 let trimmed = cell.trim();
316 let has_left_colon = trimmed.starts_with(':');
317 let has_right_colon = trimmed.ends_with(':');
318 let colon_count = (has_left_colon as usize) + (has_right_colon as usize);
319
320 let min_width_for_delimiter = 3 + colon_count;
322 final_widths[i] = final_widths[i].max(min_width_for_delimiter);
323 }
324 }
325 }
326
327 final_widths
328 }
329
330 fn format_table_row(
331 cells: &[String],
332 column_widths: &[usize],
333 column_alignments: &[ColumnAlignment],
334 is_delimiter: bool,
335 compact_delimiter: bool,
336 column_align_override: ColumnAlign,
337 ) -> String {
338 let formatted_cells: Vec<String> = cells
339 .iter()
340 .enumerate()
341 .map(|(i, cell)| {
342 let target_width = column_widths.get(i).copied().unwrap_or(0);
343 if is_delimiter {
344 let trimmed = cell.trim();
345 let has_left_colon = trimmed.starts_with(':');
346 let has_right_colon = trimmed.ends_with(':');
347
348 let extra_width = if compact_delimiter { 2 } else { 0 };
352 let dash_count = if has_left_colon && has_right_colon {
353 (target_width + extra_width).saturating_sub(2)
354 } else if has_left_colon || has_right_colon {
355 (target_width + extra_width).saturating_sub(1)
356 } else {
357 target_width + extra_width
358 };
359
360 let dashes = "-".repeat(dash_count.max(3)); let delimiter_content = if has_left_colon && has_right_colon {
362 format!(":{dashes}:")
363 } else if has_left_colon {
364 format!(":{dashes}")
365 } else if has_right_colon {
366 format!("{dashes}:")
367 } else {
368 dashes
369 };
370
371 if compact_delimiter {
373 delimiter_content
374 } else {
375 format!(" {delimiter_content} ")
376 }
377 } else {
378 let trimmed = cell.trim();
379 let current_width = Self::calculate_cell_display_width(cell);
380 let padding = target_width.saturating_sub(current_width);
381
382 let alignment = match column_align_override {
384 ColumnAlign::Auto => column_alignments.get(i).copied().unwrap_or(ColumnAlignment::Left),
385 ColumnAlign::Left => ColumnAlignment::Left,
386 ColumnAlign::Center => ColumnAlignment::Center,
387 ColumnAlign::Right => ColumnAlignment::Right,
388 };
389 match alignment {
390 ColumnAlignment::Left => {
391 format!(" {trimmed}{} ", " ".repeat(padding))
393 }
394 ColumnAlignment::Center => {
395 let left_padding = padding / 2;
397 let right_padding = padding - left_padding;
398 format!(" {}{trimmed}{} ", " ".repeat(left_padding), " ".repeat(right_padding))
399 }
400 ColumnAlignment::Right => {
401 format!(" {}{trimmed} ", " ".repeat(padding))
403 }
404 }
405 }
406 })
407 .collect();
408
409 format!("|{}|", formatted_cells.join("|"))
410 }
411
412 fn format_table_compact(cells: &[String]) -> String {
413 let formatted_cells: Vec<String> = cells.iter().map(|cell| format!(" {} ", cell.trim())).collect();
414 format!("|{}|", formatted_cells.join("|"))
415 }
416
417 fn format_table_tight(cells: &[String]) -> String {
418 let formatted_cells: Vec<String> = cells.iter().map(|cell| cell.trim().to_string()).collect();
419 format!("|{}|", formatted_cells.join("|"))
420 }
421
422 fn is_table_already_aligned(
434 table_lines: &[&str],
435 flavor: crate::config::MarkdownFlavor,
436 compact_delimiter: bool,
437 ) -> bool {
438 if table_lines.len() < 2 {
439 return false;
440 }
441
442 let first_len = table_lines[0].len();
444 if !table_lines.iter().all(|line| line.len() == first_len) {
445 return false;
446 }
447
448 let parsed: Vec<Vec<String>> = table_lines
450 .iter()
451 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
452 .collect();
453
454 if parsed.is_empty() {
455 return false;
456 }
457
458 let num_columns = parsed[0].len();
459 if !parsed.iter().all(|row| row.len() == num_columns) {
460 return false;
461 }
462
463 if let Some(delimiter_row) = parsed.get(1) {
466 if !Self::is_delimiter_row(delimiter_row) {
467 return false;
468 }
469 for cell in delimiter_row {
471 let trimmed = cell.trim();
472 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
473 if dash_count < 1 {
474 return false;
475 }
476 }
477
478 let delimiter_has_spaces = delimiter_row
482 .iter()
483 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
484
485 if compact_delimiter && delimiter_has_spaces {
488 return false;
489 }
490 if !compact_delimiter && !delimiter_has_spaces {
491 return false;
492 }
493 }
494
495 for col_idx in 0..num_columns {
499 let mut widths = Vec::new();
500 for (row_idx, row) in parsed.iter().enumerate() {
501 if row_idx == 1 {
503 continue;
504 }
505 if let Some(cell) = row.get(col_idx) {
506 widths.push(cell.width());
507 }
508 }
509 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
511 return false;
512 }
513 }
514
515 true
516 }
517
518 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
519 if table_lines.is_empty() {
520 return None;
521 }
522
523 let mut is_tight = true;
526 let mut is_compact = true;
527
528 for line in table_lines {
529 let cells = Self::parse_table_row_with_flavor(line, flavor);
530
531 if cells.is_empty() {
532 continue;
533 }
534
535 if Self::is_delimiter_row(&cells) {
537 continue;
538 }
539
540 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
542
543 let row_has_single_space = cells.iter().all(|cell| {
545 let trimmed = cell.trim();
546 cell == &format!(" {trimmed} ")
547 });
548
549 if !row_has_no_padding {
551 is_tight = false;
552 }
553
554 if !row_has_single_space {
556 is_compact = false;
557 }
558
559 if !is_tight && !is_compact {
561 return Some("aligned".to_string());
562 }
563 }
564
565 if is_tight {
567 Some("tight".to_string())
568 } else if is_compact {
569 Some("compact".to_string())
570 } else {
571 Some("aligned".to_string())
572 }
573 }
574
575 fn fix_table_block(
576 &self,
577 lines: &[&str],
578 table_block: &crate::utils::table_utils::TableBlock,
579 flavor: crate::config::MarkdownFlavor,
580 ) -> TableFormatResult {
581 let mut result = Vec::new();
582 let mut auto_compacted = false;
583 let mut aligned_width = None;
584
585 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
586 .chain(std::iter::once(lines[table_block.delimiter_line]))
587 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
588 .collect();
589
590 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
591 return TableFormatResult {
592 lines: table_lines.iter().map(|s| s.to_string()).collect(),
593 auto_compacted: false,
594 aligned_width: None,
595 };
596 }
597
598 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
601
602 let list_context = &table_block.list_context;
604 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
605 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
606 } else {
607 ("", String::new())
608 };
609
610 let stripped_lines: Vec<&str> = table_lines
612 .iter()
613 .enumerate()
614 .map(|(i, line)| {
615 let after_blockquote = Self::extract_blockquote_prefix(line).1;
616 if list_context.is_some() {
617 if i == 0 {
618 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
620 } else {
621 after_blockquote
623 .strip_prefix(&continuation_indent)
624 .unwrap_or(after_blockquote.trim_start())
625 }
626 } else {
627 after_blockquote
628 }
629 })
630 .collect();
631
632 let style = self.config.style.as_str();
633
634 match style {
635 "any" => {
636 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
637 if detected_style.is_none() {
638 return TableFormatResult {
639 lines: table_lines.iter().map(|s| s.to_string()).collect(),
640 auto_compacted: false,
641 aligned_width: None,
642 };
643 }
644
645 let target_style = detected_style.unwrap();
646
647 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
649 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
650
651 for line in &stripped_lines {
652 let cells = Self::parse_table_row_with_flavor(line, flavor);
653 match target_style.as_str() {
654 "tight" => result.push(Self::format_table_tight(&cells)),
655 "compact" => result.push(Self::format_table_compact(&cells)),
656 _ => {
657 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
658 let is_delimiter = Self::is_delimiter_row(&cells);
659 result.push(Self::format_table_row(
660 &cells,
661 &column_widths,
662 &column_alignments,
663 is_delimiter,
664 false,
665 self.config.column_align,
666 ));
667 }
668 }
669 }
670 }
671 "compact" => {
672 for line in &stripped_lines {
673 let cells = Self::parse_table_row_with_flavor(line, flavor);
674 result.push(Self::format_table_compact(&cells));
675 }
676 }
677 "tight" => {
678 for line in &stripped_lines {
679 let cells = Self::parse_table_row_with_flavor(line, flavor);
680 result.push(Self::format_table_tight(&cells));
681 }
682 }
683 "aligned" | "aligned-no-space" => {
684 let compact_delimiter = style == "aligned-no-space";
685
686 if self.config.column_align == ColumnAlign::Auto
691 && Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter)
692 {
693 return TableFormatResult {
694 lines: table_lines.iter().map(|s| s.to_string()).collect(),
695 auto_compacted: false,
696 aligned_width: None,
697 };
698 }
699
700 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
701
702 let num_columns = column_widths.len();
704 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
705 aligned_width = Some(calc_aligned_width);
706
707 if calc_aligned_width > self.effective_max_width() {
709 auto_compacted = true;
710 for line in &stripped_lines {
711 let cells = Self::parse_table_row_with_flavor(line, flavor);
712 result.push(Self::format_table_compact(&cells));
713 }
714 } else {
715 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
717 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
718
719 for line in &stripped_lines {
720 let cells = Self::parse_table_row_with_flavor(line, flavor);
721 let is_delimiter = Self::is_delimiter_row(&cells);
722 result.push(Self::format_table_row(
723 &cells,
724 &column_widths,
725 &column_alignments,
726 is_delimiter,
727 compact_delimiter,
728 self.config.column_align,
729 ));
730 }
731 }
732 }
733 _ => {
734 return TableFormatResult {
735 lines: table_lines.iter().map(|s| s.to_string()).collect(),
736 auto_compacted: false,
737 aligned_width: None,
738 };
739 }
740 }
741
742 let prefixed_result: Vec<String> = result
744 .into_iter()
745 .enumerate()
746 .map(|(i, line)| {
747 if list_context.is_some() {
748 if i == 0 {
749 format!("{blockquote_prefix}{list_prefix}{line}")
751 } else {
752 format!("{blockquote_prefix}{continuation_indent}{line}")
754 }
755 } else {
756 format!("{blockquote_prefix}{line}")
757 }
758 })
759 .collect();
760
761 TableFormatResult {
762 lines: prefixed_result,
763 auto_compacted,
764 aligned_width,
765 }
766 }
767}
768
769impl Rule for MD060TableFormat {
770 fn name(&self) -> &'static str {
771 "MD060"
772 }
773
774 fn description(&self) -> &'static str {
775 "Table columns should be consistently aligned"
776 }
777
778 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
779 !self.config.enabled || !ctx.likely_has_tables()
780 }
781
782 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
783 if !self.config.enabled {
784 return Ok(Vec::new());
785 }
786
787 let content = ctx.content;
788 let line_index = &ctx.line_index;
789 let mut warnings = Vec::new();
790
791 let lines: Vec<&str> = content.lines().collect();
792 let table_blocks = &ctx.table_blocks;
793
794 for table_block in table_blocks {
795 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
796
797 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
798 .chain(std::iter::once(table_block.delimiter_line))
799 .chain(table_block.content_lines.iter().copied())
800 .collect();
801
802 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());
809 for (i, &line_idx) in table_line_indices.iter().enumerate() {
810 let fixed_line = &format_result.lines[i];
811 if line_idx < lines.len() - 1 {
813 fixed_table_lines.push(format!("{fixed_line}\n"));
814 } else {
815 fixed_table_lines.push(fixed_line.clone());
816 }
817 }
818 let table_replacement = fixed_table_lines.concat();
819 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
820
821 for (i, &line_idx) in table_line_indices.iter().enumerate() {
822 let original = lines[line_idx];
823 let fixed = &format_result.lines[i];
824
825 if original != fixed {
826 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
827
828 let message = if format_result.auto_compacted {
829 if let Some(width) = format_result.aligned_width {
830 format!(
831 "Table too wide for aligned formatting ({} chars > max-width: {})",
832 width,
833 self.effective_max_width()
834 )
835 } else {
836 "Table too wide for aligned formatting".to_string()
837 }
838 } else {
839 "Table columns should be aligned".to_string()
840 };
841
842 warnings.push(LintWarning {
845 rule_name: Some(self.name().to_string()),
846 severity: Severity::Warning,
847 message,
848 line: start_line,
849 column: start_col,
850 end_line,
851 end_column: end_col,
852 fix: Some(crate::rule::Fix {
853 range: table_range.clone(),
854 replacement: table_replacement.clone(),
855 }),
856 });
857 }
858 }
859 }
860
861 Ok(warnings)
862 }
863
864 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
865 if !self.config.enabled {
866 return Ok(ctx.content.to_string());
867 }
868
869 let content = ctx.content;
870 let lines: Vec<&str> = content.lines().collect();
871 let table_blocks = &ctx.table_blocks;
872
873 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
874
875 for table_block in table_blocks {
876 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
877
878 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
879 .chain(std::iter::once(table_block.delimiter_line))
880 .chain(table_block.content_lines.iter().copied())
881 .collect();
882
883 for (i, &line_idx) in table_line_indices.iter().enumerate() {
884 result_lines[line_idx] = format_result.lines[i].clone();
885 }
886 }
887
888 let mut fixed = result_lines.join("\n");
889 if content.ends_with('\n') && !fixed.ends_with('\n') {
890 fixed.push('\n');
891 }
892 Ok(fixed)
893 }
894
895 fn as_any(&self) -> &dyn std::any::Any {
896 self
897 }
898
899 fn default_config_section(&self) -> Option<(String, toml::Value)> {
900 let json_value = serde_json::to_value(&self.config).ok()?;
901 Some((
902 self.name().to_string(),
903 crate::rule_config_serde::json_to_toml_value(&json_value)?,
904 ))
905 }
906
907 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
908 where
909 Self: Sized,
910 {
911 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
912 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
913
914 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
916
917 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924 use crate::lint_context::LintContext;
925 use crate::types::LineLength;
926
927 fn md013_with_line_length(line_length: usize) -> MD013Config {
929 MD013Config {
930 line_length: LineLength::from_const(line_length),
931 tables: true, ..Default::default()
933 }
934 }
935
936 #[test]
937 fn test_md060_disabled_by_default() {
938 let rule = MD060TableFormat::default();
939 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941
942 let warnings = rule.check(&ctx).unwrap();
943 assert_eq!(warnings.len(), 0);
944
945 let fixed = rule.fix(&ctx).unwrap();
946 assert_eq!(fixed, content);
947 }
948
949 #[test]
950 fn test_md060_align_simple_ascii_table() {
951 let rule = MD060TableFormat::new(true, "aligned".to_string());
952
953 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955
956 let fixed = rule.fix(&ctx).unwrap();
957 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
958 assert_eq!(fixed, expected);
959
960 let lines: Vec<&str> = fixed.lines().collect();
962 assert_eq!(lines[0].len(), lines[1].len());
963 assert_eq!(lines[1].len(), lines[2].len());
964 }
965
966 #[test]
967 fn test_md060_cjk_characters_aligned_correctly() {
968 let rule = MD060TableFormat::new(true, "aligned".to_string());
969
970 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972
973 let fixed = rule.fix(&ctx).unwrap();
974
975 let lines: Vec<&str> = fixed.lines().collect();
976 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
977 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
978
979 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
980 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
981
982 assert_eq!(width1, width3);
983 }
984
985 #[test]
986 fn test_md060_basic_emoji() {
987 let rule = MD060TableFormat::new(true, "aligned".to_string());
988
989 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991
992 let fixed = rule.fix(&ctx).unwrap();
993 assert!(fixed.contains("Status"));
994 }
995
996 #[test]
997 fn test_md060_zwj_emoji_skipped() {
998 let rule = MD060TableFormat::new(true, "aligned".to_string());
999
1000 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002
1003 let fixed = rule.fix(&ctx).unwrap();
1004 assert_eq!(fixed, content);
1005 }
1006
1007 #[test]
1008 fn test_md060_inline_code_with_escaped_pipes() {
1009 let rule = MD060TableFormat::new(true, "aligned".to_string());
1012
1013 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016
1017 let fixed = rule.fix(&ctx).unwrap();
1018 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1019 }
1020
1021 #[test]
1022 fn test_md060_compact_style() {
1023 let rule = MD060TableFormat::new(true, "compact".to_string());
1024
1025 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027
1028 let fixed = rule.fix(&ctx).unwrap();
1029 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1030 assert_eq!(fixed, expected);
1031 }
1032
1033 #[test]
1034 fn test_md060_tight_style() {
1035 let rule = MD060TableFormat::new(true, "tight".to_string());
1036
1037 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039
1040 let fixed = rule.fix(&ctx).unwrap();
1041 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1042 assert_eq!(fixed, expected);
1043 }
1044
1045 #[test]
1046 fn test_md060_aligned_no_space_style() {
1047 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1049
1050 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052
1053 let fixed = rule.fix(&ctx).unwrap();
1054
1055 let lines: Vec<&str> = fixed.lines().collect();
1057 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1058 assert_eq!(
1059 lines[1], "|-------|-----|",
1060 "Delimiter should have NO spaces around dashes"
1061 );
1062 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1063
1064 assert_eq!(lines[0].len(), lines[1].len());
1066 assert_eq!(lines[1].len(), lines[2].len());
1067 }
1068
1069 #[test]
1070 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1071 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1073
1074 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076
1077 let fixed = rule.fix(&ctx).unwrap();
1078 let lines: Vec<&str> = fixed.lines().collect();
1079
1080 assert!(
1082 fixed.contains("|:"),
1083 "Should have left alignment indicator adjacent to pipe"
1084 );
1085 assert!(
1086 fixed.contains(":|"),
1087 "Should have right alignment indicator adjacent to pipe"
1088 );
1089 assert!(
1091 lines[1].contains(":---") && lines[1].contains("---:"),
1092 "Should have center alignment colons"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_md060_aligned_no_space_three_column_table() {
1098 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1100
1101 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 |";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103
1104 let fixed = rule.fix(&ctx).unwrap();
1105 let lines: Vec<&str> = fixed.lines().collect();
1106
1107 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1109 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1110 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1111 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1112 }
1113
1114 #[test]
1115 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1116 let config = MD060Config {
1118 enabled: true,
1119 style: "aligned-no-space".to_string(),
1120 max_width: LineLength::from_const(50),
1121 column_align: ColumnAlign::Auto,
1122 };
1123 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1124
1125 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128
1129 let fixed = rule.fix(&ctx).unwrap();
1130
1131 assert!(
1133 fixed.contains("| --- |"),
1134 "Should be compact format when exceeding max-width"
1135 );
1136 }
1137
1138 #[test]
1139 fn test_md060_aligned_no_space_cjk_characters() {
1140 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1142
1143 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145
1146 let fixed = rule.fix(&ctx).unwrap();
1147 let lines: Vec<&str> = fixed.lines().collect();
1148
1149 use unicode_width::UnicodeWidthStr;
1152 assert_eq!(
1153 lines[0].width(),
1154 lines[1].width(),
1155 "Header and delimiter should have same display width"
1156 );
1157 assert_eq!(
1158 lines[1].width(),
1159 lines[2].width(),
1160 "Delimiter and content should have same display width"
1161 );
1162
1163 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1165 }
1166
1167 #[test]
1168 fn test_md060_aligned_no_space_minimum_width() {
1169 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1171
1172 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174
1175 let fixed = rule.fix(&ctx).unwrap();
1176 let lines: Vec<&str> = fixed.lines().collect();
1177
1178 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1180 assert_eq!(lines[0].len(), lines[1].len());
1182 assert_eq!(lines[1].len(), lines[2].len());
1183 }
1184
1185 #[test]
1186 fn test_md060_any_style_consistency() {
1187 let rule = MD060TableFormat::new(true, "any".to_string());
1188
1189 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192
1193 let fixed = rule.fix(&ctx).unwrap();
1194 assert_eq!(fixed, content);
1195
1196 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1198 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1199
1200 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1201 assert_eq!(fixed_aligned, content_aligned);
1202 }
1203
1204 #[test]
1205 fn test_md060_empty_cells() {
1206 let rule = MD060TableFormat::new(true, "aligned".to_string());
1207
1208 let content = "| A | B |\n|---|---|\n| | X |";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210
1211 let fixed = rule.fix(&ctx).unwrap();
1212 assert!(fixed.contains("|"));
1213 }
1214
1215 #[test]
1216 fn test_md060_mixed_content() {
1217 let rule = MD060TableFormat::new(true, "aligned".to_string());
1218
1219 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221
1222 let fixed = rule.fix(&ctx).unwrap();
1223 assert!(fixed.contains("δΈζ"));
1224 assert!(fixed.contains("NYC"));
1225 }
1226
1227 #[test]
1228 fn test_md060_preserve_alignment_indicators() {
1229 let rule = MD060TableFormat::new(true, "aligned".to_string());
1230
1231 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233
1234 let fixed = rule.fix(&ctx).unwrap();
1235
1236 assert!(fixed.contains(":---"), "Should contain left alignment");
1237 assert!(fixed.contains(":----:"), "Should contain center alignment");
1238 assert!(fixed.contains("----:"), "Should contain right alignment");
1239 }
1240
1241 #[test]
1242 fn test_md060_minimum_column_width() {
1243 let rule = MD060TableFormat::new(true, "aligned".to_string());
1244
1245 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249
1250 let fixed = rule.fix(&ctx).unwrap();
1251
1252 let lines: Vec<&str> = fixed.lines().collect();
1253 assert_eq!(lines[0].len(), lines[1].len());
1254 assert_eq!(lines[1].len(), lines[2].len());
1255
1256 assert!(fixed.contains("ID "), "Short content should be padded");
1258 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1259 }
1260
1261 #[test]
1262 fn test_md060_auto_compact_exceeds_default_threshold() {
1263 let config = MD060Config {
1265 enabled: true,
1266 style: "aligned".to_string(),
1267 max_width: LineLength::from_const(0),
1268 column_align: ColumnAlign::Auto,
1269 };
1270 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1271
1272 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277
1278 let fixed = rule.fix(&ctx).unwrap();
1279
1280 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1282 assert!(fixed.contains("| --- | --- | --- |"));
1283 assert!(fixed.contains("| Short | Data | Here |"));
1284
1285 let lines: Vec<&str> = fixed.lines().collect();
1287 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1289 }
1290
1291 #[test]
1292 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1293 let config = MD060Config {
1295 enabled: true,
1296 style: "aligned".to_string(),
1297 max_width: LineLength::from_const(50),
1298 column_align: ColumnAlign::Auto,
1299 };
1300 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 |";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307
1308 let fixed = rule.fix(&ctx).unwrap();
1309
1310 assert!(
1312 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1313 );
1314 assert!(fixed.contains("| --- | --- | --- |"));
1315 assert!(fixed.contains("| Data | Data | Data |"));
1316
1317 let lines: Vec<&str> = fixed.lines().collect();
1319 assert!(lines[0].len() != lines[2].len());
1320 }
1321
1322 #[test]
1323 fn test_md060_stays_aligned_under_threshold() {
1324 let config = MD060Config {
1326 enabled: true,
1327 style: "aligned".to_string(),
1328 max_width: LineLength::from_const(100),
1329 column_align: ColumnAlign::Auto,
1330 };
1331 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1332
1333 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336
1337 let fixed = rule.fix(&ctx).unwrap();
1338
1339 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1341 assert_eq!(fixed, expected);
1342
1343 let lines: Vec<&str> = fixed.lines().collect();
1344 assert_eq!(lines[0].len(), lines[1].len());
1345 assert_eq!(lines[1].len(), lines[2].len());
1346 }
1347
1348 #[test]
1349 fn test_md060_width_calculation_formula() {
1350 let config = MD060Config {
1352 enabled: true,
1353 style: "aligned".to_string(),
1354 max_width: LineLength::from_const(0),
1355 column_align: ColumnAlign::Auto,
1356 };
1357 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1358
1359 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364
1365 let fixed = rule.fix(&ctx).unwrap();
1366
1367 let lines: Vec<&str> = fixed.lines().collect();
1369 assert_eq!(lines[0].len(), lines[1].len());
1370 assert_eq!(lines[1].len(), lines[2].len());
1371 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1375 enabled: true,
1376 style: "aligned".to_string(),
1377 max_width: LineLength::from_const(24),
1378 column_align: ColumnAlign::Auto,
1379 };
1380 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1381
1382 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1383
1384 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1386 assert!(fixed_compact.contains("| --- | --- | --- |"));
1387 }
1388
1389 #[test]
1390 fn test_md060_very_wide_table_auto_compacts() {
1391 let config = MD060Config {
1392 enabled: true,
1393 style: "aligned".to_string(),
1394 max_width: LineLength::from_const(0),
1395 column_align: ColumnAlign::Auto,
1396 };
1397 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1398
1399 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 |";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404
1405 let fixed = rule.fix(&ctx).unwrap();
1406
1407 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1409 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1410 }
1411
1412 #[test]
1413 fn test_md060_inherit_from_md013_line_length() {
1414 let config = MD060Config {
1416 enabled: true,
1417 style: "aligned".to_string(),
1418 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1420 };
1421
1422 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1424 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1425
1426 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429
1430 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1432
1433 let fixed_120 = rule_120.fix(&ctx).unwrap();
1435
1436 let lines_120: Vec<&str> = fixed_120.lines().collect();
1438 assert_eq!(lines_120[0].len(), lines_120[1].len());
1439 assert_eq!(lines_120[1].len(), lines_120[2].len());
1440 }
1441
1442 #[test]
1443 fn test_md060_edge_case_exactly_at_threshold() {
1444 let config = MD060Config {
1448 enabled: true,
1449 style: "aligned".to_string(),
1450 max_width: LineLength::from_const(17),
1451 column_align: ColumnAlign::Auto,
1452 };
1453 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1454
1455 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457
1458 let fixed = rule.fix(&ctx).unwrap();
1459
1460 let lines: Vec<&str> = fixed.lines().collect();
1462 assert_eq!(lines[0].len(), 17);
1463 assert_eq!(lines[0].len(), lines[1].len());
1464 assert_eq!(lines[1].len(), lines[2].len());
1465
1466 let config_under = MD060Config {
1468 enabled: true,
1469 style: "aligned".to_string(),
1470 max_width: LineLength::from_const(16),
1471 column_align: ColumnAlign::Auto,
1472 };
1473 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1474
1475 let fixed_compact = rule_under.fix(&ctx).unwrap();
1476
1477 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1479 assert!(fixed_compact.contains("| --- | --- |"));
1480 }
1481
1482 #[test]
1483 fn test_md060_auto_compact_warning_message() {
1484 let config = MD060Config {
1486 enabled: true,
1487 style: "aligned".to_string(),
1488 max_width: LineLength::from_const(50),
1489 column_align: ColumnAlign::Auto,
1490 };
1491 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1492
1493 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496
1497 let warnings = rule.check(&ctx).unwrap();
1498
1499 assert!(!warnings.is_empty(), "Should generate warnings");
1501
1502 let auto_compact_warnings: Vec<_> = warnings
1503 .iter()
1504 .filter(|w| w.message.contains("too wide for aligned formatting"))
1505 .collect();
1506
1507 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1508
1509 let first_warning = auto_compact_warnings[0];
1511 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1512 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1513 }
1514
1515 #[test]
1516 fn test_md060_issue_129_detect_style_from_all_rows() {
1517 let rule = MD060TableFormat::new(true, "any".to_string());
1521
1522 let content = "| a long heading | another long heading |\n\
1524 | -------------- | -------------------- |\n\
1525 | a | 1 |\n\
1526 | b b | 2 |\n\
1527 | c c c | 3 |";
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529
1530 let fixed = rule.fix(&ctx).unwrap();
1531
1532 assert!(
1534 fixed.contains("| a | 1 |"),
1535 "Should preserve aligned padding in first content row"
1536 );
1537 assert!(
1538 fixed.contains("| b b | 2 |"),
1539 "Should preserve aligned padding in second content row"
1540 );
1541 assert!(
1542 fixed.contains("| c c c | 3 |"),
1543 "Should preserve aligned padding in third content row"
1544 );
1545
1546 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1548 }
1549
1550 #[test]
1551 fn test_md060_regular_alignment_warning_message() {
1552 let config = MD060Config {
1554 enabled: true,
1555 style: "aligned".to_string(),
1556 max_width: LineLength::from_const(100), column_align: ColumnAlign::Auto,
1558 };
1559 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1560
1561 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564
1565 let warnings = rule.check(&ctx).unwrap();
1566
1567 assert!(!warnings.is_empty(), "Should generate warnings");
1569
1570 assert!(warnings[0].message.contains("Table columns should be aligned"));
1572 assert!(!warnings[0].message.contains("too wide"));
1573 assert!(!warnings[0].message.contains("max-width"));
1574 }
1575
1576 #[test]
1579 fn test_md060_unlimited_when_md013_disabled() {
1580 let config = MD060Config {
1582 enabled: true,
1583 style: "aligned".to_string(),
1584 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1586 };
1587 let md013_config = MD013Config::default();
1588 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1589
1590 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 let fixed = rule.fix(&ctx).unwrap();
1594
1595 let lines: Vec<&str> = fixed.lines().collect();
1597 assert_eq!(
1599 lines[0].len(),
1600 lines[1].len(),
1601 "Table should be aligned when MD013 is disabled"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_md060_unlimited_when_md013_tables_false() {
1607 let config = MD060Config {
1609 enabled: true,
1610 style: "aligned".to_string(),
1611 max_width: LineLength::from_const(0),
1612 column_align: ColumnAlign::Auto,
1613 };
1614 let md013_config = MD013Config {
1615 tables: false, line_length: LineLength::from_const(80),
1617 ..Default::default()
1618 };
1619 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1620
1621 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1624 let fixed = rule.fix(&ctx).unwrap();
1625
1626 let lines: Vec<&str> = fixed.lines().collect();
1628 assert_eq!(
1629 lines[0].len(),
1630 lines[1].len(),
1631 "Table should be aligned when MD013.tables=false"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_md060_unlimited_when_md013_line_length_zero() {
1637 let config = MD060Config {
1639 enabled: true,
1640 style: "aligned".to_string(),
1641 max_width: LineLength::from_const(0),
1642 column_align: ColumnAlign::Auto,
1643 };
1644 let md013_config = MD013Config {
1645 tables: true,
1646 line_length: LineLength::from_const(0), ..Default::default()
1648 };
1649 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1650
1651 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let fixed = rule.fix(&ctx).unwrap();
1655
1656 let lines: Vec<&str> = fixed.lines().collect();
1658 assert_eq!(
1659 lines[0].len(),
1660 lines[1].len(),
1661 "Table should be aligned when MD013.line_length=0"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_md060_explicit_max_width_overrides_md013_settings() {
1667 let config = MD060Config {
1669 enabled: true,
1670 style: "aligned".to_string(),
1671 max_width: LineLength::from_const(50), column_align: ColumnAlign::Auto,
1673 };
1674 let md013_config = MD013Config {
1675 tables: false, line_length: LineLength::from_const(0), ..Default::default()
1678 };
1679 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1680
1681 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684 let fixed = rule.fix(&ctx).unwrap();
1685
1686 assert!(
1688 fixed.contains("| --- |"),
1689 "Should be compact format due to explicit max_width"
1690 );
1691 }
1692
1693 #[test]
1694 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1695 let config = MD060Config {
1697 enabled: true,
1698 style: "aligned".to_string(),
1699 max_width: LineLength::from_const(0), column_align: ColumnAlign::Auto,
1701 };
1702 let md013_config = MD013Config {
1703 tables: true,
1704 line_length: LineLength::from_const(50), ..Default::default()
1706 };
1707 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1708
1709 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1712 let fixed = rule.fix(&ctx).unwrap();
1713
1714 assert!(
1716 fixed.contains("| --- |"),
1717 "Should be compact format when inheriting MD013 limit"
1718 );
1719 }
1720
1721 #[test]
1724 fn test_aligned_no_space_reformats_spaced_delimiter() {
1725 let config = MD060Config {
1728 enabled: true,
1729 style: "aligned-no-space".to_string(),
1730 max_width: LineLength::from_const(0),
1731 column_align: ColumnAlign::Auto,
1732 };
1733 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1734
1735 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738 let fixed = rule.fix(&ctx).unwrap();
1739
1740 assert!(
1743 !fixed.contains("| ----"),
1744 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1745 );
1746 assert!(
1747 !fixed.contains("---- |"),
1748 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1749 );
1750 assert!(
1752 fixed.contains("|----"),
1753 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1754 );
1755 }
1756
1757 #[test]
1758 fn test_aligned_reformats_compact_delimiter() {
1759 let config = MD060Config {
1762 enabled: true,
1763 style: "aligned".to_string(),
1764 max_width: LineLength::from_const(0),
1765 column_align: ColumnAlign::Auto,
1766 };
1767 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1768
1769 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772 let fixed = rule.fix(&ctx).unwrap();
1773
1774 assert!(
1776 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1777 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_aligned_no_space_preserves_matching_table() {
1783 let config = MD060Config {
1785 enabled: true,
1786 style: "aligned-no-space".to_string(),
1787 max_width: LineLength::from_const(0),
1788 column_align: ColumnAlign::Auto,
1789 };
1790 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1791
1792 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let fixed = rule.fix(&ctx).unwrap();
1796
1797 assert_eq!(
1799 fixed, content,
1800 "Table already in aligned-no-space style should be preserved"
1801 );
1802 }
1803
1804 #[test]
1805 fn test_aligned_preserves_matching_table() {
1806 let config = MD060Config {
1808 enabled: true,
1809 style: "aligned".to_string(),
1810 max_width: LineLength::from_const(0),
1811 column_align: ColumnAlign::Auto,
1812 };
1813 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1814
1815 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let fixed = rule.fix(&ctx).unwrap();
1819
1820 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
1822 }
1823
1824 #[test]
1825 fn test_cjk_table_display_width_consistency() {
1826 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
1832
1833 let is_aligned =
1835 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
1836 assert!(
1837 !is_aligned,
1838 "Table with uneven raw line lengths should NOT be considered aligned"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_cjk_width_calculation_in_aligned_check() {
1844 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
1847 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
1848
1849 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
1850 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
1851
1852 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
1854 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
1855
1856 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
1858 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
1860 }
1861
1862 #[test]
1865 fn test_md060_column_align_left() {
1866 let config = MD060Config {
1868 enabled: true,
1869 style: "aligned".to_string(),
1870 max_width: LineLength::from_const(0),
1871 column_align: ColumnAlign::Left,
1872 };
1873 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1874
1875 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
1876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1877
1878 let fixed = rule.fix(&ctx).unwrap();
1879 let lines: Vec<&str> = fixed.lines().collect();
1880
1881 assert!(
1883 lines[2].contains("| Alice "),
1884 "Content should be left-aligned (Alice should have trailing padding)"
1885 );
1886 assert!(
1887 lines[3].contains("| Bob "),
1888 "Content should be left-aligned (Bob should have trailing padding)"
1889 );
1890 }
1891
1892 #[test]
1893 fn test_md060_column_align_center() {
1894 let config = MD060Config {
1896 enabled: true,
1897 style: "aligned".to_string(),
1898 max_width: LineLength::from_const(0),
1899 column_align: ColumnAlign::Center,
1900 };
1901 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1902
1903 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
1904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1905
1906 let fixed = rule.fix(&ctx).unwrap();
1907 let lines: Vec<&str> = fixed.lines().collect();
1908
1909 assert!(
1912 lines[3].contains("| Bob |"),
1913 "Bob should be centered with padding on both sides. Got: {}",
1914 lines[3]
1915 );
1916 }
1917
1918 #[test]
1919 fn test_md060_column_align_right() {
1920 let config = MD060Config {
1922 enabled: true,
1923 style: "aligned".to_string(),
1924 max_width: LineLength::from_const(0),
1925 column_align: ColumnAlign::Right,
1926 };
1927 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1928
1929 let content = "| Name | Age | City |\n|---|---|---|\n| Alice | 30 | Seattle |\n| Bob | 25 | Portland |";
1930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1931
1932 let fixed = rule.fix(&ctx).unwrap();
1933 let lines: Vec<&str> = fixed.lines().collect();
1934
1935 assert!(
1937 lines[3].contains("| Bob |"),
1938 "Bob should be right-aligned with padding on left. Got: {}",
1939 lines[3]
1940 );
1941 }
1942
1943 #[test]
1944 fn test_md060_column_align_auto_respects_delimiter() {
1945 let config = MD060Config {
1947 enabled: true,
1948 style: "aligned".to_string(),
1949 max_width: LineLength::from_const(0),
1950 column_align: ColumnAlign::Auto,
1951 };
1952 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1953
1954 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1957
1958 let fixed = rule.fix(&ctx).unwrap();
1959
1960 assert!(fixed.contains("| A "), "Left column should be left-aligned");
1962 let lines: Vec<&str> = fixed.lines().collect();
1964 assert!(
1968 lines[2].contains(" C |"),
1969 "Right column should be right-aligned. Got: {}",
1970 lines[2]
1971 );
1972 }
1973
1974 #[test]
1975 fn test_md060_column_align_overrides_delimiter_indicators() {
1976 let config = MD060Config {
1978 enabled: true,
1979 style: "aligned".to_string(),
1980 max_width: LineLength::from_const(0),
1981 column_align: ColumnAlign::Right, };
1983 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1984
1985 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1988
1989 let fixed = rule.fix(&ctx).unwrap();
1990 let lines: Vec<&str> = fixed.lines().collect();
1991
1992 assert!(
1995 lines[2].contains(" A |") || lines[2].contains(" A |"),
1996 "Even left-indicated column should be right-aligned. Got: {}",
1997 lines[2]
1998 );
1999 }
2000
2001 #[test]
2002 fn test_md060_column_align_with_aligned_no_space() {
2003 let config = MD060Config {
2005 enabled: true,
2006 style: "aligned-no-space".to_string(),
2007 max_width: LineLength::from_const(0),
2008 column_align: ColumnAlign::Center,
2009 };
2010 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2011
2012 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014
2015 let fixed = rule.fix(&ctx).unwrap();
2016 let lines: Vec<&str> = fixed.lines().collect();
2017
2018 assert!(
2020 lines[1].contains("|---"),
2021 "Delimiter should have no spaces in aligned-no-space style. Got: {}",
2022 lines[1]
2023 );
2024 assert!(
2026 lines[3].contains("| Bob |"),
2027 "Content should be centered. Got: {}",
2028 lines[3]
2029 );
2030 }
2031
2032 #[test]
2033 fn test_md060_column_align_config_parsing() {
2034 let toml_str = r#"
2036enabled = true
2037style = "aligned"
2038column-align = "center"
2039"#;
2040 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2041 assert_eq!(config.column_align, ColumnAlign::Center);
2042
2043 let toml_str = r#"
2044enabled = true
2045style = "aligned"
2046column-align = "right"
2047"#;
2048 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2049 assert_eq!(config.column_align, ColumnAlign::Right);
2050
2051 let toml_str = r#"
2052enabled = true
2053style = "aligned"
2054column-align = "left"
2055"#;
2056 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2057 assert_eq!(config.column_align, ColumnAlign::Left);
2058
2059 let toml_str = r#"
2060enabled = true
2061style = "aligned"
2062column-align = "auto"
2063"#;
2064 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2065 assert_eq!(config.column_align, ColumnAlign::Auto);
2066 }
2067
2068 #[test]
2069 fn test_md060_column_align_default_is_auto() {
2070 let toml_str = r#"
2072enabled = true
2073style = "aligned"
2074"#;
2075 let config: MD060Config = toml::from_str(toml_str).expect("Should parse config");
2076 assert_eq!(config.column_align, ColumnAlign::Auto);
2077 }
2078
2079 #[test]
2080 fn test_md060_column_align_reformats_already_aligned_table() {
2081 let config = MD060Config {
2083 enabled: true,
2084 style: "aligned".to_string(),
2085 max_width: LineLength::from_const(0),
2086 column_align: ColumnAlign::Right,
2087 };
2088 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2089
2090 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2093
2094 let fixed = rule.fix(&ctx).unwrap();
2095 let lines: Vec<&str> = fixed.lines().collect();
2096
2097 assert!(
2099 lines[2].contains("| Alice |") && lines[2].contains("| 30 |"),
2100 "Already aligned table should be reformatted with right alignment. Got: {}",
2101 lines[2]
2102 );
2103 assert!(
2104 lines[3].contains("| Bob |") || lines[3].contains("| Bob |"),
2105 "Bob should be right-aligned. Got: {}",
2106 lines[3]
2107 );
2108 }
2109
2110 #[test]
2111 fn test_md060_column_align_with_cjk_characters() {
2112 let config = MD060Config {
2114 enabled: true,
2115 style: "aligned".to_string(),
2116 max_width: LineLength::from_const(0),
2117 column_align: ColumnAlign::Center,
2118 };
2119 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2120
2121 let content = "| Name | City |\n|---|---|\n| Alice | ζ±δΊ¬ |\n| Bob | LA |";
2122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2123
2124 let fixed = rule.fix(&ctx).unwrap();
2125
2126 assert!(fixed.contains("Bob"), "Table should contain Bob");
2129 assert!(fixed.contains("ζ±δΊ¬"), "Table should contain ζ±δΊ¬");
2130 }
2131
2132 #[test]
2133 fn test_md060_column_align_ignored_for_compact_style() {
2134 let config = MD060Config {
2136 enabled: true,
2137 style: "compact".to_string(),
2138 max_width: LineLength::from_const(0),
2139 column_align: ColumnAlign::Right, };
2141 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2142
2143 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145
2146 let fixed = rule.fix(&ctx).unwrap();
2147
2148 assert!(
2150 fixed.contains("| Alice |"),
2151 "Compact style should have single space padding, not alignment. Got: {fixed}"
2152 );
2153 }
2154
2155 #[test]
2156 fn test_md060_column_align_ignored_for_tight_style() {
2157 let config = MD060Config {
2159 enabled: true,
2160 style: "tight".to_string(),
2161 max_width: LineLength::from_const(0),
2162 column_align: ColumnAlign::Center, };
2164 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2165
2166 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| Bob | 25 |";
2167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2168
2169 let fixed = rule.fix(&ctx).unwrap();
2170
2171 assert!(
2173 fixed.contains("|Alice|"),
2174 "Tight style should have no spaces. Got: {fixed}"
2175 );
2176 }
2177
2178 #[test]
2179 fn test_md060_column_align_with_empty_cells() {
2180 let config = MD060Config {
2182 enabled: true,
2183 style: "aligned".to_string(),
2184 max_width: LineLength::from_const(0),
2185 column_align: ColumnAlign::Center,
2186 };
2187 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2188
2189 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |\n| | 25 |";
2190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2191
2192 let fixed = rule.fix(&ctx).unwrap();
2193 let lines: Vec<&str> = fixed.lines().collect();
2194
2195 assert!(
2197 lines[3].contains("| |") || lines[3].contains("| |"),
2198 "Empty cell should be padded correctly. Got: {}",
2199 lines[3]
2200 );
2201 }
2202
2203 #[test]
2204 fn test_md060_column_align_auto_preserves_already_aligned() {
2205 let config = MD060Config {
2207 enabled: true,
2208 style: "aligned".to_string(),
2209 max_width: LineLength::from_const(0),
2210 column_align: ColumnAlign::Auto,
2211 };
2212 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
2213
2214 let content = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |\n| Bob | 25 |";
2216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2217
2218 let fixed = rule.fix(&ctx).unwrap();
2219
2220 assert_eq!(
2222 fixed, content,
2223 "Already aligned table should be preserved with column-align=auto"
2224 );
2225 }
2226}