markdown-table-fixer 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/PKG-INFO +3 -1
  2. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/pyproject.toml +2 -0
  3. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/_version.py +2 -2
  4. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/cli.py +25 -30
  5. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/models.py +10 -2
  6. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/table_fixer.py +275 -19
  7. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/table_parser.py +45 -2
  8. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/table_validator.py +17 -4
  9. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/.gitignore +0 -0
  10. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/.pre-commit-hooks.yaml +0 -0
  11. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/LICENSE +0 -0
  12. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/LICENSES/Apache-2.0.txt +0 -0
  13. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/README.md +0 -0
  14. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/scripts/README.md +0 -0
  15. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/__init__.py +0 -0
  16. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/exceptions.py +0 -0
  17. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/git_config.py +0 -0
  18. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/github_client.py +0 -0
  19. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/graphql_queries.py +0 -0
  20. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/pr_fixer.py +0 -0
  21. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/pr_scanner.py +0 -0
  22. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/progress_tracker.py +0 -0
  23. {markdown_table_fixer-0.1.2 → markdown_table_fixer-0.1.3}/src/markdown_table_fixer/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-table-fixer
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Markdown table formatter and linter with GitHub integration
5
5
  Project-URL: Homepage, https://github.com/lfit/markdown-table-fixer
6
6
  Project-URL: Repository, https://github.com/lfit/markdown-table-fixer.git
@@ -25,6 +25,7 @@ Requires-Python: <3.14,>=3.10
25
25
  Requires-Dist: aiolimiter>=1.2.0
26
26
  Requires-Dist: httpx[http2]>=0.28.0
27
27
  Requires-Dist: pydantic>=2.10.3
28
+ Requires-Dist: pyyaml>=6.0.0
28
29
  Requires-Dist: rich>=13.9.4
29
30
  Requires-Dist: tenacity>=9.0.0
30
31
  Requires-Dist: typer>=0.15.0
@@ -37,6 +38,7 @@ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
37
38
  Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
38
39
  Requires-Dist: pytest>=8.3.4; extra == 'dev'
39
40
  Requires-Dist: ruff>=0.8.4; extra == 'dev'
41
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
40
42
  Description-Content-Type: text/markdown
41
43
 
42
44
  <!--
@@ -38,6 +38,7 @@ dependencies = [
38
38
  "aiolimiter>=1.2.0",
39
39
  "tenacity>=9.0.0",
40
40
  "wcwidth>=0.2.13",
41
+ "pyyaml>=6.0.0",
41
42
  ]
42
43
 
43
44
  [project.optional-dependencies]
@@ -49,6 +50,7 @@ dev = [
49
50
  "mypy>=1.13.0",
50
51
  "ruff>=0.8.4",
51
52
  "pre-commit>=4.0.1",
53
+ "types-PyYAML>=6.0.0",
52
54
  ]
53
55
 
54
56
  [project.urls]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.2'
32
- __version_tuple__ = version_tuple = (0, 1, 2)
31
+ __version__ = version = '0.1.3'
32
+ __version_tuple__ = version_tuple = (0, 1, 3)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -27,7 +27,6 @@ from .models import (
27
27
  OutputFormat,
28
28
  PRInfo,
29
29
  ScanResult,
30
- TableViolation,
31
30
  )
32
31
  from .pr_fixer import PRFixer
33
32
  from .pr_scanner import PRScanner
