#!/usr/bin/env python3 # file: scripts/audit_markdown_tables.py # version: 1 """Validate source formatting of Markdown tables in explicitly selected paths.""" from __future__ import annotations import argparse import pathlib import re import sys _SEPARATOR = re.compile(r"^:?-{3,}:?$") _IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"}) def _selected_files(raw_paths: list[str]) -> tuple[list[pathlib.Path], list[str]]: files: list[pathlib.Path] = [] errors: list[str] = [] for raw_path in raw_paths: path = pathlib.Path(raw_path) if path.is_dir(): files.extend( candidate for candidate in path.rglob("*.md") if candidate.is_file() and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts) ) elif path.is_file() and path.suffix.lower() == ".md": files.append(path) else: errors.append(f"Markdown table audit: unsupported or missing path: {path}") return sorted(set(files)), errors def _is_row(line: str) -> bool: return line.startswith("|") and line.endswith("|") def _cells(line: str) -> list[str]: return line.split("|")[1:-1] def _separator_marker(cell: str) -> str | None: marker = cell.strip() return marker if _SEPARATOR.fullmatch(marker) is not None else None def _is_separator_row(line: str) -> bool: if not _is_row(line): return False cells = _cells(line) return bool(cells) and all(_separator_marker(cell) is not None for cell in cells) def _alignment(marker: str) -> str: if marker.startswith(":") and marker.endswith(":"): return "center" if marker.endswith(":"): return "right" return "left" def _padding(cell: str) -> tuple[int, int]: return len(cell) - len(cell.lstrip(" ")), len(cell) - len(cell.rstrip(" ")) def _validate_table(path: pathlib.Path, start_line: int, rows: list[str]) -> list[str]: errors: list[str] = [] if any("\\|" in row for row in rows): errors.append(f"{path}:{start_line}: escaped pipe is forbidden inside a table cell") parsed = [_cells(row) for row in rows] column_count = len(parsed[0]) for offset, row in enumerate(parsed): if len(row) != column_count: errors.append(f"{path}:{start_line + offset}: table row has {len(row)} columns; expected {column_count}") return errors for column in range(column_count): raw_cells = [row[column] for row in parsed] widths = [len(cell) for cell in raw_cells] expected_width = widths[0] if any(width != expected_width for width in widths): errors.append(f"{path}:{start_line}: column {column + 1} is not vertically aligned; raw widths are {widths}") continue marker = _separator_marker(raw_cells[1]) if marker is None: errors.append(f"{path}:{start_line + 1}: separator for column {column + 1} is malformed") continue content_cells = [cell for index, cell in enumerate(raw_cells) if index != 1] longest_content = max(len(cell.strip()) for cell in content_cells) required_width = max(longest_content + 2, 3 + marker.count(":")) if expected_width != required_width: errors.append( f"{path}:{start_line}: column {column + 1} width is {expected_width}; expected {required_width}" ) continue alignment = _alignment(marker) for cell in content_cells: content_width = len(cell.strip()) left, right = _padding(cell) expected_padding = expected_width - content_width if content_width + left + right != len(cell) or left < 1 or right < 1: errors.append(f"{path}:{start_line}: column {column + 1} must use spaces only for outer padding") break if alignment == "left" and (left != 1 or right != expected_padding - 1): errors.append(f"{path}:{start_line}: column {column + 1} must be source-aligned to the left") break if alignment == "right" and (right != 1 or left != expected_padding - 1): errors.append(f"{path}:{start_line}: column {column + 1} must be source-aligned to the right") break if alignment == "center" and abs(left - right) > 1: errors.append(f"{path}:{start_line}: column {column + 1} must be source-aligned to the center") break if len(raw_cells[1]) != expected_width or _SEPARATOR.fullmatch(raw_cells[1]) is None: errors.append(f"{path}:{start_line + 1}: separator for column {column + 1} must fill the column width") return errors def _audit_file(path: pathlib.Path) -> tuple[int, list[str]]: lines = path.read_text(encoding="utf-8").splitlines() errors: list[str] = [] tables = 0 fence: str | None = None index = 0 while index < len(lines): stripped = lines[index].lstrip() if stripped.startswith("```") or stripped.startswith("~~~"): marker = stripped[:3] if fence is None: fence = marker elif fence == marker: fence = None index += 1 continue if fence is not None: index += 1 continue if index + 1 < len(lines) and _is_row(lines[index]) and _is_separator_row(lines[index + 1]): start = index rows = [lines[index], lines[index + 1]] index += 2 while index < len(lines) and _is_row(lines[index]): rows.append(lines[index]) index += 1 tables += 1 errors.extend(_validate_table(path, start + 1, rows)) continue index += 1 return tables, errors def main() -> int: """Run the read-only Markdown table audit.""" parser = argparse.ArgumentParser() parser.add_argument("paths", nargs="+", help="Markdown files or directories to audit") arguments = parser.parse_args() files, selection_errors = _selected_files(arguments.paths) if selection_errors: for error in selection_errors: print(error, file=sys.stderr) return 2 if not files: print("Markdown table audit: no Markdown files selected", file=sys.stderr) return 2 table_count = 0 errors: list[str] = [] for path in files: current_count, current_errors = _audit_file(path) table_count += current_count errors.extend(current_errors) if errors: for error in errors: print(error, file=sys.stderr) print(f"Markdown table audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) return 1 print(f"Markdown table audit: clean ({table_count} table(s), {len(files)} file(s))") return 0 if __name__ == "__main__": raise SystemExit(main())