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 list_context = &table_block.list_context;
596 let (list_prefix, continuation_indent) = if let Some(ctx) = list_context {
597 (ctx.list_prefix.as_str(), " ".repeat(ctx.content_indent))
598 } else {
599 ("", String::new())
600 };
601
602 let stripped_lines: Vec<&str> = table_lines
604 .iter()
605 .enumerate()
606 .map(|(i, line)| {
607 let after_blockquote = Self::extract_blockquote_prefix(line).1;
608 if list_context.is_some() {
609 if i == 0 {
610 crate::utils::table_utils::TableUtils::extract_list_prefix(after_blockquote).1
612 } else {
613 after_blockquote
615 .strip_prefix(&continuation_indent)
616 .unwrap_or(after_blockquote.trim_start())
617 }
618 } else {
619 after_blockquote
620 }
621 })
622 .collect();
623
624 let style = self.config.style.as_str();
625
626 match style {
627 "any" => {
628 let detected_style = Self::detect_table_style(&stripped_lines, flavor);
629 if detected_style.is_none() {
630 return TableFormatResult {
631 lines: table_lines.iter().map(|s| s.to_string()).collect(),
632 auto_compacted: false,
633 aligned_width: None,
634 };
635 }
636
637 let target_style = detected_style.unwrap();
638
639 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
641 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
642
643 for line in &stripped_lines {
644 let cells = Self::parse_table_row_with_flavor(line, flavor);
645 match target_style.as_str() {
646 "tight" => result.push(Self::format_table_tight(&cells)),
647 "compact" => result.push(Self::format_table_compact(&cells)),
648 _ => {
649 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
650 let is_delimiter = Self::is_delimiter_row(&cells);
651 result.push(Self::format_table_row(
652 &cells,
653 &column_widths,
654 &column_alignments,
655 is_delimiter,
656 false,
657 ));
658 }
659 }
660 }
661 }
662 "compact" => {
663 for line in &stripped_lines {
664 let cells = Self::parse_table_row_with_flavor(line, flavor);
665 result.push(Self::format_table_compact(&cells));
666 }
667 }
668 "tight" => {
669 for line in &stripped_lines {
670 let cells = Self::parse_table_row_with_flavor(line, flavor);
671 result.push(Self::format_table_tight(&cells));
672 }
673 }
674 "aligned" | "aligned-no-space" => {
675 let compact_delimiter = style == "aligned-no-space";
676
677 if Self::is_table_already_aligned(&stripped_lines, flavor, compact_delimiter) {
680 return TableFormatResult {
681 lines: table_lines.iter().map(|s| s.to_string()).collect(),
682 auto_compacted: false,
683 aligned_width: None,
684 };
685 }
686
687 let column_widths = Self::calculate_column_widths(&stripped_lines, flavor);
688
689 let num_columns = column_widths.len();
691 let calc_aligned_width = 1 + (num_columns * 3) + column_widths.iter().sum::<usize>();
692 aligned_width = Some(calc_aligned_width);
693
694 if calc_aligned_width > self.effective_max_width() {
696 auto_compacted = true;
697 for line in &stripped_lines {
698 let cells = Self::parse_table_row_with_flavor(line, flavor);
699 result.push(Self::format_table_compact(&cells));
700 }
701 } else {
702 let delimiter_cells = Self::parse_table_row_with_flavor(stripped_lines[1], flavor);
704 let column_alignments = Self::parse_column_alignments(&delimiter_cells);
705
706 for line in &stripped_lines {
707 let cells = Self::parse_table_row_with_flavor(line, flavor);
708 let is_delimiter = Self::is_delimiter_row(&cells);
709 result.push(Self::format_table_row(
710 &cells,
711 &column_widths,
712 &column_alignments,
713 is_delimiter,
714 compact_delimiter,
715 ));
716 }
717 }
718 }
719 _ => {
720 return TableFormatResult {
721 lines: table_lines.iter().map(|s| s.to_string()).collect(),
722 auto_compacted: false,
723 aligned_width: None,
724 };
725 }
726 }
727
728 let prefixed_result: Vec<String> = result
730 .into_iter()
731 .enumerate()
732 .map(|(i, line)| {
733 if list_context.is_some() {
734 if i == 0 {
735 format!("{blockquote_prefix}{list_prefix}{line}")
737 } else {
738 format!("{blockquote_prefix}{continuation_indent}{line}")
740 }
741 } else {
742 format!("{blockquote_prefix}{line}")
743 }
744 })
745 .collect();
746
747 TableFormatResult {
748 lines: prefixed_result,
749 auto_compacted,
750 aligned_width,
751 }
752 }
753}
754
755impl Rule for MD060TableFormat {
756 fn name(&self) -> &'static str {
757 "MD060"
758 }
759
760 fn description(&self) -> &'static str {
761 "Table columns should be consistently aligned"
762 }
763
764 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
765 !self.config.enabled || !ctx.likely_has_tables()
766 }
767
768 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
769 if !self.config.enabled {
770 return Ok(Vec::new());
771 }
772
773 let content = ctx.content;
774 let line_index = &ctx.line_index;
775 let mut warnings = Vec::new();
776
777 let lines: Vec<&str> = content.lines().collect();
778 let table_blocks = &ctx.table_blocks;
779
780 for table_block in table_blocks {
781 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
782
783 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
784 .chain(std::iter::once(table_block.delimiter_line))
785 .chain(table_block.content_lines.iter().copied())
786 .collect();
787
788 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());
795 for (i, &line_idx) in table_line_indices.iter().enumerate() {
796 let fixed_line = &format_result.lines[i];
797 if line_idx < lines.len() - 1 {
799 fixed_table_lines.push(format!("{fixed_line}\n"));
800 } else {
801 fixed_table_lines.push(fixed_line.clone());
802 }
803 }
804 let table_replacement = fixed_table_lines.concat();
805 let table_range = line_index.multi_line_range(table_start_line, table_end_line);
806
807 for (i, &line_idx) in table_line_indices.iter().enumerate() {
808 let original = lines[line_idx];
809 let fixed = &format_result.lines[i];
810
811 if original != fixed {
812 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_idx + 1, original);
813
814 let message = if format_result.auto_compacted {
815 if let Some(width) = format_result.aligned_width {
816 format!(
817 "Table too wide for aligned formatting ({} chars > max-width: {})",
818 width,
819 self.effective_max_width()
820 )
821 } else {
822 "Table too wide for aligned formatting".to_string()
823 }
824 } else {
825 "Table columns should be aligned".to_string()
826 };
827
828 warnings.push(LintWarning {
831 rule_name: Some(self.name().to_string()),
832 severity: Severity::Warning,
833 message,
834 line: start_line,
835 column: start_col,
836 end_line,
837 end_column: end_col,
838 fix: Some(crate::rule::Fix {
839 range: table_range.clone(),
840 replacement: table_replacement.clone(),
841 }),
842 });
843 }
844 }
845 }
846
847 Ok(warnings)
848 }
849
850 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
851 if !self.config.enabled {
852 return Ok(ctx.content.to_string());
853 }
854
855 let content = ctx.content;
856 let lines: Vec<&str> = content.lines().collect();
857 let table_blocks = &ctx.table_blocks;
858
859 let mut result_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
860
861 for table_block in table_blocks {
862 let format_result = self.fix_table_block(&lines, table_block, ctx.flavor);
863
864 let table_line_indices: Vec<usize> = std::iter::once(table_block.header_line)
865 .chain(std::iter::once(table_block.delimiter_line))
866 .chain(table_block.content_lines.iter().copied())
867 .collect();
868
869 for (i, &line_idx) in table_line_indices.iter().enumerate() {
870 result_lines[line_idx] = format_result.lines[i].clone();
871 }
872 }
873
874 let mut fixed = result_lines.join("\n");
875 if content.ends_with('\n') && !fixed.ends_with('\n') {
876 fixed.push('\n');
877 }
878 Ok(fixed)
879 }
880
881 fn as_any(&self) -> &dyn std::any::Any {
882 self
883 }
884
885 fn default_config_section(&self) -> Option<(String, toml::Value)> {
886 let json_value = serde_json::to_value(&self.config).ok()?;
887 Some((
888 self.name().to_string(),
889 crate::rule_config_serde::json_to_toml_value(&json_value)?,
890 ))
891 }
892
893 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
894 where
895 Self: Sized,
896 {
897 let rule_config = crate::rule_config_serde::load_rule_config::<MD060Config>(config);
898 let md013_config = crate::rule_config_serde::load_rule_config::<MD013Config>(config);
899
900 let md013_disabled = config.global.disable.iter().any(|r| r == "MD013");
902
903 Box::new(Self::from_config_struct(rule_config, md013_config, md013_disabled))
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use crate::lint_context::LintContext;
911 use crate::types::LineLength;
912
913 fn md013_with_line_length(line_length: usize) -> MD013Config {
915 MD013Config {
916 line_length: LineLength::from_const(line_length),
917 tables: true, ..Default::default()
919 }
920 }
921
922 #[test]
923 fn test_md060_disabled_by_default() {
924 let rule = MD060TableFormat::default();
925 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927
928 let warnings = rule.check(&ctx).unwrap();
929 assert_eq!(warnings.len(), 0);
930
931 let fixed = rule.fix(&ctx).unwrap();
932 assert_eq!(fixed, content);
933 }
934
935 #[test]
936 fn test_md060_align_simple_ascii_table() {
937 let rule = MD060TableFormat::new(true, "aligned".to_string());
938
939 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941
942 let fixed = rule.fix(&ctx).unwrap();
943 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
944 assert_eq!(fixed, expected);
945
946 let lines: Vec<&str> = fixed.lines().collect();
948 assert_eq!(lines[0].len(), lines[1].len());
949 assert_eq!(lines[1].len(), lines[2].len());
950 }
951
952 #[test]
953 fn test_md060_cjk_characters_aligned_correctly() {
954 let rule = MD060TableFormat::new(true, "aligned".to_string());
955
956 let content = "| Name | Age |\n|---|---|\n| δΈζ | 30 |";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958
959 let fixed = rule.fix(&ctx).unwrap();
960
961 let lines: Vec<&str> = fixed.lines().collect();
962 let cells_line1 = MD060TableFormat::parse_table_row(lines[0]);
963 let cells_line3 = MD060TableFormat::parse_table_row(lines[2]);
964
965 let width1 = MD060TableFormat::calculate_cell_display_width(&cells_line1[0]);
966 let width3 = MD060TableFormat::calculate_cell_display_width(&cells_line3[0]);
967
968 assert_eq!(width1, width3);
969 }
970
971 #[test]
972 fn test_md060_basic_emoji() {
973 let rule = MD060TableFormat::new(true, "aligned".to_string());
974
975 let content = "| Status | Name |\n|---|---|\n| β
| Test |";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977
978 let fixed = rule.fix(&ctx).unwrap();
979 assert!(fixed.contains("Status"));
980 }
981
982 #[test]
983 fn test_md060_zwj_emoji_skipped() {
984 let rule = MD060TableFormat::new(true, "aligned".to_string());
985
986 let content = "| Emoji | Name |\n|---|---|\n| π¨βπ©βπ§βπ¦ | Family |";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988
989 let fixed = rule.fix(&ctx).unwrap();
990 assert_eq!(fixed, content);
991 }
992
993 #[test]
994 fn test_md060_inline_code_with_escaped_pipes() {
995 let rule = MD060TableFormat::new(true, "aligned".to_string());
998
999 let content = "| Pattern | Regex |\n|---|---|\n| Time | `[0-9]\\|[0-9]` |";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002
1003 let fixed = rule.fix(&ctx).unwrap();
1004 assert!(fixed.contains(r"`[0-9]\|[0-9]`"), "Escaped pipes should be preserved");
1005 }
1006
1007 #[test]
1008 fn test_md060_compact_style() {
1009 let rule = MD060TableFormat::new(true, "compact".to_string());
1010
1011 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013
1014 let fixed = rule.fix(&ctx).unwrap();
1015 let expected = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1016 assert_eq!(fixed, expected);
1017 }
1018
1019 #[test]
1020 fn test_md060_tight_style() {
1021 let rule = MD060TableFormat::new(true, "tight".to_string());
1022
1023 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025
1026 let fixed = rule.fix(&ctx).unwrap();
1027 let expected = "|Name|Age|\n|---|---|\n|Alice|30|";
1028 assert_eq!(fixed, expected);
1029 }
1030
1031 #[test]
1032 fn test_md060_aligned_no_space_style() {
1033 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1035
1036 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038
1039 let fixed = rule.fix(&ctx).unwrap();
1040
1041 let lines: Vec<&str> = fixed.lines().collect();
1043 assert_eq!(lines[0], "| Name | Age |", "Header should have spaces around content");
1044 assert_eq!(
1045 lines[1], "|-------|-----|",
1046 "Delimiter should have NO spaces around dashes"
1047 );
1048 assert_eq!(lines[2], "| Alice | 30 |", "Content should have spaces around content");
1049
1050 assert_eq!(lines[0].len(), lines[1].len());
1052 assert_eq!(lines[1].len(), lines[2].len());
1053 }
1054
1055 #[test]
1056 fn test_md060_aligned_no_space_preserves_alignment_indicators() {
1057 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1059
1060 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062
1063 let fixed = rule.fix(&ctx).unwrap();
1064 let lines: Vec<&str> = fixed.lines().collect();
1065
1066 assert!(
1068 fixed.contains("|:"),
1069 "Should have left alignment indicator adjacent to pipe"
1070 );
1071 assert!(
1072 fixed.contains(":|"),
1073 "Should have right alignment indicator adjacent to pipe"
1074 );
1075 assert!(
1077 lines[1].contains(":---") && lines[1].contains("---:"),
1078 "Should have center alignment colons"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_md060_aligned_no_space_three_column_table() {
1084 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1086
1087 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 |";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089
1090 let fixed = rule.fix(&ctx).unwrap();
1091 let lines: Vec<&str> = fixed.lines().collect();
1092
1093 assert!(lines[1].starts_with("|---"), "Delimiter should start with |---");
1095 assert!(lines[1].ends_with("---|"), "Delimiter should end with ---|");
1096 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1097 assert!(!lines[1].contains("- |"), "Delimiter should NOT have space before pipe");
1098 }
1099
1100 #[test]
1101 fn test_md060_aligned_no_space_auto_compacts_wide_tables() {
1102 let config = MD060Config {
1104 enabled: true,
1105 style: "aligned-no-space".to_string(),
1106 max_width: LineLength::from_const(50),
1107 };
1108 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1109
1110 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113
1114 let fixed = rule.fix(&ctx).unwrap();
1115
1116 assert!(
1118 fixed.contains("| --- |"),
1119 "Should be compact format when exceeding max-width"
1120 );
1121 }
1122
1123 #[test]
1124 fn test_md060_aligned_no_space_cjk_characters() {
1125 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1127
1128 let content = "| Name | City |\n|---|---|\n| δΈζ | ζ±δΊ¬ |";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130
1131 let fixed = rule.fix(&ctx).unwrap();
1132 let lines: Vec<&str> = fixed.lines().collect();
1133
1134 use unicode_width::UnicodeWidthStr;
1137 assert_eq!(
1138 lines[0].width(),
1139 lines[1].width(),
1140 "Header and delimiter should have same display width"
1141 );
1142 assert_eq!(
1143 lines[1].width(),
1144 lines[2].width(),
1145 "Delimiter and content should have same display width"
1146 );
1147
1148 assert!(!lines[1].contains("| -"), "Delimiter should NOT have space after pipe");
1150 }
1151
1152 #[test]
1153 fn test_md060_aligned_no_space_minimum_width() {
1154 let rule = MD060TableFormat::new(true, "aligned-no-space".to_string());
1156
1157 let content = "| A | B |\n|-|-|\n| 1 | 2 |";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159
1160 let fixed = rule.fix(&ctx).unwrap();
1161 let lines: Vec<&str> = fixed.lines().collect();
1162
1163 assert!(lines[1].contains("---"), "Should have minimum 3 dashes");
1165 assert_eq!(lines[0].len(), lines[1].len());
1167 assert_eq!(lines[1].len(), lines[2].len());
1168 }
1169
1170 #[test]
1171 fn test_md060_any_style_consistency() {
1172 let rule = MD060TableFormat::new(true, "any".to_string());
1173
1174 let content = "| Name | Age |\n| --- | --- |\n| Alice | 30 |";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177
1178 let fixed = rule.fix(&ctx).unwrap();
1179 assert_eq!(fixed, content);
1180
1181 let content_aligned = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1183 let ctx_aligned = LintContext::new(content_aligned, crate::config::MarkdownFlavor::Standard, None);
1184
1185 let fixed_aligned = rule.fix(&ctx_aligned).unwrap();
1186 assert_eq!(fixed_aligned, content_aligned);
1187 }
1188
1189 #[test]
1190 fn test_md060_empty_cells() {
1191 let rule = MD060TableFormat::new(true, "aligned".to_string());
1192
1193 let content = "| A | B |\n|---|---|\n| | X |";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195
1196 let fixed = rule.fix(&ctx).unwrap();
1197 assert!(fixed.contains("|"));
1198 }
1199
1200 #[test]
1201 fn test_md060_mixed_content() {
1202 let rule = MD060TableFormat::new(true, "aligned".to_string());
1203
1204 let content = "| Name | Age | City |\n|---|---|---|\n| δΈζ | 30 | NYC |";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206
1207 let fixed = rule.fix(&ctx).unwrap();
1208 assert!(fixed.contains("δΈζ"));
1209 assert!(fixed.contains("NYC"));
1210 }
1211
1212 #[test]
1213 fn test_md060_preserve_alignment_indicators() {
1214 let rule = MD060TableFormat::new(true, "aligned".to_string());
1215
1216 let content = "| Left | Center | Right |\n|:---|:---:|---:|\n| A | B | C |";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218
1219 let fixed = rule.fix(&ctx).unwrap();
1220
1221 assert!(fixed.contains(":---"), "Should contain left alignment");
1222 assert!(fixed.contains(":----:"), "Should contain center alignment");
1223 assert!(fixed.contains("----:"), "Should contain right alignment");
1224 }
1225
1226 #[test]
1227 fn test_md060_minimum_column_width() {
1228 let rule = MD060TableFormat::new(true, "aligned".to_string());
1229
1230 let content = "| ID | Name |\n|-|-|\n| 1 | A |";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234
1235 let fixed = rule.fix(&ctx).unwrap();
1236
1237 let lines: Vec<&str> = fixed.lines().collect();
1238 assert_eq!(lines[0].len(), lines[1].len());
1239 assert_eq!(lines[1].len(), lines[2].len());
1240
1241 assert!(fixed.contains("ID "), "Short content should be padded");
1243 assert!(fixed.contains("---"), "Delimiter should have at least 3 dashes");
1244 }
1245
1246 #[test]
1247 fn test_md060_auto_compact_exceeds_default_threshold() {
1248 let config = MD060Config {
1250 enabled: true,
1251 style: "aligned".to_string(),
1252 max_width: LineLength::from_const(0),
1253 };
1254 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1255
1256 let content = "| Very Long Column Header | Another Long Header | Third Very Long Header Column |\n|---|---|---|\n| Short | Data | Here |";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261
1262 let fixed = rule.fix(&ctx).unwrap();
1263
1264 assert!(fixed.contains("| Very Long Column Header | Another Long Header | Third Very Long Header Column |"));
1266 assert!(fixed.contains("| --- | --- | --- |"));
1267 assert!(fixed.contains("| Short | Data | Here |"));
1268
1269 let lines: Vec<&str> = fixed.lines().collect();
1271 assert!(lines[0].len() != lines[1].len() || lines[1].len() != lines[2].len());
1273 }
1274
1275 #[test]
1276 fn test_md060_auto_compact_exceeds_explicit_threshold() {
1277 let config = MD060Config {
1279 enabled: true,
1280 style: "aligned".to_string(),
1281 max_width: LineLength::from_const(50),
1282 };
1283 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 |";
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290
1291 let fixed = rule.fix(&ctx).unwrap();
1292
1293 assert!(
1295 fixed.contains("| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |")
1296 );
1297 assert!(fixed.contains("| --- | --- | --- |"));
1298 assert!(fixed.contains("| Data | Data | Data |"));
1299
1300 let lines: Vec<&str> = fixed.lines().collect();
1302 assert!(lines[0].len() != lines[2].len());
1303 }
1304
1305 #[test]
1306 fn test_md060_stays_aligned_under_threshold() {
1307 let config = MD060Config {
1309 enabled: true,
1310 style: "aligned".to_string(),
1311 max_width: LineLength::from_const(100),
1312 };
1313 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1314
1315 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318
1319 let fixed = rule.fix(&ctx).unwrap();
1320
1321 let expected = "| Name | Age |\n| ----- | --- |\n| Alice | 30 |";
1323 assert_eq!(fixed, expected);
1324
1325 let lines: Vec<&str> = fixed.lines().collect();
1326 assert_eq!(lines[0].len(), lines[1].len());
1327 assert_eq!(lines[1].len(), lines[2].len());
1328 }
1329
1330 #[test]
1331 fn test_md060_width_calculation_formula() {
1332 let config = MD060Config {
1334 enabled: true,
1335 style: "aligned".to_string(),
1336 max_width: LineLength::from_const(0),
1337 };
1338 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(30), false);
1339
1340 let content = "| AAAAA | BBBBB | CCCCC |\n|---|---|---|\n| AAAAA | BBBBB | CCCCC |";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345
1346 let fixed = rule.fix(&ctx).unwrap();
1347
1348 let lines: Vec<&str> = fixed.lines().collect();
1350 assert_eq!(lines[0].len(), lines[1].len());
1351 assert_eq!(lines[1].len(), lines[2].len());
1352 assert_eq!(lines[0].len(), 25); let config_tight = MD060Config {
1356 enabled: true,
1357 style: "aligned".to_string(),
1358 max_width: LineLength::from_const(24),
1359 };
1360 let rule_tight = MD060TableFormat::from_config_struct(config_tight, md013_with_line_length(80), false);
1361
1362 let fixed_compact = rule_tight.fix(&ctx).unwrap();
1363
1364 assert!(fixed_compact.contains("| AAAAA | BBBBB | CCCCC |"));
1366 assert!(fixed_compact.contains("| --- | --- | --- |"));
1367 }
1368
1369 #[test]
1370 fn test_md060_very_wide_table_auto_compacts() {
1371 let config = MD060Config {
1372 enabled: true,
1373 style: "aligned".to_string(),
1374 max_width: LineLength::from_const(0),
1375 };
1376 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1377
1378 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 |";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383
1384 let fixed = rule.fix(&ctx).unwrap();
1385
1386 assert!(fixed.contains("| Column One A | Column Two B | Column Three | Column Four D | Column Five E | Column Six FG | Column Seven | Column Eight |"));
1388 assert!(fixed.contains("| --- | --- | --- | --- | --- | --- | --- | --- |"));
1389 }
1390
1391 #[test]
1392 fn test_md060_inherit_from_md013_line_length() {
1393 let config = MD060Config {
1395 enabled: true,
1396 style: "aligned".to_string(),
1397 max_width: LineLength::from_const(0), };
1399
1400 let rule_80 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(80), false);
1402 let rule_120 = MD060TableFormat::from_config_struct(config.clone(), md013_with_line_length(120), false);
1403
1404 let content = "| Column Header A | Column Header B | Column Header C |\n|---|---|---|\n| Some Data | More Data | Even More |";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407
1408 let _fixed_80 = rule_80.fix(&ctx).unwrap();
1410
1411 let fixed_120 = rule_120.fix(&ctx).unwrap();
1413
1414 let lines_120: Vec<&str> = fixed_120.lines().collect();
1416 assert_eq!(lines_120[0].len(), lines_120[1].len());
1417 assert_eq!(lines_120[1].len(), lines_120[2].len());
1418 }
1419
1420 #[test]
1421 fn test_md060_edge_case_exactly_at_threshold() {
1422 let config = MD060Config {
1426 enabled: true,
1427 style: "aligned".to_string(),
1428 max_width: LineLength::from_const(17),
1429 };
1430 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1431
1432 let content = "| AAAAA | BBBBB |\n|---|---|\n| AAAAA | BBBBB |";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1434
1435 let fixed = rule.fix(&ctx).unwrap();
1436
1437 let lines: Vec<&str> = fixed.lines().collect();
1439 assert_eq!(lines[0].len(), 17);
1440 assert_eq!(lines[0].len(), lines[1].len());
1441 assert_eq!(lines[1].len(), lines[2].len());
1442
1443 let config_under = MD060Config {
1445 enabled: true,
1446 style: "aligned".to_string(),
1447 max_width: LineLength::from_const(16),
1448 };
1449 let rule_under = MD060TableFormat::from_config_struct(config_under, md013_with_line_length(80), false);
1450
1451 let fixed_compact = rule_under.fix(&ctx).unwrap();
1452
1453 assert!(fixed_compact.contains("| AAAAA | BBBBB |"));
1455 assert!(fixed_compact.contains("| --- | --- |"));
1456 }
1457
1458 #[test]
1459 fn test_md060_auto_compact_warning_message() {
1460 let config = MD060Config {
1462 enabled: true,
1463 style: "aligned".to_string(),
1464 max_width: LineLength::from_const(50),
1465 };
1466 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1467
1468 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| Data | Data | Data |";
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1471
1472 let warnings = rule.check(&ctx).unwrap();
1473
1474 assert!(!warnings.is_empty(), "Should generate warnings");
1476
1477 let auto_compact_warnings: Vec<_> = warnings
1478 .iter()
1479 .filter(|w| w.message.contains("too wide for aligned formatting"))
1480 .collect();
1481
1482 assert!(!auto_compact_warnings.is_empty(), "Should have auto-compact warning");
1483
1484 let first_warning = auto_compact_warnings[0];
1486 assert!(first_warning.message.contains("85 chars > max-width: 50"));
1487 assert!(first_warning.message.contains("Table too wide for aligned formatting"));
1488 }
1489
1490 #[test]
1491 fn test_md060_issue_129_detect_style_from_all_rows() {
1492 let rule = MD060TableFormat::new(true, "any".to_string());
1496
1497 let content = "| a long heading | another long heading |\n\
1499 | -------------- | -------------------- |\n\
1500 | a | 1 |\n\
1501 | b b | 2 |\n\
1502 | c c c | 3 |";
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504
1505 let fixed = rule.fix(&ctx).unwrap();
1506
1507 assert!(
1509 fixed.contains("| a | 1 |"),
1510 "Should preserve aligned padding in first content row"
1511 );
1512 assert!(
1513 fixed.contains("| b b | 2 |"),
1514 "Should preserve aligned padding in second content row"
1515 );
1516 assert!(
1517 fixed.contains("| c c c | 3 |"),
1518 "Should preserve aligned padding in third content row"
1519 );
1520
1521 assert_eq!(fixed, content, "Table should be detected as aligned and preserved");
1523 }
1524
1525 #[test]
1526 fn test_md060_regular_alignment_warning_message() {
1527 let config = MD060Config {
1529 enabled: true,
1530 style: "aligned".to_string(),
1531 max_width: LineLength::from_const(100), };
1533 let rule = MD060TableFormat::from_config_struct(config, md013_with_line_length(80), false);
1534
1535 let content = "| Name | Age |\n|---|---|\n| Alice | 30 |";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538
1539 let warnings = rule.check(&ctx).unwrap();
1540
1541 assert!(!warnings.is_empty(), "Should generate warnings");
1543
1544 assert!(warnings[0].message.contains("Table columns should be aligned"));
1546 assert!(!warnings[0].message.contains("too wide"));
1547 assert!(!warnings[0].message.contains("max-width"));
1548 }
1549
1550 #[test]
1553 fn test_md060_unlimited_when_md013_disabled() {
1554 let config = MD060Config {
1556 enabled: true,
1557 style: "aligned".to_string(),
1558 max_width: LineLength::from_const(0), };
1560 let md013_config = MD013Config::default();
1561 let rule = MD060TableFormat::from_config_struct(config, md013_config, true );
1562
1563 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| data | data | data |";
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1566 let fixed = rule.fix(&ctx).unwrap();
1567
1568 let lines: Vec<&str> = fixed.lines().collect();
1570 assert_eq!(
1572 lines[0].len(),
1573 lines[1].len(),
1574 "Table should be aligned when MD013 is disabled"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_md060_unlimited_when_md013_tables_false() {
1580 let config = MD060Config {
1582 enabled: true,
1583 style: "aligned".to_string(),
1584 max_width: LineLength::from_const(0),
1585 };
1586 let md013_config = MD013Config {
1587 tables: false, line_length: LineLength::from_const(80),
1589 ..Default::default()
1590 };
1591 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1592
1593 let content = "| Very Long Header A | Very Long Header B | Very Long Header C |\n|---|---|---|\n| x | y | z |";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let fixed = rule.fix(&ctx).unwrap();
1597
1598 let lines: Vec<&str> = fixed.lines().collect();
1600 assert_eq!(
1601 lines[0].len(),
1602 lines[1].len(),
1603 "Table should be aligned when MD013.tables=false"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_md060_unlimited_when_md013_line_length_zero() {
1609 let config = MD060Config {
1611 enabled: true,
1612 style: "aligned".to_string(),
1613 max_width: LineLength::from_const(0),
1614 };
1615 let md013_config = MD013Config {
1616 tables: true,
1617 line_length: LineLength::from_const(0), ..Default::default()
1619 };
1620 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1621
1622 let content = "| Very Long Header | Another Long Header | Third Long Header |\n|---|---|---|\n| x | y | z |";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625 let fixed = rule.fix(&ctx).unwrap();
1626
1627 let lines: Vec<&str> = fixed.lines().collect();
1629 assert_eq!(
1630 lines[0].len(),
1631 lines[1].len(),
1632 "Table should be aligned when MD013.line_length=0"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_md060_explicit_max_width_overrides_md013_settings() {
1638 let config = MD060Config {
1640 enabled: true,
1641 style: "aligned".to_string(),
1642 max_width: LineLength::from_const(50), };
1644 let md013_config = MD013Config {
1645 tables: false, 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 Column Header A | Very Long Column Header B | Very Long Column Header C |\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 assert!(
1658 fixed.contains("| --- |"),
1659 "Should be compact format due to explicit max_width"
1660 );
1661 }
1662
1663 #[test]
1664 fn test_md060_inherits_md013_line_length_when_tables_enabled() {
1665 let config = MD060Config {
1667 enabled: true,
1668 style: "aligned".to_string(),
1669 max_width: LineLength::from_const(0), };
1671 let md013_config = MD013Config {
1672 tables: true,
1673 line_length: LineLength::from_const(50), ..Default::default()
1675 };
1676 let rule = MD060TableFormat::from_config_struct(config, md013_config, false);
1677
1678 let content = "| Very Long Column Header A | Very Long Column Header B | Very Long Column Header C |\n|---|---|---|\n| x | y | z |";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let fixed = rule.fix(&ctx).unwrap();
1682
1683 assert!(
1685 fixed.contains("| --- |"),
1686 "Should be compact format when inheriting MD013 limit"
1687 );
1688 }
1689
1690 #[test]
1693 fn test_aligned_no_space_reformats_spaced_delimiter() {
1694 let config = MD060Config {
1697 enabled: true,
1698 style: "aligned-no-space".to_string(),
1699 max_width: LineLength::from_const(0),
1700 };
1701 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1702
1703 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1706 let fixed = rule.fix(&ctx).unwrap();
1707
1708 assert!(
1711 !fixed.contains("| ----"),
1712 "Delimiter should NOT have spaces after pipe. Got:\n{fixed}"
1713 );
1714 assert!(
1715 !fixed.contains("---- |"),
1716 "Delimiter should NOT have spaces before pipe. Got:\n{fixed}"
1717 );
1718 assert!(
1720 fixed.contains("|----"),
1721 "Delimiter should have dashes touching the leading pipe. Got:\n{fixed}"
1722 );
1723 }
1724
1725 #[test]
1726 fn test_aligned_reformats_compact_delimiter() {
1727 let config = MD060Config {
1730 enabled: true,
1731 style: "aligned".to_string(),
1732 max_width: LineLength::from_const(0),
1733 };
1734 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1735
1736 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let fixed = rule.fix(&ctx).unwrap();
1740
1741 assert!(
1743 fixed.contains("| -------- | -------- |") || fixed.contains("| ---------- | ---------- |"),
1744 "Delimiter should have spaces around dashes. Got:\n{fixed}"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_aligned_no_space_preserves_matching_table() {
1750 let config = MD060Config {
1752 enabled: true,
1753 style: "aligned-no-space".to_string(),
1754 max_width: LineLength::from_const(0),
1755 };
1756 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1757
1758 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761 let fixed = rule.fix(&ctx).unwrap();
1762
1763 assert_eq!(
1765 fixed, content,
1766 "Table already in aligned-no-space style should be preserved"
1767 );
1768 }
1769
1770 #[test]
1771 fn test_aligned_preserves_matching_table() {
1772 let config = MD060Config {
1774 enabled: true,
1775 style: "aligned".to_string(),
1776 max_width: LineLength::from_const(0),
1777 };
1778 let rule = MD060TableFormat::from_config_struct(config, MD013Config::default(), false);
1779
1780 let content = "| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let fixed = rule.fix(&ctx).unwrap();
1784
1785 assert_eq!(fixed, content, "Table already in aligned style should be preserved");
1787 }
1788
1789 #[test]
1790 fn test_cjk_table_display_width_consistency() {
1791 let table_lines = vec!["| εε | Age |", "|------|-----|", "| η°δΈ | 25 |"];
1797
1798 let is_aligned =
1800 MD060TableFormat::is_table_already_aligned(&table_lines, crate::config::MarkdownFlavor::Standard, false);
1801 assert!(
1802 !is_aligned,
1803 "Table with uneven raw line lengths should NOT be considered aligned"
1804 );
1805 }
1806
1807 #[test]
1808 fn test_cjk_width_calculation_in_aligned_check() {
1809 let cjk_width = MD060TableFormat::calculate_cell_display_width("εε");
1812 assert_eq!(cjk_width, 4, "Two CJK characters should have display width 4");
1813
1814 let ascii_width = MD060TableFormat::calculate_cell_display_width("Age");
1815 assert_eq!(ascii_width, 3, "Three ASCII characters should have display width 3");
1816
1817 let padded_cjk = MD060TableFormat::calculate_cell_display_width(" εε ");
1819 assert_eq!(padded_cjk, 4, "Padded CJK should have same width after trim");
1820
1821 let mixed = MD060TableFormat::calculate_cell_display_width(" ζ₯ζ¬θͺABC ");
1823 assert_eq!(mixed, 9, "Mixed CJK/ASCII content");
1825 }
1826}