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(
426 table_lines: &[&str],
427 flavor: crate::config::MarkdownFlavor,
428 compact_delimiter: bool,
429 ) -> bool {
430 if table_lines.len() < 2 {
431 return false;
432 }
433
434 let first_len = table_lines[0].len();
436 if !table_lines.iter().all(|line| line.len() == first_len) {
437 return false;
438 }
439
440 let parsed: Vec<Vec<String>> = table_lines
442 .iter()
443 .map(|line| Self::parse_table_row_with_flavor(line, flavor))
444 .collect();
445
446 if parsed.is_empty() {
447 return false;
448 }
449
450 let num_columns = parsed[0].len();
451 if !parsed.iter().all(|row| row.len() == num_columns) {
452 return false;
453 }
454
455 if let Some(delimiter_row) = parsed.get(1) {
458 if !Self::is_delimiter_row(delimiter_row) {
459 return false;
460 }
461 for cell in delimiter_row {
463 let trimmed = cell.trim();
464 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
465 if dash_count < 1 {
466 return false;
467 }
468 }
469
470 let delimiter_has_spaces = delimiter_row
474 .iter()
475 .all(|cell| cell.starts_with(' ') && cell.ends_with(' '));
476
477 if compact_delimiter && delimiter_has_spaces {
480 return false;
481 }
482 if !compact_delimiter && !delimiter_has_spaces {
483 return false;
484 }
485 }
486
487 for col_idx in 0..num_columns {
491 let mut widths = Vec::new();
492 for (row_idx, row) in parsed.iter().enumerate() {
493 if row_idx == 1 {
495 continue;
496 }
497 if let Some(cell) = row.get(col_idx) {
498 widths.push(cell.width());
499 }
500 }
501 if !widths.is_empty() && !widths.iter().all(|&w| w == widths[0]) {
503 return false;
504 }
505 }
506
507 true
508 }
509
510 fn detect_table_style(table_lines: &[&str], flavor: crate::config::MarkdownFlavor) -> Option<String> {
511 if table_lines.is_empty() {
512 return None;
513 }
514
515 let mut is_tight = true;
518 let mut is_compact = true;
519
520 for line in table_lines {
521 let cells = Self::parse_table_row_with_flavor(line, flavor);
522
523 if cells.is_empty() {
524 continue;
525 }
526
527 if Self::is_delimiter_row(&cells) {
529 continue;
530 }
531
532 let row_has_no_padding = cells.iter().all(|cell| !cell.starts_with(' ') && !cell.ends_with(' '));
534
535 let row_has_single_space = cells.iter().all(|cell| {
537 let trimmed = cell.trim();
538 cell == &format!(" {trimmed} ")
539 });
540
541 if !row_has_no_padding {
543 is_tight = false;
544 }
545
546 if !row_has_single_space {
548 is_compact = false;
549 }
550
551 if !is_tight && !is_compact {
553 return Some("aligned".to_string());
554 }
555 }
556
557 if is_tight {
559 Some("tight".to_string())
560 } else if is_compact {
561 Some("compact".to_string())
562 } else {
563 Some("aligned".to_string())
564 }
565 }
566
567 fn fix_table_block(
568 &self,
569 lines: &[&str],
570 table_block: &crate::utils::table_utils::TableBlock,
571 flavor: crate::config::MarkdownFlavor,
572 ) -> TableFormatResult {
573 let mut result = Vec::new();
574 let mut auto_compacted = false;
575 let mut aligned_width = None;
576
577 let table_lines: Vec<&str> = std::iter::once(lines[table_block.header_line])
578 .chain(std::iter::once(lines[table_block.delimiter_line]))
579 .chain(table_block.content_lines.iter().map(|&idx| lines[idx]))
580 .collect();
581
582 if table_lines.iter().any(|line| Self::contains_problematic_chars(line)) {
583 return TableFormatResult {
584 lines: table_lines.iter().map(|s| s.to_string()).collect(),
585 auto_compacted: false,
586 aligned_width: None,
587 };
588 }
589
590 let (blockquote_prefix, _) = Self::extract_blockquote_prefix(table_lines[0]);
593
594 let stripped_lines: Vec<&str> = table_lines
596 .iter()
597 .map(|line| Self::extract_blockquote_prefix(line).1)
598 .collect();
599
600 let style = self.config.style.as_str();
601
602 match style {
603 "any" => {
604 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
605 if detected_style.is_none() {
606 return TableFormatResult {
607 lines: table_lines.iter().map(|s| s.to_string()).collect(),
608 auto_compacted: false,
609 aligned_width: None,
610 };
611 }
612
613 let target_style = detected_style.unwrap();
614
615 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
617 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
618
619 for line in &stripped_lines {
620 let cells = Self::parse_table_row_with_flavor(line, flavor);
621 match target_style.as_str() {
622 "tight" => result.push(Self::format_table_tight(&cells)),
623 "compact" => result.push(Self::format_table_compact(&cells)),
624 _ => {
625 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
626 let is_delimiter = Self::is_delimiter_row(&cells);
627 result.push(Self::format_table_row(
628 &cells,
629 &column_widths,
630 &column_alignments,
631 is_delimiter,
632 false,
633 ));
634 }
635 }
636 }
637 }
638 "compact" => {
639 for line in &stripped_lines {
640 let cells = Self::parse_table_row_with_flavor(line, flavor);
641 result.push(Self::format_table_compact(&cells));
642 }
643 }
644 "tight" => {
645 for line in &stripped_lines {
646 let cells = Self::parse_table_row_with_flavor(line, flavor);
647 result.push(Self::format_table_tight(&cells));
648 }
649 }
650 "aligned" | "aligned-no-space" => {
651 let compact_delimiter = style == "aligned-no-space";
652
653 if Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
656 return TableFormatResult {
657 lines: table_lines.iter().map(|s| s.to_string()).collect(),
658 auto_compacted: false,
659 aligned_width: None,
660 };
661 }
662
663 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
664
665 let num_columns = column_widths.len();
667 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
668 aligned_width = Some(calc_aligned_width);
669
670 if calc_aligned_width > self.effective_max_width() {
672 auto_compacted = true;
673 for line in &stripped_lines {
674 let cells = Self::parse_table_row_with_flavor(line, flavor);
675 result.push(Self::format_table_compact(&cells));
676 }
677 } else {
678 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
680 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
681
682 for line in &stripped_lines {
683 let cells = Self::parse_table_row_with_flavor(line, flavor);
684 let is_delimiter = Self::is_delimiter_row(&cells);
685 result.push(Self::format_table_row(
686 &cells,
687 &column_widths,
688 &column_alignments,
689 is_delimiter,
690 compact_delimiter,
691 ));
692 }
693 }
694 }
695 _ => {
696 return TableFormatResult {
697 lines: table_lines.iter().map(|s| s.to_string()).collect(),
698 auto_compacted: false,
699 aligned_width: None,
700 };
701 }
702 }
703
704 let prefixed_result: Vec<String> = result
706 .into_iter()
707 .map(|line| format!("{blockquote_prefix}{line}"))
708 .collect();
709
710 TableFormatResult {
711 lines: prefixed_result,
712 auto_compacted,
713 aligned_width,
714 }
715 }
716}
717
718impl Rule for MD060TableFormat {
719 fn name(&self) -> &'static str {
720 "MD060"
721 }
722
723 fn description(&self) -> &'static str {
724 "Table columns should be consistently aligned"
725 }
726
727 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
728 !self.config.enabled || !ctx.likely_has_tables()
729 }
730
731 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
732 if !self.config.enabled {
733 return Ok(Vec::new());
734 }
735
736 let content = ctx.content;
737 let line_index = &ctx.line_index;
738 let mut warnings = Vec::new();
739
740 let lines: Vec<&str> = content.lines().collect();
741 let table_blocks = &ctx.table_blocks;
742
743 for table_block in table_blocks {
744 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
745
746 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
747 .chain(std::iter::once(table_block.delimiter_line))
748 .chain(table_block.content_lines.iter().copied())
749 .collect();
750
751 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());
758 for (i, &line_idx) in table_line_indices.iter().enumerate() {
759 let fixed_line = &format_result.lines[i];
760 if line_idx < lines.len() - 1 {
762 fixed_table_lines.push(format!("{fixed_line}\n"));
763 } else {
764 fixed_table_lines.push(fixed_line.clone());
765 }
766 }
767 let table_replacement = fixed_table_lines.concat();
768 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
769
770 for (i, &line_idx) in table_line_indices.iter().enumerate() {
771 let original = lines[line_idx];
772 let fixed = &format_result.lines[i];
773
774 if original != fixed {
775 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
776
777 let message = if format_result.auto_compacted {
778 if let Some(width) = format_result.aligned_width {
779 format!(
780 "Table too wide for aligned formatting ({} chars > max-width: {})",
781 width,
782 self.effective_max_width()
783 )
784 } else {
785 "Table too wide for aligned formatting".to_string()
786 }
787 } else {
788 "Table columns should be aligned".to_string()
789 };
790
791 warnings.push(LintWarning {
794 rule_name: Some(self.name().to_string()),
795 severity: Severity::Warning,
796 message,
797 line: start_line,
798 column: start_col,
799 end_line,
800 end_column: end_col,
801 fix: Some(crate::rule::Fix {
802 range: table_range.clone(),
803 replacement: table_replacement.clone(),
804 }),
805 });
806 }
807 }
808 }
809
810 Ok(warnings)
811 }
812
813 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
814 if !self.config.enabled {
815 return Ok(ctx.content.to_string());
816 }
817
818 let content = ctx.content;
819 let lines: Vec<&str> = content.lines().collect();
820 let table_blocks = &ctx.table_blocks;
821
822 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
823
824 for table_block in table_blocks {
825 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
826
827 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
828 .chain(std::iter::once(table_block.delimiter_line))
829 .chain(table_block.content_lines.iter().copied())
830 .collect();
831
832 for (i, &line_idx) in table_line_indices.iter().enumerate() {
833 result_lines[line_idx] = format_result.lines[i].clone();
834 }
835 }
836
837 let mut fixed = result_lines.join("\n");
838 if content.ends_with('\n') && !fixed.ends_with('\n') {
839 fixed.push('\n');
840 }
841 Ok(fixed)
842 }
843
844 fn as_any(&self) -> &dyn std::any::Any {
845 self
846 }
847
848 fn default_config_section(&self) -> Option<(String, toml::Value)> {
849 let json_value = serde_json::to_value(&self.config).ok()?;
850 Some((
851 self.name().to_string(),
852 crate::rule_config_serde::json_to_toml_value(&json_value)?,
853 ))
854 }
855
856 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
857 where
858 Self: Sized,
859 {
860 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
861 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
862
863 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
865
866 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::lint_context::LintContext;
874 use crate::types::LineLength;
875
876 fn md013_with_line_length(line_length: usize) -> MD013Config {
878 MD013Config {
879 line_length: LineLength::from_const(line_length),
880 tables: true, ..Default::default()
882 }
883 }
884
885 #[test]
886 fn test_md060_disabled_by_default() {
887 let rule = MD060TableFormat::default();
888 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890
891 let warnings = rule.check(&ctx).unwrap();
892 assert_eq!(warnings.len(), 0);
893
894 let fixed = rule.fix(&ctx).unwrap();
895 assert_eq!(fixed, content);
896 }
897
898 #[test]
899 fn test_md060_align_simple_ascii_table() {
900 let rule = MD060TableFormat::new(true, "aligned".to_string());
901
902 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904
905 let fixed = rule.fix(&ctx).unwrap();
906 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
907 assert_eq!(fixed, expected);
908
909 let lines: Vec<&str> = fixed.lines().collect();
911 assert_eq!(lines[0].len(), lines[1].len());
912 assert_eq!(lines[1].len(), lines[2].len());
913 }
914
915 #[test]
916 fn test_md060_cjk_characters_aligned_correctly() {
917 let rule = MD060TableFormat::new(true, "aligned".to_string());
918
919 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921
922 let fixed = rule.fix(&ctx).unwrap();
923
924 let lines: Vec<&str> = fixed.lines().collect();
925 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
926 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
927
928 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
929 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
930
931 assert_eq!(width1, width3);
932 }
933
934 #[test]
935 fn test_md060_basic_emoji() {
936 let rule = MD060TableFormat::new(true, "aligned".to_string());
937
938 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940
941 let fixed = rule.fix(&ctx).unwrap();
942 assert!(fixed.contains("Status"));
943 }
944
945 #[test]
946 fn test_md060_zwj_emoji_skipped() {
947 let rule = MD060TableFormat::new(true, "aligned".to_string());
948
949 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951
952 let fixed = rule.fix(&ctx).unwrap();
953 assert_eq!(fixed, content);
954 }
955
956 #[test]
957 fn test_md060_inline_code_with_escaped_pipes() {
958 let rule = MD060TableFormat::new(true, "aligned".to_string());
961
962 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965
966 let fixed = rule.fix(&ctx).unwrap();
967 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
968 }
969
970 #[test]
971 fn test_md060_compact_style() {
972 let rule = MD060TableFormat::new(true, "compact".to_string());
973
974 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976
977 let fixed = rule.fix(&ctx).unwrap();
978 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
979 assert_eq!(fixed, expected);
980 }
981
982 #[test]
983 fn test_md060_tight_style() {
984 let rule = MD060TableFormat::new(true, "tight".to_string());
985
986 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988
989 let fixed = rule.fix(&ctx).unwrap();
990 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
991 assert_eq!(fixed, expected);
992 }
993
994 #[test]
995 fn test_md060_aligned_no_space_style() {
996 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
998
999 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001
1002 let fixed = rule.fix(&ctx).unwrap();
1003
1004 let lines: Vec<&str> = fixed.lines().collect();
1006 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1007 assert_eq!(
1008 lines[1], "|-------|-----|",
1009 "Delimiter should have NO spaces around dashes"
1010 );
1011 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1012
1013 assert_eq!(lines[0].len(), lines[1].len());
1015 assert_eq!(lines[1].len(), lines[2].len());
1016 }
1017
1018 #[test]
1019 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1020 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1022
1023 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
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!(
1031 fixed.contains("|:"),
1032 "Should have left alignment indicator adjacent to pipe"
1033 );
1034 assert!(
1035 fixed.contains(":|"),
1036 "Should have right alignment indicator adjacent to pipe"
1037 );
1038 assert!(
1040 lines[1].contains(":---") && lines[1].contains("---:"),
1041 "Should have center alignment colons"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_md060_aligned_no_space_three_column_table() {
1047 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1049
1050 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 |";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052
1053 let fixed = rule.fix(&ctx).unwrap();
1054 let lines: Vec<&str> = fixed.lines().collect();
1055
1056 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1058 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1059 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1060 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1061 }
1062
1063 #[test]
1064 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1065 let config = MD060Config {
1067 enabled: true,
1068 style: "aligned-no-space".to_string(),
1069 max_width: LineLength::from_const(50),
1070 };
1071 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1072
1073 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076
1077 let fixed = rule.fix(&ctx).unwrap();
1078
1079 assert!(
1081 fixed.contains("| --- |"),
1082 "Should be compact format when exceeding max-width"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_md060_aligned_no_space_cjk_characters() {
1088 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1090
1091 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093
1094 let fixed = rule.fix(&ctx).unwrap();
1095 let lines: Vec<&str> = fixed.lines().collect();
1096
1097 use unicode_width::UnicodeWidthStr;
1100 assert_eq!(
1101 lines[0].width(),
1102 lines[1].width(),
1103 "Header and delimiter should have same display width"
1104 );
1105 assert_eq!(
1106 lines[1].width(),
1107 lines[2].width(),
1108 "Delimiter and content should have same display width"
1109 );
1110
1111 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1113 }
1114
1115 #[test]
1116 fn test_md060_aligned_no_space_minimum_width() {
1117 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1119
1120 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122
1123 let fixed = rule.fix(&ctx).unwrap();
1124 let lines: Vec<&str> = fixed.lines().collect();
1125
1126 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1128 assert_eq!(lines[0].len(), lines[1].len());
1130 assert_eq!(lines[1].len(), lines[2].len());
1131 }
1132
1133 #[test]
1134 fn test_md060_any_style_consistency() {
1135 let rule = MD060TableFormat::new(true, "any".to_string());
1136
1137 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1140
1141 let fixed = rule.fix(&ctx).unwrap();
1142 assert_eq!(fixed, content);
1143
1144 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1146 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1147
1148 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1149 assert_eq!(fixed_aligned, content_aligned);
1150 }
1151
1152 #[test]
1153 fn test_md060_empty_cells() {
1154 let rule = MD060TableFormat::new(true, "aligned".to_string());
1155
1156 let content = "| A | B |\n|---|---|\n| | X |";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158
1159 let fixed = rule.fix(&ctx).unwrap();
1160 assert!(fixed.contains("|"));
1161 }
1162
1163 #[test]
1164 fn test_md060_mixed_content() {
1165 let rule = MD060TableFormat::new(true, "aligned".to_string());
1166
1167 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169
1170 let fixed = rule.fix(&ctx).unwrap();
1171 assert!(fixed.contains("δΈζ"));
1172 assert!(fixed.contains("NYC"));
1173 }
1174
1175 #[test]
1176 fn test_md060_preserve_alignment_indicators() {
1177 let rule = MD060TableFormat::new(true, "aligned".to_string());
1178
1179 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181
1182 let fixed = rule.fix(&ctx).unwrap();
1183
1184 assert!(fixed.contains(":---"), "Should contain left alignment");
1185 assert!(fixed.contains(":----:"), "Should contain center alignment");
1186 assert!(fixed.contains("----:"), "Should contain right alignment");
1187 }
1188
1189 #[test]
1190 fn test_md060_minimum_column_width() {
1191 let rule = MD060TableFormat::new(true, "aligned".to_string());
1192
1193 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1197
1198 let fixed = rule.fix(&ctx).unwrap();
1199
1200 let lines: Vec<&str> = fixed.lines().collect();
1201 assert_eq!(lines[0].len(), lines[1].len());
1202 assert_eq!(lines[1].len(), lines[2].len());
1203
1204 assert!(fixed.contains("ID "), "Short content should be padded");
1206 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1207 }
1208
1209 #[test]
1210 fn test_md060_auto_compact_exceeds_default_threshold() {
1211 let config = MD060Config {
1213 enabled: true,
1214 style: "aligned".to_string(),
1215 max_width: LineLength::from_const(0),
1216 };
1217 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1218
1219 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224
1225 let fixed = rule.fix(&ctx).unwrap();
1226
1227 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1229 assert!(fixed.contains("| --- | --- | --- |"));
1230 assert!(fixed.contains("| Short | Data | Here |"));
1231
1232 let lines: Vec<&str> = fixed.lines().collect();
1234 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1236 }
1237
1238 #[test]
1239 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1240 let config = MD060Config {
1242 enabled: true,
1243 style: "aligned".to_string(),
1244 max_width: LineLength::from_const(50),
1245 };
1246 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 |";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253
1254 let fixed = rule.fix(&ctx).unwrap();
1255
1256 assert!(
1258 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1259 );
1260 assert!(fixed.contains("| --- | --- | --- |"));
1261 assert!(fixed.contains("| Data | Data | Data |"));
1262
1263 let lines: Vec<&str> = fixed.lines().collect();
1265 assert!(lines[0].len() != lines[2].len());
1266 }
1267
1268 #[test]
1269 fn test_md060_stays_aligned_under_threshold() {
1270 let config = MD060Config {
1272 enabled: true,
1273 style: "aligned".to_string(),
1274 max_width: LineLength::from_const(100),
1275 };
1276 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1277
1278 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281
1282 let fixed = rule.fix(&ctx).unwrap();
1283
1284 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1286 assert_eq!(fixed, expected);
1287
1288 let lines: Vec<&str> = fixed.lines().collect();
1289 assert_eq!(lines[0].len(), lines[1].len());
1290 assert_eq!(lines[1].len(), lines[2].len());
1291 }
1292
1293 #[test]
1294 fn test_md060_width_calculation_formula() {
1295 let config = MD060Config {
1297 enabled: true,
1298 style: "aligned".to_string(),
1299 max_width: LineLength::from_const(0),
1300 };
1301 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1302
1303 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308
1309 let fixed = rule.fix(&ctx).unwrap();
1310
1311 let lines: Vec<&str> = fixed.lines().collect();
1313 assert_eq!(lines[0].len(), lines[1].len());
1314 assert_eq!(lines[1].len(), lines[2].len());
1315 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1319 enabled: true,
1320 style: "aligned".to_string(),
1321 max_width: LineLength::from_const(24),
1322 };
1323 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1324
1325 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1326
1327 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1329 assert!(fixed_compact.contains("| --- | --- | --- |"));
1330 }
1331
1332 #[test]
1333 fn test_md060_very_wide_table_auto_compacts() {
1334 let config = MD060Config {
1335 enabled: true,
1336 style: "aligned".to_string(),
1337 max_width: LineLength::from_const(0),
1338 };
1339 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1340
1341 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 |";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346
1347 let fixed = rule.fix(&ctx).unwrap();
1348
1349 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1351 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1352 }
1353
1354 #[test]
1355 fn test_md060_inherit_from_md013_line_length() {
1356 let config = MD060Config {
1358 enabled: true,
1359 style: "aligned".to_string(),
1360 max_width: LineLength::from_const(0), };
1362
1363 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1365 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1366
1367 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370
1371 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1373
1374 let fixed_120 = rule_120.fix(&ctx).unwrap();
1376
1377 let lines_120: Vec<&str> = fixed_120.lines().collect();
1379 assert_eq!(lines_120[0].len(), lines_120[1].len());
1380 assert_eq!(lines_120[1].len(), lines_120[2].len());
1381 }
1382
1383 #[test]
1384 fn test_md060_edge_case_exactly_at_threshold() {
1385 let config = MD060Config {
1389 enabled: true,
1390 style: "aligned".to_string(),
1391 max_width: LineLength::from_const(17),
1392 };
1393 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1394
1395 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397
1398 let fixed = rule.fix(&ctx).unwrap();
1399
1400 let lines: Vec<&str> = fixed.lines().collect();
1402 assert_eq!(lines[0].len(), 17);
1403 assert_eq!(lines[0].len(), lines[1].len());
1404 assert_eq!(lines[1].len(), lines[2].len());
1405
1406 let config_under = MD060Config {
1408 enabled: true,
1409 style: "aligned".to_string(),
1410 max_width: LineLength::from_const(16),
1411 };
1412 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1413
1414 let fixed_compact = rule_under.fix(&ctx).unwrap();
1415
1416 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1418 assert!(fixed_compact.contains("| --- | --- |"));
1419 }
1420
1421 #[test]
1422 fn test_md060_auto_compact_warning_message() {
1423 let config = MD060Config {
1425 enabled: true,
1426 style: "aligned".to_string(),
1427 max_width: LineLength::from_const(50),
1428 };
1429 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1430
1431 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1434
1435 let warnings = rule.check(&ctx).unwrap();
1436
1437 assert!(!warnings.is_empty(), "Should generate warnings");
1439
1440 let auto_compact_warnings: Vec<_> = warnings
1441 .iter()
1442 .filter(|w| w.message.contains("too wide for aligned formatting"))
1443 .collect();
1444
1445 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1446
1447 let first_warning = auto_compact_warnings[0];
1449 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1450 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1451 }
1452
1453 #[test]
1454 fn test_md060_issue_129_detect_style_from_all_rows() {
1455 let rule = MD060TableFormat::new(true, "any".to_string());
1459
1460 let content = "| a long heading | another long heading |\n\
1462 | -------------- | -------------------- |\n\
1463 | a | 1 |\n\
1464 | b b | 2 |\n\
1465 | c c c | 3 |";
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467
1468 let fixed = rule.fix(&ctx).unwrap();
1469
1470 assert!(
1472 fixed.contains("| a | 1 |"),
1473 "Should preserve aligned padding in first content row"
1474 );
1475 assert!(
1476 fixed.contains("| b b | 2 |"),
1477 "Should preserve aligned padding in second content row"
1478 );
1479 assert!(
1480 fixed.contains("| c c c | 3 |"),
1481 "Should preserve aligned padding in third content row"
1482 );
1483
1484 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1486 }
1487
1488 #[test]
1489 fn test_md060_regular_alignment_warning_message() {
1490 let config = MD060Config {
1492 enabled: true,
1493 style: "aligned".to_string(),
1494 max_width: LineLength::from_const(100), };
1496 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1497
1498 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501
1502 let warnings = rule.check(&ctx).unwrap();
1503
1504 assert!(!warnings.is_empty(), "Should generate warnings");
1506
1507 assert!(warnings[0].message.contains("Table columns should be aligned"));
1509 assert!(!warnings[0].message.contains("too wide"));
1510 assert!(!warnings[0].message.contains("max-width"));
1511 }
1512
1513 #[test]
1516 fn test_md060_unlimited_when_md013_disabled() {
1517 let config = MD060Config {
1519 enabled: true,
1520 style: "aligned".to_string(),
1521 max_width: LineLength::from_const(0), };
1523 let md013_config = MD013Config::default();
1524 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1525
1526 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529 let fixed = rule.fix(&ctx).unwrap();
1530
1531 let lines: Vec<&str> = fixed.lines().collect();
1533 assert_eq!(
1535 lines[0].len(),
1536 lines[1].len(),
1537 "Table should be aligned when MD013 is disabled"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_md060_unlimited_when_md013_tables_false() {
1543 let config = MD060Config {
1545 enabled: true,
1546 style: "aligned".to_string(),
1547 max_width: LineLength::from_const(0),
1548 };
1549 let md013_config = MD013Config {
1550 tables: false, line_length: LineLength::from_const(80),
1552 ..Default::default()
1553 };
1554 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1555
1556 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let fixed = rule.fix(&ctx).unwrap();
1560
1561 let lines: Vec<&str> = fixed.lines().collect();
1563 assert_eq!(
1564 lines[0].len(),
1565 lines[1].len(),
1566 "Table should be aligned when MD013.tables=false"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_md060_unlimited_when_md013_line_length_zero() {
1572 let config = MD060Config {
1574 enabled: true,
1575 style: "aligned".to_string(),
1576 max_width: LineLength::from_const(0),
1577 };
1578 let md013_config = MD013Config {
1579 tables: true,
1580 line_length: LineLength::from_const(0), ..Default::default()
1582 };
1583 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1584
1585 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let fixed = rule.fix(&ctx).unwrap();
1589
1590 let lines: Vec<&str> = fixed.lines().collect();
1592 assert_eq!(
1593 lines[0].len(),
1594 lines[1].len(),
1595 "Table should be aligned when MD013.line_length=0"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_md060_explicit_max_width_overrides_md013_settings() {
1601 let config = MD060Config {
1603 enabled: true,
1604 style: "aligned".to_string(),
1605 max_width: LineLength::from_const(50), };
1607 let md013_config = MD013Config {
1608 tables: false, line_length: LineLength::from_const(0), ..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 due to explicit max_width"
1623 );
1624 }
1625
1626 #[test]
1627 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1628 let config = MD060Config {
1630 enabled: true,
1631 style: "aligned".to_string(),
1632 max_width: LineLength::from_const(0), };
1634 let md013_config = MD013Config {
1635 tables: true,
1636 line_length: LineLength::from_const(50), ..Default::default()
1638 };
1639 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1640
1641 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644 let fixed = rule.fix(&ctx).unwrap();
1645
1646 assert!(
1648 fixed.contains("| --- |"),
1649 "Should be compact format when inheriting MD013 limit"
1650 );
1651 }
1652
1653 #[test]
1656 fn test_aligned_no_space_reformats_spaced_delimiter() {
1657 let config = MD060Config {
1660 enabled: true,
1661 style: "aligned-no-space".to_string(),
1662 max_width: LineLength::from_const(0),
1663 };
1664 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1665
1666 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669 let fixed = rule.fix(&ctx).unwrap();
1670
1671 assert!(
1674 !fixed.contains("| ----"),
1675 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1676 );
1677 assert!(
1678 !fixed.contains("---- |"),
1679 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1680 );
1681 assert!(
1683 fixed.contains("|----"),
1684 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_aligned_reformats_compact_delimiter() {
1690 let config = MD060Config {
1693 enabled: true,
1694 style: "aligned".to_string(),
1695 max_width: LineLength::from_const(0),
1696 };
1697 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1698
1699 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let fixed = rule.fix(&ctx).unwrap();
1703
1704 assert!(
1706 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1707 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_aligned_no_space_preserves_matching_table() {
1713 let config = MD060Config {
1715 enabled: true,
1716 style: "aligned-no-space".to_string(),
1717 max_width: LineLength::from_const(0),
1718 };
1719 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1720
1721 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 let fixed = rule.fix(&ctx).unwrap();
1725
1726 assert_eq!(
1728 fixed, content,
1729 "Table already in aligned-no-space style should be preserved"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_aligned_preserves_matching_table() {
1735 let config = MD060Config {
1737 enabled: true,
1738 style: "aligned".to_string(),
1739 max_width: LineLength::from_const(0),
1740 };
1741 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1742
1743 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let fixed = rule.fix(&ctx).unwrap();
1747
1748 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
1750 }
1751
1752 #[test]
1753 fn test_cjk_table_display_width_consistency() {
1754 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
1760
1761 let is_aligned =
1763 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
1764 assert!(
1765 !is_aligned,
1766 "Table with uneven raw line lengths should NOT be considered aligned"
1767 );
1768 }
1769
1770 #[test]
1771 fn test_cjk_width_calculation_in_aligned_check() {
1772 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
1775 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
1776
1777 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
1778 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
1779
1780 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
1782 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
1783
1784 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
1786 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
1788 }
1789}