#!/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())