From d0baac1efdb88f98eef36200c63102b9c80d8e77 Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Tue, 27 Oct 2026 12:03:40 -0400 Subject: [PATCH] Add markdown linting utility for docs --- markdown-linter.py | 281 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100755 markdown-linter.py diff --git a/markdown-linter.py b/markdown-linter.py new file mode 100755 index 0000000..b283165 --- /dev/null +++ b/markdown-linter.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Standalone markdown linter. + +Checks code fence balance, table structure (column counts and +separator rows), heading hierarchy, trailing whitespace, and the +final newline. + +Usage: + markdown-linter.py FILE [FILE...] + markdown-linter.py DIR + +Directories are scanned recursively for *.md / *.markdown files +(hidden paths are skipped). + +Exit codes: + 0 no issues found + 1 one or more issues found + 2 usage or file errors +""" + +import argparse +import re +import sys +from collections import namedtuple +from pathlib import Path + +Issue = namedtuple("Issue", ["line", "message"]) + +FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") +HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+.*)?$") +BAD_HEADING_RE = re.compile(r"^ {0,3}#{1,6}(?=[^#\s])") +SEP_CELL_RE = re.compile(r"^:?-+:?$") +MD_SUFFIXES = {".md", ".markdown"} + + +def classify_fences(lines): + """Return per-line fence flags and the index of an unclosed fence, if any.""" + in_fence = [False] * len(lines) + open_idx = None + close_re = None + + for i, line in enumerate(lines): + if open_idx is None: + m = FENCE_OPEN_RE.match(line) + if m and not (m.group(1)[0] == "`" and "`" in m.group(2)): + marker = m.group(1) + open_idx = i + close_re = re.compile( + "^ {0,3}" + re.escape(marker[0]) + "{" + str(len(marker)) + ",}[ \t]*$" + ) + in_fence[i] = True + else: + in_fence[i] = True + if close_re.match(line): + open_idx = None + + return in_fence, open_idx + + +def split_row(line): + """Split a table row on unescaped pipes; drop optional edge cells.""" + s = line.strip() + cells = [] + cur = [] + i = 0 + while i < len(s): + ch = s[i] + if ch == "\\" and i + 1 < len(s): + cur.append(ch) + cur.append(s[i + 1]) + i += 2 + elif ch == "|": + cells.append("".join(cur)) + cur = [] + i += 1 + else: + cur.append(ch) + i += 1 + cells.append("".join(cur)) + + if cells and not cells[0].strip(): + cells.pop(0) + if cells and not cells[-1].strip(): + cells.pop() + return cells + + +def _table_line(line): + stripped = line.lstrip(" \t") + return len(line) - len(stripped) <= 3 and stripped.startswith("|") + + +def _separator_like(cells): + return bool(cells) and all(SEP_CELL_RE.match(c.strip()) for c in cells) + + +def check_tables(lines, in_fence): + issues = [] + n = len(lines) + i = 0 + while i < n: + if in_fence[i] or not _table_line(lines[i]): + i += 1 + continue + j = i + while j < n and not in_fence[j] and _table_line(lines[j]): + j += 1 + rows = list(range(i, j)) + if len(rows) >= 2: + issues.extend(_check_table_block(lines, rows)) + i = j + return issues + + +def _check_table_block(lines, rows): + issues = [] + header = split_row(lines[rows[0]]) + expected = len(header) + sep = split_row(lines[rows[1]]) + sep_bad = len(sep) != expected + + if not _separator_like(sep): + issues.append(Issue(rows[1] + 1, "table missing or invalid separator row after header")) + return issues + + if sep_bad: + issues.append(Issue(rows[1] + 1, f"table separator row has {len(sep)} columns, header has {expected}")) + + for ln in rows: + if ln == rows[1] and sep_bad: + continue + count = len(split_row(lines[ln])) + if count != expected: + issues.append(Issue(ln + 1, f"table column mismatch: expected {expected}, got {count}")) + return issues + + +def check_headings(lines, in_fence): + issues = [] + prev_level = 0 + first_h1 = None + + for i, line in enumerate(lines): + if in_fence[i]: + continue + if BAD_HEADING_RE.match(line): + issues.append(Issue(i + 1, "heading is missing a space after # (not a valid ATX heading)")) + continue + m = HEADING_RE.match(line) + if not m: + continue + level = len(m.group(1)) + if prev_level and level > prev_level + 1: + issues.append(Issue(i + 1, f"heading level skipped: H{prev_level} to H{level}")) + if level == 1: + if first_h1 is not None: + issues.append(Issue(i + 1, f"multiple H1 headings (first at line {first_h1 + 1})")) + else: + first_h1 = i + prev_level = level + + return issues + + +def check_trailing_whitespace(lines, in_fence): + return [ + Issue(i + 1, "trailing whitespace") + for i, line in enumerate(lines) + if not in_fence[i] and line != line.rstrip(" \t") + ] + + +def lint_file(path): + content = path.read_text(encoding="utf-8-sig") + + if content: + lines = content.split("\n") + if lines[-1] == "": + lines.pop() + else: + lines = [] + + in_fence, unclosed = classify_fences(lines) + + issues = [] + if unclosed is not None: + issues.append(Issue(unclosed + 1, "code fence opened but never closed")) + issues.extend(check_headings(lines, in_fence)) + issues.extend(check_tables(lines, in_fence)) + issues.extend(check_trailing_whitespace(lines, in_fence)) + if content and not content.endswith("\n"): + issues.append(Issue(len(lines), "file is missing a final newline")) + + issues.sort(key=lambda issue: issue.line) + return issues + + +def _is_hidden(rel): + return any(part.startswith(".") for part in rel.parts) + + +def collect_files(paths): + files = [] + errors = [] + seen = set() + + for raw in paths: + path = Path(raw) + if not path.exists(): + errors.append(f"error: no such file or directory: {raw}") + continue + if path.is_dir(): + found = [ + p for p in sorted(path.rglob("*")) + if p.is_file() and p.suffix.lower() in MD_SUFFIXES + and not _is_hidden(p.relative_to(path)) + ] + if not found: + errors.append(f"error: no markdown files found in directory: {raw}") + continue + candidates = found + else: + candidates = [path] + for cand in candidates: + key = str(cand.resolve()) + if key not in seen: + seen.add(key) + files.append(cand) + + return files, errors + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Lint markdown: code fences, tables, headings, " + "trailing whitespace, and final newlines." + ) + parser.add_argument("paths", nargs="+", metavar="PATH", + help="markdown file(s) or directories to lint") + args = parser.parse_args(argv) + + files, errors = collect_files(args.paths) + if errors: + for err in errors: + print(err, file=sys.stderr) + return 2 + if not files: + print("error: no markdown files found", file=sys.stderr) + return 2 + + results = [] + read_errors = [] + for path in files: + try: + results.append((path, lint_file(path))) + except (OSError, UnicodeDecodeError) as exc: + read_errors.append(f"error: cannot read {path}: {exc}") + + if read_errors: + for err in read_errors: + print(err, file=sys.stderr) + return 2 + + total = sum(len(issues) for _, issues in results) + if total == 0: + print(f"All clean ({len(results)} file(s) checked)") + return 0 + + bad = sum(1 for _, issues in results if issues) + blocks = [] + for path, issues in results: + if issues: + blocks.append("\n".join(f"{path}:{issue.line}: {issue.message}" for issue in issues)) + print("\n\n".join(blocks)) + print() + print(f"{total} issue(s) in {bad} of {len(results)} file(s)") + return 1 + + +if __name__ == "__main__": + sys.exit(main())