Add TSF conversion and shared Markdown recognition for mixed function, class, and unit declarations. Extend generation, linting, indexing, examples, and tests around the unified declaration model.
564 lines
20 KiB
Python
564 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""Lint TSL codegen API Markdown against the house standard.
|
||
|
||
The standard lives in tools/tsl-codegen/STANDARD.md.
|
||
Each typed H2 starts one top-level declaration; typed H3/H4 headings describe
|
||
class or unit members. Rules split into hard errors (CI-blocking) and soft
|
||
warnings (style convergence over the ~12k existing entries).
|
||
|
||
Hard errors include incomplete descriptions/metadata/parameter tables and
|
||
invalid class/unit API headings, visibility, or child-heading levels.
|
||
|
||
Soft warnings:
|
||
- optional-parameter wording not starting with `可选。`
|
||
- malformed / empty `<!-- tags: ... -->` line
|
||
|
||
Exit status: 1 if any error (or, with --strict, any warning); else 0.
|
||
|
||
Usage:
|
||
python lint.py --file path/to/page.md
|
||
python lint.py --dir path/to/codegen-dir
|
||
python lint.py --dir path/to/codegen-dir --strict
|
||
"""
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
|
||
|
||
RETURN_LINE_RE = re.compile(r"^返回[::]")
|
||
RETURN_RE = re.compile(r"^返回[::]\s*\S")
|
||
TYPE_LINE_RE = re.compile(r"^类型[::]\s*(.*?)\s*$", re.IGNORECASE)
|
||
TYPE_RE = re.compile(r"^类型[::]\s*\S")
|
||
VALUE_LINE_RE = re.compile(r"^值[::]")
|
||
VALUE_RE = re.compile(r"^值[::]\s*(?:`[^`]+`|[^`\s])")
|
||
ACCESS_RE = re.compile(r"^访问[::]\s*(read|write|read\s*/\s*write)\s*$")
|
||
VISIBILITY_RE = re.compile(r"^可见性[::]\s*`?(public|protected|private)`?\s*$")
|
||
BASE_RE = re.compile(r"^父类[::]")
|
||
MODIFIERS_RE = re.compile(r"^修饰符[::]")
|
||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
|
||
FENCE_RE = re.compile(r"^(```|~~~)")
|
||
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+(.+?)\s*$")
|
||
VALUE_HEADING_RE = re.compile(r"^`.+?`\s+取值$")
|
||
OPTIONAL_HINT_RE = re.compile(r"可选|可省略|省略")
|
||
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
|
||
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
||
SEP_CELL_RE = re.compile(r"^:?-+:?$")
|
||
|
||
PARAM_HEADER = ["参数", "类型", "说明"]
|
||
|
||
|
||
def scan_body(lines, start, end):
|
||
"""Return [(lineno, raw, in_fence)] for the entry body (excludes the
|
||
signature line). Fence delimiter lines are marked in_fence so callers
|
||
skip both the fences and their contents."""
|
||
body = []
|
||
in_fence = False
|
||
for idx in range(start + 1, end):
|
||
raw = lines[idx]
|
||
if FENCE_RE.match(raw.strip()):
|
||
body.append((idx, raw, True))
|
||
in_fence = not in_fence
|
||
continue
|
||
body.append((idx, raw, in_fence))
|
||
return body
|
||
|
||
|
||
def has_params(sig):
|
||
"""True if the signature's parentheses hold anything (`...` counts)."""
|
||
left = sig.find("(")
|
||
right = sig.rfind(")")
|
||
if left == -1 or right == -1 or right < left:
|
||
return False
|
||
return bool(sig[left + 1:right].strip())
|
||
|
||
|
||
def signature_params(sig):
|
||
"""Return the parameter names carried by a normalized API signature."""
|
||
left = sig.find("(")
|
||
right = sig.rfind(")")
|
||
if left == -1 or right == -1 or right < left:
|
||
return []
|
||
raw = sig[left + 1:right].strip()
|
||
return [item.strip() for item in raw.split(",")] if raw else []
|
||
|
||
|
||
def split_row(text):
|
||
"""Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
|
||
parts = CELL_SPLIT_RE.split(text.strip())
|
||
if parts and parts[0].strip() == "":
|
||
parts = parts[1:]
|
||
if parts and parts[-1].strip() == "":
|
||
parts = parts[:-1]
|
||
return [p.strip() for p in parts]
|
||
|
||
|
||
def is_separator_row(cells):
|
||
return bool(cells) and all(SEP_CELL_RE.match(c) for c in cells)
|
||
|
||
|
||
def find_table(body):
|
||
"""Return (header_lineno, header_cells, [(lineno, cells)] data_rows) for the
|
||
first pipe table in the body, or None. Skips fenced content."""
|
||
collected = []
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
stripped = raw.strip()
|
||
if stripped.startswith("|"):
|
||
collected.append((lineno, stripped))
|
||
elif collected:
|
||
break # blank/prose line ends the table
|
||
if not collected:
|
||
return None
|
||
header_lineno, header_text = collected[0]
|
||
header_cells = split_row(header_text)
|
||
data = []
|
||
for lineno, text in collected[1:]:
|
||
cells = split_row(text)
|
||
if is_separator_row(cells):
|
||
continue
|
||
data.append((lineno, cells))
|
||
return header_lineno, header_cells, data
|
||
|
||
|
||
def find_description(body):
|
||
"""Return (found, lineno_of_offending_line). found is True when the first
|
||
content line after the signature is prose. When False the lineno points at
|
||
the table/heading/return line that showed up where a description belongs
|
||
(or None if the entry is empty)."""
|
||
for lineno, raw, in_fence in body:
|
||
stripped = raw.strip()
|
||
if not stripped or in_fence:
|
||
continue
|
||
if DECLARATION_LINE_RE.fullmatch(stripped):
|
||
continue
|
||
if (
|
||
stripped.startswith("<!--")
|
||
or stripped.startswith("声明:")
|
||
or stripped.startswith("|")
|
||
or stripped.startswith("#")
|
||
or RETURN_LINE_RE.match(stripped)
|
||
or TYPE_LINE_RE.match(stripped)
|
||
or VALUE_LINE_RE.match(stripped)
|
||
or ACCESS_RE.match(stripped)
|
||
or VISIBILITY_RE.match(stripped)
|
||
or BASE_RE.match(stripped)
|
||
or MODIFIERS_RE.match(stripped)
|
||
):
|
||
return False, lineno
|
||
return True, lineno
|
||
return False, None
|
||
|
||
|
||
def check_entry(
|
||
md_display,
|
||
lines,
|
||
start,
|
||
end,
|
||
sig,
|
||
findings,
|
||
*,
|
||
returns_required=True,
|
||
visibility_required=False,
|
||
validate_parameter_rows=False,
|
||
):
|
||
entry_line = start + 1 # 1-based signature line, used for entry-level errors
|
||
body = scan_body(lines, start, end)
|
||
|
||
# description ----------------------------------------------------------
|
||
found, off_lineno = find_description(body)
|
||
if not found:
|
||
line = (off_lineno + 1) if off_lineno is not None else entry_line
|
||
findings.append((md_display, line, "error", "description",
|
||
f"`{sig}` 缺少描述(签名后第一行须为非空描述)"))
|
||
|
||
# return ---------------------------------------------------------------
|
||
has_return = any(
|
||
RETURN_RE.match(raw.strip())
|
||
for _, raw, in_fence in body if not in_fence
|
||
)
|
||
if returns_required and not has_return:
|
||
findings.append((md_display, entry_line, "error", "return",
|
||
f"`{sig}` 缺少 `返回:类型` 行"))
|
||
|
||
visibilities = []
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
match = VISIBILITY_RE.match(raw.strip())
|
||
if match:
|
||
visibilities.append((lineno, match.group(1)))
|
||
if visibility_required and not visibilities:
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
entry_line,
|
||
"error",
|
||
"visibility",
|
||
f"`{sig}` 缺少 `可见性:public|protected` 行",
|
||
)
|
||
)
|
||
for lineno, visibility in visibilities:
|
||
if visibility == "private":
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
lineno + 1,
|
||
"error",
|
||
"visibility",
|
||
"private API 不得进入文档",
|
||
)
|
||
)
|
||
|
||
# parameter table ------------------------------------------------------
|
||
table = find_table(body)
|
||
wants_params = has_params(sig)
|
||
if wants_params and table is None:
|
||
findings.append((md_display, entry_line, "error", "param-table",
|
||
f"`{sig}` 有参数但缺少参数表"))
|
||
elif not wants_params and table is not None:
|
||
header_lineno = table[0]
|
||
findings.append((md_display, header_lineno + 1, "error", "param-table",
|
||
f"`{sig}` 无参数却存在参数表"))
|
||
elif table is not None:
|
||
header_lineno, header_cells, data_rows = table
|
||
if header_cells != PARAM_HEADER:
|
||
findings.append((md_display, header_lineno + 1, "error",
|
||
"param-header",
|
||
f"参数表表头须为 {' / '.join(PARAM_HEADER)},"
|
||
f"实为 {' / '.join(header_cells) or '(空)'}"))
|
||
elif validate_parameter_rows:
|
||
actual_names = []
|
||
rows_valid = True
|
||
for lineno, cells in data_rows:
|
||
if len(cells) != 3 or any(not cell.strip() for cell in cells):
|
||
rows_valid = False
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
lineno + 1,
|
||
"error",
|
||
"param-row",
|
||
"参数表每行都必须包含非空的参数名、类型和说明",
|
||
)
|
||
)
|
||
continue
|
||
name_cell = cells[0]
|
||
if name_cell.startswith("`") and name_cell.endswith("`"):
|
||
name_cell = name_cell[1:-1]
|
||
actual_names.append(name_cell.strip())
|
||
expected_names = signature_params(sig)
|
||
if rows_valid and [name.casefold() for name in actual_names] != [
|
||
name.casefold() for name in expected_names
|
||
]:
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
header_lineno + 1,
|
||
"error",
|
||
"param-names",
|
||
f"参数表名称/顺序必须与 `{sig}` 一致",
|
||
)
|
||
)
|
||
# soft: optional-parameter wording
|
||
for lineno, cells in data_rows:
|
||
if len(cells) < 3:
|
||
continue
|
||
desc = cells[2]
|
||
if OPTIONAL_HINT_RE.search(desc) and not desc.startswith("可选。"):
|
||
findings.append((md_display, lineno + 1, "warning", "optional",
|
||
"可选参数说明建议以 `可选。` 开头"))
|
||
|
||
# soft: tags line ------------------------------------------------------
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
m = TAGS_RE.match(raw.strip())
|
||
if m and not m.group(1).split():
|
||
findings.append((md_display, lineno + 1, "warning", "tags",
|
||
"空的 tags 行;填入关键词或删除"))
|
||
|
||
|
||
def check_api_subheadings(md_display, lines, entry, findings):
|
||
"""Enforce the fixed API child-heading levels and labels."""
|
||
heading = entry.heading
|
||
|
||
allowed_titles = set()
|
||
if heading.level == 2 and heading.root_kind == "function":
|
||
allowed_titles.add("示例")
|
||
allow_values = False
|
||
elif heading.kind in {"function", "method"}:
|
||
allowed_titles.add("示例")
|
||
allow_values = True
|
||
elif heading.kind == "property":
|
||
allow_values = True
|
||
else:
|
||
allow_values = False
|
||
expected_level = heading.level + 1
|
||
|
||
for lineno, raw, in_fence in scan_body(lines, entry.start, entry.end):
|
||
if in_fence:
|
||
continue
|
||
match = HEADING_RE.match(raw.strip())
|
||
if not match:
|
||
continue
|
||
level = len(match.group(1))
|
||
title = match.group(2)
|
||
title_allowed = title in allowed_titles or bool(
|
||
allow_values and VALUE_HEADING_RE.match(title)
|
||
)
|
||
if level == expected_level and title_allowed:
|
||
continue
|
||
if not allow_values and not allowed_titles:
|
||
message = f"{heading.kind} `{heading.signature}` 不得包含子标题"
|
||
else:
|
||
message = (
|
||
f"{heading.kind} `{heading.signature}` 的子标题必须位于 "
|
||
f"H{expected_level},且只能使用参数取值"
|
||
+ ("或示例" if "示例" in allowed_titles else "")
|
||
)
|
||
findings.append(
|
||
(md_display, lineno + 1, "error", "subheading", message)
|
||
)
|
||
|
||
|
||
def lint_file(md, root, findings):
|
||
try:
|
||
display = md.relative_to(root).as_posix()
|
||
except ValueError:
|
||
display = str(md)
|
||
lines = md.read_text(encoding="utf-8").splitlines()
|
||
for entry in iter_api_entries(lines):
|
||
heading = entry.heading
|
||
if not heading.valid:
|
||
findings.append(
|
||
(
|
||
display,
|
||
entry.start + 1,
|
||
"error",
|
||
"heading",
|
||
heading.error,
|
||
)
|
||
)
|
||
body = scan_body(lines, entry.start, entry.end)
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
visibility = VISIBILITY_RE.match(raw.strip())
|
||
if visibility and visibility.group(1) == "private":
|
||
findings.append(
|
||
(
|
||
display,
|
||
lineno + 1,
|
||
"error",
|
||
"visibility",
|
||
"private API 不得进入文档",
|
||
)
|
||
)
|
||
continue
|
||
|
||
kind = heading.kind
|
||
check_api_subheadings(display, lines, entry, findings)
|
||
if kind == "function":
|
||
check_entry(
|
||
display,
|
||
lines,
|
||
entry.start,
|
||
entry.end,
|
||
heading.signature,
|
||
findings,
|
||
validate_parameter_rows=heading.root_kind != "function",
|
||
)
|
||
continue
|
||
if kind == "method":
|
||
check_entry(
|
||
display,
|
||
lines,
|
||
entry.start,
|
||
entry.end,
|
||
heading.signature,
|
||
findings,
|
||
returns_required=False,
|
||
visibility_required=True,
|
||
validate_parameter_rows=True,
|
||
)
|
||
continue
|
||
if kind == "property":
|
||
check_entry(
|
||
display,
|
||
lines,
|
||
entry.start,
|
||
entry.end,
|
||
heading.signature,
|
||
findings,
|
||
returns_required=False,
|
||
visibility_required=True,
|
||
validate_parameter_rows=True,
|
||
)
|
||
body = scan_body(lines, entry.start, entry.end)
|
||
empty_types = [
|
||
lineno
|
||
for lineno, raw, fenced in body
|
||
if not fenced
|
||
and (match := TYPE_LINE_RE.fullmatch(raw.strip()))
|
||
and not match.group(1).strip()
|
||
]
|
||
for lineno in empty_types:
|
||
findings.append(
|
||
(
|
||
display,
|
||
lineno + 1,
|
||
"error",
|
||
"type",
|
||
f"`{heading.signature}` 的类型不能为空",
|
||
)
|
||
)
|
||
if not any(
|
||
ACCESS_RE.match(raw.strip())
|
||
for _, raw, fenced in body
|
||
if not fenced
|
||
):
|
||
findings.append(
|
||
(
|
||
display,
|
||
entry.start + 1,
|
||
"error",
|
||
"access",
|
||
f"`{heading.signature}` 缺少访问方式",
|
||
)
|
||
)
|
||
continue
|
||
|
||
body = scan_body(lines, entry.start, entry.end)
|
||
found, offending = find_description(body)
|
||
if not found:
|
||
line = offending + 1 if offending is not None else entry.start + 1
|
||
findings.append(
|
||
(
|
||
display,
|
||
line,
|
||
"error",
|
||
"description",
|
||
f"`{heading.signature}` 缺少描述",
|
||
)
|
||
)
|
||
|
||
if kind in {"class", "unit"}:
|
||
continue
|
||
|
||
visibility_required = heading.binding != "unit"
|
||
visibilities = [
|
||
(lineno, match.group(1))
|
||
for lineno, raw, fenced in body
|
||
if not fenced and (match := VISIBILITY_RE.match(raw.strip()))
|
||
]
|
||
if visibility_required and not visibilities:
|
||
findings.append(
|
||
(
|
||
display,
|
||
entry.start + 1,
|
||
"error",
|
||
"visibility",
|
||
f"`{heading.signature}` 缺少 `可见性:public|protected` 行",
|
||
)
|
||
)
|
||
for lineno, visibility in visibilities:
|
||
if visibility == "private":
|
||
findings.append(
|
||
(
|
||
display,
|
||
lineno + 1,
|
||
"error",
|
||
"visibility",
|
||
"private API 不得进入文档",
|
||
)
|
||
)
|
||
|
||
has_type = any(TYPE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
|
||
has_value = any(VALUE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
|
||
if kind in {"field", "variable"} and not has_type:
|
||
findings.append(
|
||
(display, entry.start + 1, "error", "type", f"`{heading.signature}` 缺少类型")
|
||
)
|
||
if kind == "constant" and not has_value:
|
||
findings.append(
|
||
(display, entry.start + 1, "error", "value", f"`{heading.signature}` 缺少值")
|
||
)
|
||
|
||
|
||
def gather_targets(paths, root):
|
||
"""Expand paths (files/dirs) into a sorted list of *.md."""
|
||
targets = []
|
||
for p in paths:
|
||
if p.is_dir():
|
||
targets.extend(p.rglob("*.md"))
|
||
elif p.is_file() and p.suffix == ".md":
|
||
targets.append(p)
|
||
return sorted(set(targets))
|
||
|
||
|
||
def main(argv=None):
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(
|
||
description="校验 Markdown 文件或目录",
|
||
add_help=False,
|
||
allow_abbrev=False,
|
||
)
|
||
parser.add_argument(
|
||
"--help",
|
||
action="help",
|
||
help="显示本帮助并退出(不提供 -h 短选项)",
|
||
)
|
||
target_group = parser.add_mutually_exclusive_group(required=True)
|
||
target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
|
||
target_group.add_argument("--dir", help="要递归校验的目录")
|
||
parser.add_argument("--strict", action="store_true",
|
||
help="treat warnings as failures")
|
||
args = parser.parse_args(argv)
|
||
|
||
target = Path(args.file or args.dir)
|
||
if not target.exists():
|
||
print(f"ERROR: target not found: {target}", file=sys.stderr)
|
||
return 2
|
||
if args.file and (not target.is_file() or target.suffix.lower() != ".md"):
|
||
print(f"ERROR: --file requires a Markdown file: {target}", file=sys.stderr)
|
||
return 2
|
||
if args.dir and not target.is_dir():
|
||
print(f"ERROR: --dir requires a directory: {target}", file=sys.stderr)
|
||
return 2
|
||
|
||
root = target if target.is_dir() else target.parent
|
||
targets = gather_targets([target], root)
|
||
if not targets:
|
||
print("no markdown targets found", file=sys.stderr)
|
||
return 2
|
||
|
||
findings = []
|
||
for md in targets:
|
||
lint_file(md, root, findings)
|
||
|
||
findings.sort(key=lambda f: (f[0], f[1], 0 if f[2] == "error" else 1))
|
||
for display, line, level, rule, message in findings:
|
||
print(f"{display}:{line}: {level}: [{rule}] {message}")
|
||
|
||
errors = sum(1 for f in findings if f[2] == "error")
|
||
warnings = sum(1 for f in findings if f[2] == "warning")
|
||
print(
|
||
f"\n{len(targets)} files, {errors} error(s), {warnings} warning(s)",
|
||
file=sys.stderr,
|
||
)
|
||
if errors or (args.strict and warnings):
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|