mpvremote/markdown-linter.py

281 lines
7.8 KiB
Python
Executable file

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