Add markdown linting utility for docs
This commit is contained in:
parent
d7524147ec
commit
d0baac1efd
1 changed files with 281 additions and 0 deletions
281
markdown-linter.py
Executable file
281
markdown-linter.py
Executable file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue