281 lines
7.8 KiB
Python
Executable file
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())
|