@@ -172,7 +171,6 @@ def lint(
172
171
  console.print(f"🔍 Scanning: {scan_path}")
173
172
  if should_fix:
174
173
  console.print("🔧 Auto-fix enabled")
175
- console.print()
176
174
 
177
175
  try:
178
176
  scanner = MarkdownFileScanner(scan_path)
@@ -206,6 +204,9 @@ def lint(
206
204
  if (check or fail_on_error) and scan_result.files_with_issues > 0:
207
205
  raise typer.Exit(1)
208
206
 
207
+ except typer.Exit:
208
+ # Re-raise typer.Exit without catching it as a general exception
209
+ raise
209
210
  except Exception as e:
210
211
  console.print(f"[red]Error:[/red] {e}")
211
212
  raise typer.Exit(1) from e
@@ -736,36 +737,30 @@ def _output_text_results(result: ScanResult, quiet: bool) -> None:
736
737
  console.print(f" ... and {len(result.errors) - 5} more errors")
737
738
  console.print()
738
739
 
739
- # Show sample violations
740
- if result.files_with_issues > 0:
740
+ # Show all files with violations or errors
741
+ if result.files_with_issues > 0 or result.errors:
741
742
  console.print("[bold yellow]Files with issues:[/bold yellow]")
742
- for file_result in result.file_results[:3]:
743
- if file_result.has_violations:
744
- console.print(f"{file_result.file_path}")
745
-
746
- # Group violations by table
747
- violations_by_table: dict[int, list[TableViolation]] = {}
748
- for violation in file_result.violations:
749
- table_line = violation.table_start_line
750
- if table_line not in violations_by_table:
751
- violations_by_table[table_line] = []
752
- violations_by_table[table_line].append(violation)
753
-
754
- # Show summary per table
755
- for table_line in sorted(violations_by_table.keys()):
756
- violations = violations_by_table[table_line]
757
- # Count unique rows with violations
758
- unique_rows = len({v.line_number for v in violations})
759
- console.print(
760
- f" Markdown table at line {table_line} has {unique_rows} "
761
- f"row(s) with formatting issues"
762
- )
763
- console.print()
764
743
 
765
- if result.files_with_issues > 3:
766
- console.print(
767
- f"... and {result.files_with_issues - 3} more files with issues"
768
- )
744
+ # Collect files with issues (violations or errors)
745
+ files_to_show = []
746
+ for file_result in result.file_results:
747
+ if file_result.has_violations or file_result.error:
748
+ files_to_show.append(file_result)
749
+
750
+ # Display each file with its issue count
751
+ for file_result in files_to_show:
752
+ if file_result.has_violations:
753
+ # Count total violations for this file
754
+ violation_count = len(file_result.violations)
755
+ error_word = "Error" if violation_count == 1 else "Errors"
756
+ console.print(
757
+ f" {file_result.file_path} [{violation_count} {error_word}]"
758
+ )
759
+ elif file_result.error:
760
+ # File has a parsing or processing error
761
+ console.print(
762
+ f" {file_result.file_path} [Parse Error: {file_result.error}]"
763
+ )
769
764
 
770
765
 
771
766
  if __name__ == "__main__":
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from dataclasses import dataclass, field
9
9
  from enum import Enum
10
+ import html
10
11
  from pathlib import Path
11
12
  from typing import Any
12
13
 
@@ -47,12 +48,19 @@ class TableCell:
47
48
 
48
49
  Uses wcwidth to properly calculate display width for Unicode characters,
49
50
  including emojis which display as 2 characters wide but count as 1 in len().
51
+ Also handles HTML entities (e.g., &#124; for |, &#x1F600; for 😀).
50
52
  """
51
53
  stripped = self.content.strip()
52
- width = wcwidth.wcswidth(stripped)
54
+
55
+ # Decode HTML entities to their actual characters
56
+ # This properly handles both named entities (&lt;) and numeric entities (&#124;, &#x1F600;)
57
+ # so that wide characters like emojis get their correct display width
58
+ decoded = html.unescape(stripped)
59
+
60
+ width = wcwidth.wcswidth(decoded)
53
61
  # wcswidth returns -1 if string contains non-printable characters
54
62
  # Fall back to len() in that case
55
- return width if width >= 0 else len(stripped)
63
+ return width if width >= 0 else len(decoded)
56
64
 
57
65
 
58
66
  @dataclass
@@ -5,8 +5,12 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import json
9
+ import re
8
10
  from typing import TYPE_CHECKING
9
11
 
12
+ import yaml
13
+
10
14
  if TYPE_CHECKING:
11
15
  from pathlib import Path
12
16
 
@@ -86,8 +90,11 @@ class TableFixer:
86
90
  def _calculate_column_widths(self) -> list[int]:
87
91
  """Calculate the maximum width needed for each column.
88
92
 
93
+ For MD060 compliance, we need to align pipes by character position.
94
+ This uses string length, not display width or byte length.
95
+
89
96
  Returns:
90
- List of column widths
97
+ List of column widths (in characters)
91
98
  """
92
99
  if not self.table.rows:
93
100
  return []
@@ -105,6 +112,7 @@ class TableFixer:
105
112
  if row.is_separator:
106
113
  content_width = 3 # Minimum "---"
107
114
  else:
115
+ # Use character length for MD060 compliance (pipe alignment)
108
116
  content_width = len(cell.content.strip())
109
117
  max_width = max(max_width, content_width)
110
118
  widths.append(max_width)
@@ -116,7 +124,7 @@ class TableFixer:
116
124
 
117
125
  Args:
118
126
  row: The row to format
119
- column_widths: Width of each column
127
+ column_widths: Width of each column (in characters)
120
128
 
121
129
  Returns:
122
130
  Formatted row string
@@ -129,7 +137,8 @@ class TableFixer:
129
137
  column_widths[idx] if idx < len(column_widths) else len(content)
130
138
  )
131
139
 
132
- # Pad content to column width
140
+ # Pad content to column width based on character length
141
+ # This ensures pipes align by character position (MD060 compliance)
133
142
  padded = content.ljust(width)
134
143
  parts.append(f" {padded} ")
135
144
 
@@ -142,7 +151,7 @@ class TableFixer:
142
151
 
143
152
  Args:
144
153
  row: The separator row to format
145
- column_widths: Width of each column
154
+ column_widths: Width of each column (in characters)
146
155
 
147
156
  Returns:
148
157
  Formatted separator row string
@@ -188,6 +197,202 @@ class FileFixer:
188
197
  """
189
198
  self.file_path = file_path
190
199
  self.max_line_length = max_line_length
200
+ self._md013_enabled = self._check_md013_enabled()
201
+ self._md060_enabled = self._check_md060_enabled()
202
+
203
+ def _remove_jsonc_comments(self, content: str) -> str:
204
+ """Remove comments from JSONC content.
205
+
206
+ This is a simple implementation that removes // comments while being
207
+ aware of strings. It's not perfect but handles most common cases.
208
+
209
+ Args:
210
+ content: JSONC content string
211
+
212
+ Returns:
213
+ Content with comments removed
214
+ """
215
+ lines = []
216
+ for line in content.split("\n"):
217
+ # Track if we're inside a string
218
+ in_string = False
219
+ escape_next = False
220
+ processed = []
221
+
222
+ i = 0
223
+ while i < len(line):
224
+ char = line[i]
225
+
226
+ if escape_next:
227
+ processed.append(char)
228
+ escape_next = False
229
+ i += 1
230
+ continue
231
+
232
+ if char == "\\":
233
+ processed.append(char)
234
+ escape_next = True
235
+ i += 1
236
+ continue
237
+
238
+ if char == '"':
239
+ in_string = not in_string
240
+ processed.append(char)
241
+ i += 1
242
+ continue
243
+
244
+ # Check for comment start (only outside strings)
245
+ if (
246
+ not in_string
247
+ and i < len(line) - 1
248
+ and line[i : i + 2] == "//"
249
+ ):
250
+ # Rest of line is a comment
251
+ break
252
+
253
+ processed.append(char)
254
+ i += 1
255
+
256
+ lines.append("".join(processed))
257
+
258
+ return "\n".join(lines)
259
+
260
+ def _check_rule_enabled(self, rule_name: str) -> bool:
261
+ """Check if a markdownlint rule is enabled in config.
262
+
263
+ Args:
264
+ rule_name: Name of the rule (e.g., "MD013", "MD060")
265
+
266
+ Returns:
267
+ True if rule checking is enabled, False otherwise
268
+ """
269
+ # Look for markdownlint config files in parent directories
270
+ current_dir = self.file_path.parent
271
+ config_names = [
272
+ ".markdownlint.json",
273
+ ".markdownlint.jsonc",
274
+ ".markdownlint.yaml",
275
+ ".markdownlint.yml",
276
+ ".markdownlintrc",
277
+ ]
278
+
279
+ # Search up to 5 levels up
280
+ for _ in range(5):
281
+ for config_name in config_names:
282
+ config_path = current_dir / config_name
283
+ if config_path.exists():
284
+ try:
285
+ with open(config_path, encoding="utf-8") as f:
286
+ if config_name.endswith((".yaml", ".yml")):
287
+ config = yaml.safe_load(f)
288
+ # yaml.safe_load returns None for empty files
289
+ if config is None:
290
+ config = {}
291
+ elif config_name.endswith(
292
+ (".json", ".jsonc", "rc")
293
+ ):
294
+ content = f.read()
295
+ if config_name.endswith(".jsonc"):
296
+ content = self._remove_jsonc_comments(
297
+ content
298
+ )
299
+ config = json.loads(content)
300
+ else:
301
+ continue
302
+
303
+ # Check if rule is explicitly disabled
304
+ if rule_name in config:
305
+ return config[rule_name] is not False
306
+ # If not specified, assume enabled (markdownlint default)
307
+ return True
308
+ except (json.JSONDecodeError, yaml.YAMLError, OSError):
309
+ # If config can't be read, assume rule is enabled
310
+ pass
311
+
312
+ # Move up one directory
313
+ if current_dir.parent == current_dir:
314
+ break # Reached root
315
+ current_dir = current_dir.parent
316
+
317
+ # No config found, assume rule is enabled by default
318
+ return True
319
+
320
+ def _check_md013_enabled(self) -> bool:
321
+ """Check if MD013 is enabled in markdownlint config.
322
+
323
+ Returns:
324
+ True if MD013 checking is enabled, False otherwise
325
+ """
326
+ return self._check_rule_enabled("MD013")
327
+
328
+ def _check_md060_enabled(self) -> bool:
329
+ """Check if MD060 is enabled in markdownlint config.
330
+
331
+ Returns:
332
+ True if MD060 checking is enabled, False otherwise
333
+ """
334
+ return self._check_rule_enabled("MD060")
335
+
336
+ def _table_has_emojis(self, table: MarkdownTable) -> bool:
337
+ """Check if table contains emoji characters.
338
+
339
+ Emojis cause alignment issues because they take 1 character position
340
+ but display as 2 characters wide, making MD060 compliance impossible.
341
+
342
+ Args:
343
+ table: The table to check
344
+
345
+ Returns:
346
+ True if table contains emojis
347
+ """
348
+ # Emoji pattern - matches most common emoji ranges
349
+ emoji_pattern = re.compile(
350
+ "["
351
+ "\U0001f600-\U0001f64f" # emoticons
352
+ "\U0001f300-\U0001f5ff" # symbols & pictographs
353
+ "\U0001f680-\U0001f6ff" # transport & map symbols
354
+ "\U0001f1e0-\U0001f1ff" # flags (iOS)
355
+ "\U00002702-\U000027b0" # dingbats
356
+ "\U000024c2-\U0001f251" # enclosed characters
357
+ "\U0001f900-\U0001f9ff" # supplemental symbols
358
+ "\U0001fa00-\U0001faff" # extended pictographs
359
+ "]+",
360
+ flags=re.UNICODE,
361
+ )
362
+
363
+ for row in table.rows:
364
+ for cell in row.cells:
365
+ if emoji_pattern.search(cell.content):
366
+ return True
367
+ return False
368
+
369
+ def _parse_markdownlint_comment(
370
+ self, line: str, comment_type: str
371
+ ) -> set[str]:
372
+ """Parse markdownlint comment to extract rule names.
373
+
374
+ Args:
375
+ line: The line to parse
376
+ comment_type: Either "disable" or "enable"
377
+
378
+ Returns:
379
+ Set of rule names found in the comment (e.g., {"MD013", "MD060"})
380
+ """
381
+ # Match markdownlint comments with word boundaries for rule names
382
+ # Examples:
383
+ # <!-- markdownlint-disable MD013 MD060 -->
384
+ # <!-- markdownlint-enable MD013 -->
385
+ pattern = rf"<!--\s*markdownlint-{comment_type}\s+(.*?)\s*-->"
386
+ match = re.search(pattern, line)
387
+ if not match:
388
+ return set()
389
+
390
+ # Extract the rules part and split by whitespace
391
+ rules_text = match.group(1)
392
+ # Match MD followed by digits, using word boundaries to avoid false matches
393
+ rule_pattern = r"\bMD\d+\b"
394
+ rules = re.findall(rule_pattern, rules_text)
395
+ return set(rules)
191
396
 
192
397
  def fix_file(
193
398
  self, tables: list[MarkdownTable], dry_run: bool = False
@@ -232,7 +437,10 @@ class FileFixer:
232
437
  # Build a map of all tables that need MD013 comments
233
438
  tables_needing_md013: dict[int, tuple[MarkdownTable, list[str]]] = {}
234
439
 
235
- # Check all tables for line length violations (not just fixed ones)
440
+ # Build a map of tables needing MD060 comments (tables with emojis)
441
+ tables_needing_md060: dict[int, tuple[MarkdownTable, list[str]]] = {}
442
+
443
+ # Check all tables for line length violations and emoji issues
236
444
  for table in all_tables:
237
445
  # Get the table content (either fixed or original)
238
446
  table_lines = []
@@ -261,24 +469,50 @@ class FileFixer:
261
469
  if needs_md013:
262
470
  tables_needing_md013[table.start_line] = (table, table_lines)
263
471
 
472
+ # Check if table has emojis (causes MD060 violations)
473
+ if self._md060_enabled and self._table_has_emojis(table):
474
+ tables_needing_md060[table.start_line] = (table, table_lines)
475
+
264
476
  # Create a unified list of all table modifications to apply
265
- # This includes both fixes and MD013-only tables
266
- all_modifications: list[tuple[int, int, list[str], bool]] = []
477
+ # This includes fixes, MD013-only tables, and MD060-only tables
478
+ all_modifications: list[tuple[int, int, list[str], bool, bool]] = []
267
479
 
268
480
  # Add fixes to modifications list
269
481
  for fix in fixes:
270
482
  fixed_lines = fix.fixed_content.split("\n")
271
483
  needs_md013 = fix.start_line in tables_needing_md013
484
+ needs_md060 = fix.start_line in tables_needing_md060
272
485
  all_modifications.append(
273
- (fix.start_line, fix.end_line, fixed_lines, needs_md013)
486
+ (
487
+ fix.start_line,
488
+ fix.end_line,
489
+ fixed_lines,
490
+ needs_md013,
491
+ needs_md060,
492
+ )
274
493
  )
275
494
 
276
495
  # Add tables that need MD013 but have no fixes
277
496
  for start_line, (table, table_lines) in tables_needing_md013.items():
278
497
  # Check if already in fixes
279
498
  if not any(mod[0] == start_line for mod in all_modifications):
499
+ needs_md060 = start_line in tables_needing_md060
280
500
  all_modifications.append(
281
- (table.start_line, table.end_line, table_lines, True)
501
+ (
502
+ table.start_line,
503
+ table.end_line,
504
+ table_lines,
505
+ True,
506
+ needs_md060,
507
+ )
508
+ )
509
+
510
+ # Add tables that need MD060 but have no fixes or MD013
511
+ for start_line, (table, table_lines) in tables_needing_md060.items():
512
+ # Check if already in fixes or MD013 list
513
+ if not any(mod[0] == start_line for mod in all_modifications):
514
+ all_modifications.append(
515
+ (table.start_line, table.end_line, table_lines, False, True)
282
516
  )
283
517
 
284
518
  # Apply all modifications in reverse order to maintain line numbers
@@ -286,22 +520,37 @@ class FileFixer:
286
520
  if not all_modifications:
287
521
  return
288
522
 
289
- for start_line, end_line, content_lines, needs_md013 in sorted(
290
- all_modifications, key=lambda x: x[0], reverse=True
291
- ):
523
+ for (
524
+ start_line,
525
+ end_line,
526
+ content_lines,
527
+ needs_md013,
528
+ needs_md060,
529
+ ) in sorted(all_modifications, key=lambda x: x[0], reverse=True):
292
530
  start_idx = start_line - 1
293
531
  end_idx = end_line
294
532
 
295
533
  # Prepare the lines to insert
296
534
  new_lines = [line + "\n" for line in content_lines]
297
535
 
298
- # Add MD013 comments if needed
299
- if needs_md013:
536
+ # Collect all disable/enable rules needed
537
+ disable_rules = []
538
+ if needs_md013 and self._md013_enabled:
539
+ disable_rules.append("MD013")
540
+ if needs_md060 and self._md060_enabled:
541
+ disable_rules.append("MD060")
542
+
543
+ # Add markdownlint comments if needed
544
+ if disable_rules:
545
+ disable_comment = " ".join(disable_rules)
300
546
  # Check if disable comment already exists within 3 lines before the table
301
547
  has_disable = False
302
548
  check_start = max(0, start_idx - 3)
303
549
  for i in range(check_start, start_idx):
304
- if "markdownlint-disable MD013" in lines[i]:
550
+ found_rules = self._parse_markdownlint_comment(
551
+ lines[i], "disable"
552
+ )
553
+ if all(rule in found_rules for rule in disable_rules):
305
554
  has_disable = True
306
555
  break
307
556
 
@@ -309,7 +558,10 @@ class FileFixer:
309
558
  has_enable = False
310
559
  check_end = min(len(lines), end_idx + 3)
311
560
  for i in range(end_idx, check_end):
312
- if "markdownlint-enable MD013" in lines[i]:
561
+ found_rules = self._parse_markdownlint_comment(
562
+ lines[i], "enable"
563
+ )
564
+ if all(rule in found_rules for rule in disable_rules):
313
565
  has_enable = True
314
566
  break
315
567
 
@@ -320,13 +572,15 @@ class FileFixer:
320
572
  # No blank line, add both blank line and comment
321
573
  new_lines.insert(0, "\n")
322
574
  new_lines.insert(
323
- 1, "<!-- markdownlint-disable MD013 -->\n"
575
+ 1,
576
+ f"<!-- markdownlint-disable {disable_comment} -->\n",
324
577
  )
325
578
  new_lines.insert(2, "\n")
326
579
  else:
327
580
  # Blank line exists, just add comment
328
581
  new_lines.insert(
329
- 0, "<!-- markdownlint-disable MD013 -->\n"
582
+ 0,
583
+ f"<!-- markdownlint-disable {disable_comment} -->\n",
330
584
  )
331
585
  new_lines.insert(1, "\n")
332
586
 
@@ -334,7 +588,9 @@ class FileFixer:
334
588
  if not has_enable:
335
589
  # Always add blank line and enable comment
336
590
  new_lines.append("\n")
337
- new_lines.append("<!-- markdownlint-enable MD013 -->\n")
591
+ new_lines.append(
592
+ f"<!-- markdownlint-enable {disable_comment} -->\n"
593
+ )
338
594
 
339
595
  # Replace the section
340
596
  lines[start_idx:end_idx] = new_lines
@@ -170,9 +170,9 @@ class TableParser:
170
170
  if stripped.endswith("|"):
171
171
  stripped = stripped[:-1]
172
172
 
173
- # Split by pipe to get cells
173
+ # Split by pipe to get cells, but handle escaped pipes and HTML entities
174
174
  # We need to track column positions for alignment checking
175
- parts = stripped.split("|")
175
+ parts = self._split_by_unescaped_pipes(stripped)
176
176
 
177
177
  current_col = 1 # Start after first pipe
178
178
  for part in parts:
@@ -189,6 +189,49 @@ class TableParser:
189
189
 
190
190
  return cells
191
191
 
192
+ def _split_by_unescaped_pipes(self, text: str) -> list[str]:
193
+ """Split text by pipes, but not escaped pipes or HTML entities.
194
+
195
+ Args:
196
+ text: Text to split
197
+
198
+ Returns:
199
+ List of cell contents
200
+ """
201
+ parts = []
202
+ current = []
203
+ i = 0
204
+
205
+ while i < len(text):
206
+ # Check for backslash-escaped pipe
207
+ if i < len(text) - 1 and text[i : i + 2] == r"\|":
208
+ # Keep the escaped pipe in the content
209
+ current.append(text[i : i + 2])
210
+ i += 2
211
+ # Check for HTML entity for pipe (&#124;)
212
+ elif i < len(text) - 5 and text[i : i + 6] == "&#124;":
213
+ # Keep the HTML entity in the content
214
+ current.append(text[i : i + 6])
215
+ i += 6
216
+ # Check for unescaped pipe (cell separator)
217
+ elif text[i] == "|":
218
+ # This is a cell separator
219
+ parts.append("".join(current))
220
+ current = []
221
+ i += 1
222
+ else:
223
+ # Regular character
224
+ current.append(text[i])
225
+ i += 1
226
+
227
+ # Add the last cell
228
+ if (
229
+ current or parts
230
+ ): # Include empty last cell if there were previous parts
231
+ parts.append("".join(current))
232
+
233
+ return parts
234
+
192
235
 
193
236
  class MarkdownFileScanner:
194
237
  """Scanner for finding markdown files."""
@@ -188,8 +188,11 @@ class TableValidator:
188
188
  def _calculate_column_widths(self) -> list[int]:
189
189
  """Calculate the maximum width needed for each column.
190
190
 
191
+ For MD060 compliance, we calculate widths based on character length.
192
+ This uses string length, not display width or byte length.
193
+
191
194
  Returns:
192
- List of column widths
195
+ List of column widths (in characters)
193
196
  """
194
197
  if not self.table.rows:
195
198
  return []
@@ -203,6 +206,7 @@ class TableValidator:
203
206
  for row in self.table.rows:
204
207
  if col_idx < len(row.cells):
205
208
  cell = row.cells[col_idx]
209
+ # Use character length for MD060 compliance (pipe alignment)
206
210
  content_width = len(cell.content.strip())
207
211
  max_width = max(max_width, content_width)
208
212
  widths.append(max_width)
@@ -213,10 +217,10 @@ class TableValidator:
213
217
  """Calculate expected positions of pipes based on column widths.
214
218
 
215
219
  Args:
216
- column_widths: Width of each column
220
+ column_widths: Width of each column (in characters)
217
221
 
218
222
  Returns:
219
- List of pipe positions
223
+ List of pipe positions (in characters)
220
224
  """
221
225
  positions: list[int] = [0] # Starting pipe at position 0
222
226
  current_pos = 0
@@ -237,13 +241,22 @@ class TableValidator:
237
241
  row: The row to analyze
238
242
 
239
243
  Returns:
240
- List of pipe positions
244
+ List of pipe positions (in characters)
241
245
  """
242
246
  positions: list[int] = []
243
247
  line = row.raw_line
244
248
 
245
249
  for idx, char in enumerate(line):
246
250
  if char == "|":
251
+ # Skip escaped pipes (preceded by an odd number of backslashes)
252
+ backslash_count = 0
253
+ j = idx - 1
254
+ while j >= 0 and line[j] == "\\":
255
+ backslash_count += 1
256
+ j -= 1
257
+ if backslash_count % 2 == 1:
258
+ # Odd number of backslashes means the pipe is escaped
259
+ continue
247
260
  positions.append(idx)
248
261
 
249
262
  return positions