✨ feat(tsl-codegen): support unified API declarations
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.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""Shared recognition of TSL codegen API headings and declaration lines."""
|
||||
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
TOP_LEVEL_RE = re.compile(r"^##(?!#)\s+`(.+?)`\s*$")
|
||||
PLAIN_H2_RE = re.compile(r"^##(?!#)\s+")
|
||||
BARE_API_RE = re.compile(r"^(#{3,5})(?!#)\s+`(.+?)`\s*$")
|
||||
LEGACY_TYPED_RE = re.compile(
|
||||
r"^(#{3,5})(?!#)\s+"
|
||||
r"(class function|static function|static field|static const|"
|
||||
r"function|property|field|const|var|class)\s+`(.+?)`\s*$"
|
||||
)
|
||||
DECLARATION_LINE_RE = re.compile(r"^声明:(.*?)\s*$")
|
||||
DECLARATION_PREFIX_RE = re.compile(r"^声明[::]", re.IGNORECASE)
|
||||
LOOSE_DECLARATION_RE = re.compile(r"^声明[::]\s*(.*?)\s*$", re.IGNORECASE)
|
||||
TOP_LEVEL_TYPES = {"function", "class", "unit"}
|
||||
CLASS_DECLARATIONS = {
|
||||
"function": ("method", "instance"),
|
||||
"class function": ("method", "class"),
|
||||
"property": ("property", "instance"),
|
||||
"field": ("field", "instance"),
|
||||
"static field": ("field", "static"),
|
||||
"const": ("constant", "instance"),
|
||||
"static const": ("constant", "static"),
|
||||
}
|
||||
UNIT_DECLARATIONS = {
|
||||
"function": ("function", "unit"),
|
||||
"var": ("variable", "unit"),
|
||||
"const": ("constant", "unit"),
|
||||
"class": ("class", "unit"),
|
||||
}
|
||||
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+")
|
||||
FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiHeading:
|
||||
level: int
|
||||
kind: str
|
||||
binding: str
|
||||
name: str
|
||||
signature: str
|
||||
visible_title: str
|
||||
root_kind: str
|
||||
valid: bool = True
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiEntry:
|
||||
heading: ApiHeading
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
def simple_name(signature):
|
||||
return signature.split("(", 1)[0].strip()
|
||||
|
||||
|
||||
def fence_flags(lines):
|
||||
flags = []
|
||||
in_fence = False
|
||||
marker = ""
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if not in_fence and FENCE_RE.match(line):
|
||||
marker = stripped[:3]
|
||||
flags.append(True)
|
||||
in_fence = True
|
||||
continue
|
||||
flags.append(in_fence)
|
||||
if in_fence and stripped.startswith(marker):
|
||||
in_fence = False
|
||||
marker = ""
|
||||
return flags
|
||||
|
||||
|
||||
def heading_body_end(lines, start, flags=None):
|
||||
if flags is None:
|
||||
flags = fence_flags(lines)
|
||||
for index in range(start + 1, len(lines)):
|
||||
if flags[index]:
|
||||
continue
|
||||
if HEADING_RE.match(lines[index]):
|
||||
return index
|
||||
return len(lines)
|
||||
|
||||
|
||||
def top_level_header_end(lines, start, flags=None):
|
||||
return heading_body_end(lines, start, flags)
|
||||
|
||||
|
||||
def _declaration(lines, start, end, flags):
|
||||
content = []
|
||||
declaration_lines = []
|
||||
for index in range(start + 1, end):
|
||||
if flags[index]:
|
||||
continue
|
||||
stripped = lines[index].strip()
|
||||
if not stripped:
|
||||
continue
|
||||
content.append((index, stripped))
|
||||
if DECLARATION_PREFIX_RE.match(stripped):
|
||||
declaration_lines.append((index, stripped))
|
||||
|
||||
if not content:
|
||||
return "", "API 标题后的第一条非空正文必须是 `声明:...`"
|
||||
first_index, first = content[0]
|
||||
match = DECLARATION_LINE_RE.fullmatch(first)
|
||||
if not match:
|
||||
loose = LOOSE_DECLARATION_RE.fullmatch(first)
|
||||
if loose and loose.group(1).strip():
|
||||
expected = f"声明:{loose.group(1).strip().casefold()}"
|
||||
return "", f"声明行必须精确写为 `{expected}`"
|
||||
return (
|
||||
"",
|
||||
"API 标题后的第一条非空正文必须是规范的 `声明:...` 行",
|
||||
)
|
||||
value = match.group(1).strip().casefold()
|
||||
expected = f"声明:{value}"
|
||||
if first != expected:
|
||||
return "", f"声明行必须精确写为 `{expected}`"
|
||||
if len(declaration_lines) != 1 or declaration_lines[0][0] != first_index:
|
||||
return "", "每个 API 必须且只能包含一行声明"
|
||||
return value, ""
|
||||
|
||||
|
||||
def top_level_type(lines, start, end):
|
||||
return _declaration(lines, start, end, fence_flags(lines))
|
||||
|
||||
|
||||
def invalid_heading(level, name, signature, visible_title, root_kind, message):
|
||||
return ApiHeading(
|
||||
level,
|
||||
"invalid",
|
||||
"",
|
||||
name,
|
||||
signature,
|
||||
visible_title,
|
||||
root_kind,
|
||||
False,
|
||||
message,
|
||||
)
|
||||
|
||||
|
||||
def _valid_heading(level, kind, binding, signature, root_kind):
|
||||
return ApiHeading(
|
||||
level,
|
||||
kind,
|
||||
binding,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
)
|
||||
|
||||
|
||||
def top_level_heading(signature, declaration, error):
|
||||
if error:
|
||||
return invalid_heading(
|
||||
2,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
signature,
|
||||
"",
|
||||
error,
|
||||
)
|
||||
if declaration not in TOP_LEVEL_TYPES:
|
||||
return invalid_heading(
|
||||
2,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
signature,
|
||||
"",
|
||||
f"顶级 API 声明必须是 function、class 或 unit,实为 {declaration}",
|
||||
)
|
||||
return _valid_heading(2, declaration, "", signature, declaration)
|
||||
|
||||
|
||||
def member_heading(
|
||||
level,
|
||||
declaration,
|
||||
signature,
|
||||
root_kind,
|
||||
unit_class_open,
|
||||
error,
|
||||
):
|
||||
name = simple_name(signature)
|
||||
if error:
|
||||
return invalid_heading(
|
||||
level, name, signature, signature, root_kind, error
|
||||
)
|
||||
if declaration == "static function":
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"不存在 static function,请使用 class function",
|
||||
)
|
||||
|
||||
if root_kind == "class":
|
||||
if level != 3 or declaration not in CLASS_DECLARATIONS:
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"class API 成员必须使用约定的 H3 声明",
|
||||
)
|
||||
kind, binding = CLASS_DECLARATIONS[declaration]
|
||||
return _valid_heading(level, kind, binding, signature, root_kind)
|
||||
|
||||
if root_kind == "unit":
|
||||
if level == 3:
|
||||
if declaration not in UNIT_DECLARATIONS:
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"unit interface 成员必须使用约定的 H3 声明",
|
||||
)
|
||||
kind, binding = UNIT_DECLARATIONS[declaration]
|
||||
return _valid_heading(level, kind, binding, signature, root_kind)
|
||||
if (
|
||||
level == 4
|
||||
and unit_class_open
|
||||
and declaration in CLASS_DECLARATIONS
|
||||
):
|
||||
kind, binding = CLASS_DECLARATIONS[declaration]
|
||||
return _valid_heading(level, kind, binding, signature, root_kind)
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"unit class 成员必须位于所属 class 的 H4",
|
||||
)
|
||||
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"function 顶级 API 不能包含 API 成员",
|
||||
)
|
||||
|
||||
|
||||
def collect_headings_and_boundaries(lines):
|
||||
flags = fence_flags(lines)
|
||||
headings = []
|
||||
h2_boundaries = []
|
||||
root_kind = ""
|
||||
unit_class_open = False
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if flags[index]:
|
||||
continue
|
||||
|
||||
if PLAIN_H2_RE.match(line):
|
||||
h2_boundaries.append(index)
|
||||
root_kind = ""
|
||||
unit_class_open = False
|
||||
top_level = TOP_LEVEL_RE.match(line)
|
||||
if not top_level:
|
||||
continue
|
||||
signature = top_level.group(1)
|
||||
end = heading_body_end(lines, index, flags)
|
||||
declaration, error = _declaration(
|
||||
lines, index, end, flags
|
||||
)
|
||||
heading = top_level_heading(signature, declaration, error)
|
||||
if heading.valid:
|
||||
root_kind = heading.kind
|
||||
headings.append((index, heading))
|
||||
continue
|
||||
|
||||
if not root_kind:
|
||||
continue
|
||||
|
||||
heading_match = HEADING_RE.match(line)
|
||||
if root_kind == "unit" and heading_match:
|
||||
if len(heading_match.group(1)) == 3:
|
||||
unit_class_open = False
|
||||
|
||||
legacy = LEGACY_TYPED_RE.match(line)
|
||||
if legacy:
|
||||
level = len(legacy.group(1))
|
||||
signature = legacy.group(3)
|
||||
headings.append(
|
||||
(
|
||||
index,
|
||||
invalid_heading(
|
||||
level,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
line.lstrip("# "),
|
||||
root_kind,
|
||||
"API 标题只写名称或调用签名,声明种类写在声明行",
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
bare = BARE_API_RE.match(line)
|
||||
if not bare:
|
||||
continue
|
||||
level = len(bare.group(1))
|
||||
signature = bare.group(2)
|
||||
end = heading_body_end(lines, index, flags)
|
||||
declaration, error = _declaration(lines, index, end, flags)
|
||||
heading = member_heading(
|
||||
level,
|
||||
declaration,
|
||||
signature,
|
||||
root_kind,
|
||||
unit_class_open,
|
||||
error,
|
||||
)
|
||||
if root_kind == "unit" and level == 3:
|
||||
unit_class_open = heading.valid and heading.kind == "class"
|
||||
headings.append((index, heading))
|
||||
|
||||
return headings, h2_boundaries
|
||||
|
||||
|
||||
def collect_headings(lines):
|
||||
headings, _ = collect_headings_and_boundaries(lines)
|
||||
return headings
|
||||
|
||||
|
||||
def iter_api_entries(lines):
|
||||
headings, h2_boundaries = collect_headings_and_boundaries(lines)
|
||||
for index, (start, heading) in enumerate(headings):
|
||||
next_heading = (
|
||||
headings[index + 1][0]
|
||||
if index + 1 < len(headings)
|
||||
else len(lines)
|
||||
)
|
||||
boundary_index = bisect_right(h2_boundaries, start)
|
||||
next_h2 = (
|
||||
h2_boundaries[boundary_index]
|
||||
if boundary_index < len(h2_boundaries)
|
||||
else len(lines)
|
||||
)
|
||||
yield ApiEntry(heading, start, min(next_heading, next_h2))
|
||||
|
||||
|
||||
def slug(text):
|
||||
return re.sub(r"[^a-z0-9_]", "", text.casefold())
|
||||
@@ -1,23 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild the bundled TSL API function index from the codegen markdown tree.
|
||||
"""Rebuild the bundled TSL API index from the codegen markdown tree.
|
||||
|
||||
The markdown tree is the source of truth: each `## `sig`` / `### `sig`` heading
|
||||
is one function entry. The TSV is a derived product; regenerate it whenever the
|
||||
leaf Markdown changes rather than editing it by hand.
|
||||
The markdown tree is the source of truth. The TSV is a derived product;
|
||||
regenerate it whenever the leaf Markdown changes rather than editing it by
|
||||
hand.
|
||||
|
||||
Columns (tab-separated, LF line endings, UTF-8):
|
||||
name scope module signature page anchor tags summary
|
||||
kind binding visibility owner qualified_name
|
||||
- name: signature text up to the first '('
|
||||
- scope: first path segment under the codegen root (for example project)
|
||||
- module: second path segment for nested pages, else the flat file stem
|
||||
- signature: verbatim from the heading, backticks stripped
|
||||
- page: POSIX path relative to the codegen root
|
||||
- anchor: GitHub-style slug of the name (lowercased, chars outside
|
||||
[a-z0-9_] removed); per-page duplicate slugs get -1/-2 suffixes
|
||||
in document order, matching the rendered heading anchors.
|
||||
- anchor: top-level declarations use the historic name slug; typed members
|
||||
slug the complete visible API title. Per-page duplicate slugs get
|
||||
-1/-2 suffixes in document order.
|
||||
- tags: space-separated keywords from `<!-- tags: ... -->`
|
||||
- summary: first prose line under the entry heading, empty for table,
|
||||
heading, or standalone return-type lines
|
||||
- summary: first prose line under the entry heading
|
||||
- kind: function/class/method/property/field/constant/unit/variable
|
||||
- binding: instance/class/static/unit, or empty when not applicable
|
||||
- visibility: public/protected for class members; public for unit interface
|
||||
- owner: dot-separated containing API path, excluding the entry name
|
||||
- qualified_name: dot-separated stable API identity
|
||||
|
||||
Usage (run from repo root; --skill-dir is required):
|
||||
SKILL=skills/tsl-api-reference
|
||||
@@ -31,9 +36,22 @@ import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ENTRY_RE = re.compile(r"^#{2,3}(?!#)\s+`(.+?)`\s*$")
|
||||
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, slug
|
||||
|
||||
|
||||
RETURN_RE = re.compile(r"^返回[::]")
|
||||
DECLARATION_TYPE_RE = re.compile(
|
||||
r"^类型[::]\s*(function|class|unit)\s*$", re.IGNORECASE
|
||||
)
|
||||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
|
||||
VISIBILITY_RE = re.compile(
|
||||
r"^可见性[::]\s*`?(public|protected|private)`?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HEADER = [
|
||||
"name",
|
||||
"scope",
|
||||
@@ -43,29 +61,56 @@ HEADER = [
|
||||
"anchor",
|
||||
"tags",
|
||||
"summary",
|
||||
"kind",
|
||||
"binding",
|
||||
"visibility",
|
||||
"owner",
|
||||
"qualified_name",
|
||||
]
|
||||
|
||||
|
||||
def slug(name):
|
||||
"""GitHub-style anchor slug: lowercase, keep [a-z0-9_], drop the rest."""
|
||||
return re.sub(r"[^a-z0-9_]", "", name.lower())
|
||||
|
||||
|
||||
def extract_metadata(lines, heading_idx):
|
||||
"""Return tags and the first prose line under a function entry heading."""
|
||||
def extract_metadata(lines, heading_idx, end_idx=None):
|
||||
"""Return tags and the first prose line under one API heading."""
|
||||
tags = ""
|
||||
for line in lines[heading_idx + 1:]:
|
||||
summary = ""
|
||||
summary_open = True
|
||||
for line in lines[heading_idx + 1:end_idx]:
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
if DECLARATION_LINE_RE.fullmatch(text):
|
||||
continue
|
||||
tag_match = TAGS_RE.match(text)
|
||||
if tag_match:
|
||||
tags = " ".join(tag_match.group(1).split()).replace("\t", " ")
|
||||
continue
|
||||
if text.startswith("|") or text.startswith("#") or RETURN_RE.match(text):
|
||||
return tags, ""
|
||||
return tags, text.replace("\t", " ")
|
||||
return tags, ""
|
||||
if summary:
|
||||
continue
|
||||
if (
|
||||
text.startswith("|")
|
||||
or text.startswith("#")
|
||||
or RETURN_RE.match(text)
|
||||
or DECLARATION_TYPE_RE.match(text)
|
||||
):
|
||||
summary_open = False
|
||||
continue
|
||||
if summary_open:
|
||||
summary = text.replace("\t", " ")
|
||||
return tags, summary
|
||||
|
||||
|
||||
def extract_visibility(lines, start, end):
|
||||
for line in lines[start + 1:end]:
|
||||
match = VISIBILITY_RE.match(line.strip())
|
||||
if match:
|
||||
return match.group(1).casefold()
|
||||
return ""
|
||||
|
||||
|
||||
def next_anchor(base, seen):
|
||||
count = seen.get(base, 0)
|
||||
seen[base] = count + 1
|
||||
return base if count == 0 else f"{base}-{count}"
|
||||
|
||||
|
||||
def parse_page(codegen_root, md):
|
||||
@@ -75,18 +120,69 @@ def parse_page(codegen_root, md):
|
||||
seen = {}
|
||||
rows = []
|
||||
lines = md.read_text(encoding="utf-8").splitlines()
|
||||
for idx, line in enumerate(lines):
|
||||
m = ENTRY_RE.match(line)
|
||||
if not m:
|
||||
entries = list(iter_api_entries(lines))
|
||||
root_kind = ""
|
||||
root_name = ""
|
||||
unit_class_owner = ""
|
||||
|
||||
for entry in entries:
|
||||
heading = entry.heading
|
||||
if not heading.valid:
|
||||
continue
|
||||
sig = m.group(1)
|
||||
name = sig.split("(", 1)[0]
|
||||
base = slug(name)
|
||||
n = seen.get(base, 0)
|
||||
seen[base] = n + 1
|
||||
anchor = base if n == 0 else f"{base}-{n}"
|
||||
tags, summary = extract_metadata(lines, idx)
|
||||
rows.append([name, scope, module, sig, page, anchor, tags, summary])
|
||||
|
||||
if heading.level == 2:
|
||||
root_kind = heading.kind
|
||||
root_name = heading.name
|
||||
unit_class_owner = ""
|
||||
owner = ""
|
||||
anchor_base = slug(heading.name)
|
||||
elif root_kind == "class":
|
||||
owner = root_name
|
||||
anchor_base = slug(heading.visible_title)
|
||||
elif root_kind == "unit" and heading.level == 3:
|
||||
anchor_base = slug(heading.visible_title)
|
||||
owner = root_name
|
||||
unit_class_owner = (
|
||||
f"{root_name}.{heading.name}"
|
||||
if heading.kind == "class"
|
||||
else ""
|
||||
)
|
||||
elif root_kind == "unit" and heading.level == 4:
|
||||
owner = unit_class_owner
|
||||
anchor_base = slug(heading.visible_title)
|
||||
else:
|
||||
continue
|
||||
|
||||
qualified_name = (
|
||||
f"{owner}.{heading.name}" if owner else heading.name
|
||||
)
|
||||
if root_kind == "unit" and heading.level == 3:
|
||||
visibility = "public"
|
||||
elif (
|
||||
root_kind == "class" and heading.level == 3
|
||||
) or (root_kind == "unit" and heading.level == 4):
|
||||
visibility = extract_visibility(lines, entry.start, entry.end)
|
||||
else:
|
||||
visibility = ""
|
||||
|
||||
tags, summary = extract_metadata(lines, entry.start, entry.end)
|
||||
rows.append(
|
||||
[
|
||||
heading.name,
|
||||
scope,
|
||||
module,
|
||||
heading.signature,
|
||||
page,
|
||||
next_anchor(anchor_base, seen),
|
||||
tags,
|
||||
summary,
|
||||
heading.kind,
|
||||
heading.binding,
|
||||
visibility,
|
||||
owner,
|
||||
qualified_name,
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -120,7 +216,16 @@ def read_tsv(tsv_path):
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__.splitlines()[0],
|
||||
add_help=False,
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--help",
|
||||
action="help",
|
||||
help="show this help message and exit (no -h short option)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skill-dir",
|
||||
required=True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,20 @@
|
||||
"""Generate compliant TSL codegen markdown from a YAML/JSON entry file.
|
||||
|
||||
The recording format is one leaf page: a `module` title, a relative `path`, and
|
||||
a `functions` list. This script renders it to the markdown the codegen tree
|
||||
stores, matching tools/tsl-codegen/STANDARD.md.
|
||||
an ordered `declarations` list containing function, class, or unit entries. This
|
||||
script renders it to the markdown the codegen tree stores, matching
|
||||
tools/tsl-codegen/STANDARD.md.
|
||||
|
||||
Tables are emitted as valid Markdown with single-space padding. Prettier may be
|
||||
used optionally to align columns.
|
||||
Rendered Markdown is passed through the repository-pinned Prettier before it is
|
||||
written, keeping generated pages consistent with the existing codegen tree.
|
||||
|
||||
Input dispatch is by extension: .json parses with the stdlib (keeping the
|
||||
toolchain dependency-free); .yml/.yaml needs pyyaml. If pyyaml is missing the
|
||||
script says so and points at the JSON path.
|
||||
|
||||
Entry schema (per function):
|
||||
Function declaration fields:
|
||||
kind required `function`
|
||||
name required must match the signature name case-insensitively
|
||||
signature required verbatim, underscores/case untouched
|
||||
desc required description; may contain multiple lines
|
||||
tags optional list of Chinese keywords -> `<!-- tags: ... -->`
|
||||
@@ -27,11 +30,20 @@ Usage (run from repo root):
|
||||
python tools/tsl-codegen/scripts/generate.py entry.json \
|
||||
--scope my-project
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
PRETTIER_CONFIG = REPO_ROOT / ".prettierrc.json"
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -53,10 +65,7 @@ def resolve_format(path, fmt):
|
||||
return "json"
|
||||
if suffix in (".yml", ".yaml"):
|
||||
return "yaml"
|
||||
die(
|
||||
f"cannot infer format from extension '{suffix}'; "
|
||||
f"pass --format json|yaml"
|
||||
)
|
||||
die(f"cannot infer format from extension '{suffix}'; " f"pass --format json|yaml")
|
||||
|
||||
|
||||
def load_entries(path, fmt=None):
|
||||
@@ -96,6 +105,322 @@ def require(cond, msg):
|
||||
die(msg)
|
||||
|
||||
|
||||
def require_mapping(value, where):
|
||||
require(isinstance(value, dict), f"{where}: must be a mapping")
|
||||
|
||||
|
||||
def reject_unknown(mapping, allowed, where):
|
||||
unknown = sorted(set(mapping) - set(allowed))
|
||||
require(not unknown, f"{where}: unknown field(s): {', '.join(unknown)}")
|
||||
|
||||
|
||||
def non_empty_string(value, where):
|
||||
require(isinstance(value, str) and value.strip(), f"{where}: must be non-empty")
|
||||
|
||||
|
||||
def optional_draft_string(value, where):
|
||||
if value == "":
|
||||
return
|
||||
non_empty_string(value, where)
|
||||
|
||||
|
||||
def validate_tags(tags, where):
|
||||
if tags is None:
|
||||
return
|
||||
require(isinstance(tags, list) and tags, f"{where}: tags must be a non-empty list")
|
||||
for index, tag in enumerate(tags):
|
||||
non_empty_string(tag, f"{where}: tags[{index}]")
|
||||
|
||||
|
||||
def signature_names(signature, where):
|
||||
non_empty_string(signature, f"{where}: signature")
|
||||
left = signature.find("(")
|
||||
right = signature.rfind(")")
|
||||
require(left > 0 and right == len(signature) - 1, f"{where}: invalid signature")
|
||||
name = signature[:left]
|
||||
non_empty_string(name, f"{where}: signature name")
|
||||
raw = signature[left + 1 : right].strip()
|
||||
if not raw:
|
||||
return name, []
|
||||
names = [item.strip() for item in raw.split(",")]
|
||||
require(all(names), f"{where}: signature contains an empty parameter")
|
||||
require(len({item.casefold() for item in names}) == len(names), f"{where}: duplicate parameter name")
|
||||
return name, names
|
||||
|
||||
|
||||
def validate_values(values, where):
|
||||
require(isinstance(values, list) and values, f"{where}: values must be a non-empty list")
|
||||
for index, item in enumerate(values):
|
||||
item_where = f"{where}[{index}]"
|
||||
require_mapping(item, item_where)
|
||||
reject_unknown(item, {"value", "desc"}, item_where)
|
||||
require("value" in item, f"{item_where}: missing 'value'")
|
||||
non_empty_string(item.get("desc"), f"{item_where}: desc")
|
||||
|
||||
|
||||
def validate_params(params, expected_names, where):
|
||||
if not expected_names:
|
||||
require(not params, f"{where}: nullary signature must not have params")
|
||||
return
|
||||
require(isinstance(params, list), f"{where}: params must be a list")
|
||||
require(len(params) == len(expected_names), f"{where}: params do not match signature")
|
||||
actual_names = []
|
||||
for index, param in enumerate(params):
|
||||
param_where = f"{where}: params[{index}]"
|
||||
require_mapping(param, param_where)
|
||||
reject_unknown(param, {"name", "type", "desc", "optional", "values"}, param_where)
|
||||
name = param.get("name")
|
||||
non_empty_string(name, f"{param_where}: name")
|
||||
non_empty_string(param.get("type"), f"{param_where}: type")
|
||||
non_empty_string(param.get("desc"), f"{param_where}: desc")
|
||||
if "optional" in param:
|
||||
require(isinstance(param["optional"], bool), f"{param_where}: optional must be boolean")
|
||||
if "values" in param:
|
||||
validate_values(param["values"], f"{param_where}: values")
|
||||
actual_names.append(name)
|
||||
require(
|
||||
[name.casefold() for name in actual_names]
|
||||
== [name.casefold() for name in expected_names],
|
||||
f"{where}: params must follow signature order",
|
||||
)
|
||||
|
||||
|
||||
def validate_examples(examples, where):
|
||||
require(isinstance(examples, list) and examples, f"{where}: examples must be a non-empty list")
|
||||
for index, example in enumerate(examples):
|
||||
example_where = f"{where}: examples[{index}]"
|
||||
require_mapping(example, example_where)
|
||||
reject_unknown(example, {"desc", "code", "output"}, example_where)
|
||||
non_empty_string(example.get("desc"), f"{example_where}: desc")
|
||||
non_empty_string(example.get("code"), f"{example_where}: code")
|
||||
if "output" in example:
|
||||
non_empty_string(example["output"], f"{example_where}: output")
|
||||
|
||||
|
||||
def validate_function(fn, where, *, returns_required, extra_fields=()):
|
||||
require_mapping(fn, where)
|
||||
allowed = {
|
||||
"signature",
|
||||
"desc",
|
||||
"tags",
|
||||
"params",
|
||||
"returns",
|
||||
"example",
|
||||
"examples",
|
||||
*extra_fields,
|
||||
}
|
||||
reject_unknown(fn, allowed, where)
|
||||
name, names = signature_names(fn.get("signature"), where)
|
||||
non_empty_string(fn.get("desc"), f"{where}: desc")
|
||||
validate_tags(fn.get("tags"), where)
|
||||
validate_params(fn.get("params"), names, where)
|
||||
if returns_required:
|
||||
non_empty_string(fn.get("returns"), f"{where}: missing 'returns'")
|
||||
elif "returns" in fn:
|
||||
optional_draft_string(fn["returns"], f"{where}: returns")
|
||||
require(not ("example" in fn and "examples" in fn), f"{where}: use example or examples, not both")
|
||||
if "example" in fn:
|
||||
non_empty_string(fn["example"], f"{where}: example")
|
||||
if "examples" in fn:
|
||||
validate_examples(fn["examples"], where)
|
||||
return name
|
||||
|
||||
|
||||
def validate_class_member(member, where):
|
||||
require_mapping(member, where)
|
||||
kind = member.get("kind")
|
||||
require(kind in {"method", "property", "field", "constant"}, f"{where}: unknown kind '{kind}'")
|
||||
non_empty_string(member.get("name"), f"{where}: name")
|
||||
visibility = member.get("visibility")
|
||||
require(visibility in {"public", "protected"}, f"{where}: visibility must be public or protected")
|
||||
non_empty_string(member.get("desc"), f"{where}: desc")
|
||||
validate_tags(member.get("tags"), where)
|
||||
|
||||
if kind == "method":
|
||||
reject_unknown(
|
||||
member,
|
||||
{
|
||||
"kind", "name", "visibility", "binding", "signature", "desc",
|
||||
"tags", "params", "returns", "modifiers", "example", "examples",
|
||||
},
|
||||
where,
|
||||
)
|
||||
require(member.get("binding") in {"instance", "class"}, f"{where}: invalid binding")
|
||||
parsed_name = validate_function(
|
||||
member,
|
||||
where,
|
||||
returns_required=False,
|
||||
extra_fields={"kind", "name", "visibility", "binding", "modifiers"},
|
||||
)
|
||||
require(parsed_name.casefold() == member["name"].casefold(), f"{where}: name and signature differ")
|
||||
if "modifiers" in member:
|
||||
modifiers = member["modifiers"]
|
||||
require(isinstance(modifiers, list), f"{where}: modifiers must be a list")
|
||||
allowed = {"overload", "virtual", "override"}
|
||||
require(all(item in allowed for item in modifiers), f"{where}: invalid modifier")
|
||||
require(len(set(modifiers)) == len(modifiers), f"{where}: duplicate modifier")
|
||||
return
|
||||
|
||||
common = {"kind", "name", "visibility", "desc", "tags"}
|
||||
if kind == "property":
|
||||
reject_unknown(member, common | {"type", "params", "access"}, where)
|
||||
if "type" in member:
|
||||
optional_draft_string(member["type"], f"{where}: type")
|
||||
require(member.get("access") in {"read", "write", "readwrite"}, f"{where}: invalid access")
|
||||
params = member.get("params")
|
||||
if params:
|
||||
expected = [param.get("name") for param in params]
|
||||
validate_params(params, expected, where)
|
||||
return
|
||||
if kind == "field":
|
||||
reject_unknown(member, common | {"type", "static"}, where)
|
||||
non_empty_string(member.get("type"), f"{where}: type")
|
||||
if "static" in member:
|
||||
require(isinstance(member["static"], bool), f"{where}: static must be boolean")
|
||||
return
|
||||
reject_unknown(member, common | {"type", "value", "static"}, where)
|
||||
require("value" in member and member["value"] is not None, f"{where}: missing 'value'")
|
||||
if isinstance(member["value"], str):
|
||||
non_empty_string(member["value"], f"{where}: value")
|
||||
if "type" in member:
|
||||
optional_draft_string(member["type"], f"{where}: type")
|
||||
if "static" in member:
|
||||
require(isinstance(member["static"], bool), f"{where}: static must be boolean")
|
||||
|
||||
|
||||
def validate_class(cls, where):
|
||||
require_mapping(cls, where)
|
||||
reject_unknown(cls, {"kind", "name", "desc", "tags", "bases", "members"}, where)
|
||||
require(cls.get("kind") == "class", f"{where}: kind must be class")
|
||||
non_empty_string(cls.get("name"), f"{where}: name")
|
||||
non_empty_string(cls.get("desc"), f"{where}: desc")
|
||||
validate_tags(cls.get("tags"), where)
|
||||
if "bases" in cls:
|
||||
require(isinstance(cls["bases"], list), f"{where}: bases must be a list")
|
||||
for index, base in enumerate(cls["bases"]):
|
||||
non_empty_string(base, f"{where}: bases[{index}]")
|
||||
require(isinstance(cls.get("members"), list), f"{where}: members must be a list")
|
||||
for index, member in enumerate(cls["members"]):
|
||||
validate_class_member(member, f"{where}: members[{index}]")
|
||||
|
||||
|
||||
def validate_unit_member(member, where):
|
||||
require_mapping(member, where)
|
||||
kind = member.get("kind")
|
||||
require(kind in {"function", "variable", "constant", "class"}, f"{where}: unknown kind '{kind}'")
|
||||
if kind == "class":
|
||||
validate_class(member, where)
|
||||
return
|
||||
non_empty_string(member.get("name"), f"{where}: name")
|
||||
non_empty_string(member.get("desc"), f"{where}: desc")
|
||||
validate_tags(member.get("tags"), where)
|
||||
if kind == "function":
|
||||
parsed_name = validate_function(
|
||||
member,
|
||||
where,
|
||||
returns_required=True,
|
||||
extra_fields={"kind", "name"},
|
||||
)
|
||||
require(parsed_name.casefold() == member["name"].casefold(), f"{where}: name and signature differ")
|
||||
return
|
||||
common = {"kind", "name", "desc", "tags", "type"}
|
||||
if kind == "variable":
|
||||
reject_unknown(member, common, where)
|
||||
non_empty_string(member.get("type"), f"{where}: type")
|
||||
return
|
||||
reject_unknown(member, common | {"value"}, where)
|
||||
require("value" in member and member["value"] is not None, f"{where}: missing 'value'")
|
||||
if isinstance(member["value"], str):
|
||||
non_empty_string(member["value"], f"{where}: value")
|
||||
if "type" in member:
|
||||
optional_draft_string(member["type"], f"{where}: type")
|
||||
|
||||
|
||||
def validate_unit(unit, where):
|
||||
require_mapping(unit, where)
|
||||
reject_unknown(unit, {"kind", "name", "desc", "tags", "members"}, where)
|
||||
require(unit.get("kind") == "unit", f"{where}: kind must be unit")
|
||||
non_empty_string(unit.get("name"), f"{where}: name")
|
||||
non_empty_string(unit.get("desc"), f"{where}: desc")
|
||||
validate_tags(unit.get("tags"), where)
|
||||
require(
|
||||
isinstance(unit.get("members"), list),
|
||||
f"{where}: members must be a list",
|
||||
)
|
||||
for index, member in enumerate(unit["members"]):
|
||||
validate_unit_member(member, f"{where}: members[{index}]")
|
||||
|
||||
|
||||
def validate_top_level_function(declaration, where):
|
||||
require(
|
||||
declaration.get("kind") == "function",
|
||||
f"{where}: kind must be function",
|
||||
)
|
||||
non_empty_string(declaration.get("name"), f"{where}: name")
|
||||
parsed_name = validate_function(
|
||||
declaration,
|
||||
where,
|
||||
returns_required=True,
|
||||
extra_fields={"kind", "name"},
|
||||
)
|
||||
require(
|
||||
parsed_name.casefold() == declaration["name"].casefold(),
|
||||
f"{where}: name and signature differ",
|
||||
)
|
||||
|
||||
|
||||
def validate_declaration(declaration, where):
|
||||
require_mapping(declaration, where)
|
||||
kind = declaration.get("kind")
|
||||
require(
|
||||
kind in {"function", "class", "unit"},
|
||||
f"{where}: unknown kind '{kind}'",
|
||||
)
|
||||
if kind == "function":
|
||||
validate_top_level_function(declaration, where)
|
||||
elif kind == "class":
|
||||
validate_class(declaration, where)
|
||||
else:
|
||||
validate_unit(declaration, where)
|
||||
|
||||
|
||||
def validate_declaration_uniqueness(declarations):
|
||||
function_signatures = set()
|
||||
class_names = set()
|
||||
unit_names = set()
|
||||
for index, declaration in enumerate(declarations):
|
||||
where = f"declarations[{index}]"
|
||||
kind = declaration["kind"]
|
||||
if kind == "function":
|
||||
key = declaration["signature"].casefold()
|
||||
require(
|
||||
key not in function_signatures,
|
||||
f"{where}: duplicate function signature",
|
||||
)
|
||||
function_signatures.add(key)
|
||||
continue
|
||||
names = class_names if kind == "class" else unit_names
|
||||
key = declaration["name"].casefold()
|
||||
require(key not in names, f"{where}: duplicate {kind} name")
|
||||
names.add(key)
|
||||
|
||||
|
||||
def validate_page(data):
|
||||
require_mapping(data, "input root")
|
||||
reject_unknown(data, {"module", "path", "declarations"}, "input root")
|
||||
non_empty_string(data.get("module"), "input root: module")
|
||||
non_empty_string(data.get("path"), "input root: path")
|
||||
declarations = data.get("declarations")
|
||||
require(
|
||||
isinstance(declarations, list) and declarations,
|
||||
"input root: declarations must be a non-empty list",
|
||||
)
|
||||
for index, declaration in enumerate(declarations):
|
||||
validate_declaration(declaration, f"declarations[{index}]")
|
||||
validate_declaration_uniqueness(declarations)
|
||||
return declarations
|
||||
|
||||
|
||||
def param_desc(param, where):
|
||||
"""Description column text: prepend `可选。` for optional params."""
|
||||
desc = param.get("desc")
|
||||
@@ -120,14 +445,17 @@ def render_param_table(params, where):
|
||||
return lines
|
||||
|
||||
|
||||
def render_enum_sections(params):
|
||||
"""`**name 取值**` sections for every param carrying a `values` list."""
|
||||
def render_enum_sections(params, heading_level=None):
|
||||
"""Render value sections, preserving legacy function-page headings."""
|
||||
lines = []
|
||||
for param in params:
|
||||
values = param.get("values")
|
||||
if not values:
|
||||
continue
|
||||
lines.append(f"**{param['name']} 取值**")
|
||||
if heading_level is None:
|
||||
lines.append(f"**{param['name']} 取值**")
|
||||
else:
|
||||
lines.append(f"{'#' * heading_level} `{param['name']}` 取值")
|
||||
lines.append("")
|
||||
for item in values:
|
||||
lines.append(f"- `{item['value']}` — {item['desc']}")
|
||||
@@ -135,23 +463,89 @@ def render_enum_sections(params):
|
||||
return lines
|
||||
|
||||
|
||||
def render_function(fn, index):
|
||||
"""Render one function entry to a list of lines (no trailing blank)."""
|
||||
where = f"functions[{index}]"
|
||||
sig = fn.get("signature")
|
||||
require(sig, f"{where}: missing 'signature'")
|
||||
desc = fn.get("desc")
|
||||
require(desc, f"{where} ({sig}): missing 'desc'")
|
||||
returns = fn.get("returns")
|
||||
require(returns, f"{where} ({sig}): missing 'returns'")
|
||||
def render_examples(fn, heading_level):
|
||||
lines = []
|
||||
if "examples" in fn:
|
||||
lines.extend([f"{'#' * heading_level} 示例", ""])
|
||||
for index, example in enumerate(fn["examples"], start=1):
|
||||
lines.append(f"范例{index:02d}:{example['desc']}")
|
||||
lines.append("")
|
||||
lines.append("```tsl")
|
||||
lines.extend(example["code"].rstrip("\n").split("\n"))
|
||||
output = example.get("output")
|
||||
if output is not None:
|
||||
output_lines = output.split("\n")
|
||||
if len(output_lines) == 1:
|
||||
lines.append(f"// 输出:{output_lines[0]}")
|
||||
else:
|
||||
lines.append("// 输出:")
|
||||
lines.extend(f"// {line}" if line else "//" for line in output_lines)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines
|
||||
if fn.get("example"):
|
||||
return [
|
||||
f"{'#' * heading_level} 示例",
|
||||
"",
|
||||
"```tsl",
|
||||
*fn["example"].rstrip("\n").split("\n"),
|
||||
"```",
|
||||
]
|
||||
return []
|
||||
|
||||
lines = [f"## `{sig}`"]
|
||||
tags = fn.get("tags")
|
||||
|
||||
def render_intro(heading, declaration, item):
|
||||
lines = [heading, "", f"声明:{declaration}", "", item["desc"], ""]
|
||||
tags = item.get("tags")
|
||||
if tags:
|
||||
lines.append(f"<!-- tags: {' '.join(str(t) for t in tags)} -->")
|
||||
lines.append("")
|
||||
lines.append(desc)
|
||||
lines.append("")
|
||||
lines.extend([f"<!-- tags: {' '.join(tags)} -->", ""])
|
||||
return lines
|
||||
|
||||
|
||||
def render_callable(
|
||||
fn,
|
||||
signature,
|
||||
declaration,
|
||||
where,
|
||||
*,
|
||||
level,
|
||||
returns_required,
|
||||
show_visibility,
|
||||
):
|
||||
validate_function(
|
||||
fn,
|
||||
where,
|
||||
returns_required=returns_required,
|
||||
extra_fields={"kind", "name", "visibility", "binding", "modifiers"},
|
||||
)
|
||||
lines = render_intro(
|
||||
f"{'#' * level} `{signature}`", declaration, fn
|
||||
)
|
||||
if show_visibility:
|
||||
lines.extend([f"可见性:`{fn['visibility']}`", ""])
|
||||
if fn.get("modifiers"):
|
||||
rendered = "、".join(f"`{item}`" for item in fn["modifiers"])
|
||||
lines.extend([f"修饰符:{rendered}", ""])
|
||||
params = fn.get("params") or []
|
||||
if params:
|
||||
lines.extend(render_param_table(params, where))
|
||||
lines.append("")
|
||||
lines.extend(render_enum_sections(params, level + 1))
|
||||
if fn.get("returns"):
|
||||
lines.append(f"返回:{fn['returns']}")
|
||||
example_lines = render_examples(fn, level + 1)
|
||||
if example_lines:
|
||||
lines.extend(["", *example_lines])
|
||||
return lines
|
||||
|
||||
|
||||
def render_top_level_function(fn, index):
|
||||
"""Render one function entry to a list of lines (no trailing blank)."""
|
||||
where = f"declarations[{index}]"
|
||||
sig = fn["signature"]
|
||||
lines = render_intro(f"## `{sig}`", "function", fn)
|
||||
|
||||
params = fn.get("params") or []
|
||||
if params:
|
||||
@@ -159,34 +553,168 @@ def render_function(fn, index):
|
||||
lines.append("")
|
||||
lines.extend(render_enum_sections(params))
|
||||
|
||||
lines.append(f"返回:{returns}")
|
||||
|
||||
example = fn.get("example")
|
||||
if example:
|
||||
lines.append("")
|
||||
lines.append("### 示例")
|
||||
lines.append("")
|
||||
lines.append("```tsl")
|
||||
lines.extend(example.rstrip("\n").split("\n"))
|
||||
lines.append("```")
|
||||
lines.append(f"返回:{fn['returns']}")
|
||||
examples = render_examples(fn, 3)
|
||||
if examples:
|
||||
lines.extend(["", *examples])
|
||||
return lines
|
||||
|
||||
|
||||
def render_page(data):
|
||||
"""Render a whole leaf page: H1 + every function entry."""
|
||||
require(isinstance(data, dict), "input root must be a mapping")
|
||||
module = data.get("module")
|
||||
require(module, "input missing 'module'")
|
||||
functions = data.get("functions")
|
||||
require(functions, "input missing non-empty 'functions'")
|
||||
def render_class_member(member, level, where):
|
||||
kind = member["kind"]
|
||||
if kind == "method":
|
||||
declaration = (
|
||||
"class function"
|
||||
if member["binding"] == "class"
|
||||
else "function"
|
||||
)
|
||||
return render_callable(
|
||||
member,
|
||||
member["signature"],
|
||||
declaration,
|
||||
where,
|
||||
level=level,
|
||||
returns_required=False,
|
||||
show_visibility=True,
|
||||
)
|
||||
static_prefix = "static " if member.get("static") else ""
|
||||
declaration = {
|
||||
"property": "property",
|
||||
"field": f"{static_prefix}field",
|
||||
"constant": f"{static_prefix}const",
|
||||
}[kind]
|
||||
signature = member["name"]
|
||||
if kind == "property" and member.get("params"):
|
||||
signature += "(" + ", ".join(param["name"] for param in member["params"]) + ")"
|
||||
lines = render_intro(
|
||||
f"{'#' * level} `{signature}`", declaration, member
|
||||
)
|
||||
lines.extend([f"可见性:`{member['visibility']}`", ""])
|
||||
if kind == "property":
|
||||
if member.get("type"):
|
||||
lines.extend([f"类型:{member['type']}", ""])
|
||||
access = {"read": "read", "write": "write", "readwrite": "read / write"}[member["access"]]
|
||||
lines.append(f"访问:{access}")
|
||||
params = member.get("params") or []
|
||||
if params:
|
||||
lines.extend(["", *render_param_table(params, where), ""])
|
||||
lines.extend(render_enum_sections(params, level + 1))
|
||||
elif kind == "field":
|
||||
lines.append(f"类型:{member['type']}")
|
||||
else:
|
||||
if member.get("type"):
|
||||
lines.extend([f"类型:{member['type']}", ""])
|
||||
lines.append(f"值:`{render_scalar(member['value'])}`")
|
||||
return lines
|
||||
|
||||
out = [f"# {module}", ""]
|
||||
for index, fn in enumerate(functions):
|
||||
out.extend(render_function(fn, index))
|
||||
|
||||
def render_class(cls, level, where, *, page_root):
|
||||
lines = render_intro(
|
||||
f"{'#' * level} `{cls['name']}`", "class", cls
|
||||
)
|
||||
if cls.get("bases"):
|
||||
lines.extend(["父类:" + "、".join(f"`{base}`" for base in cls["bases"]), ""])
|
||||
for index, member in enumerate(cls["members"]):
|
||||
lines.extend(render_class_member(member, level + 1, f"{where}: members[{index}]"))
|
||||
lines.append("")
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def render_unit_member(member, index, unit_where):
|
||||
where = f"{unit_where}: members[{index}]"
|
||||
kind = member["kind"]
|
||||
if kind == "function":
|
||||
return render_callable(
|
||||
member,
|
||||
member["signature"],
|
||||
"function",
|
||||
where,
|
||||
level=3,
|
||||
returns_required=True,
|
||||
show_visibility=False,
|
||||
)
|
||||
if kind == "class":
|
||||
return render_class(member, 3, where, page_root=False)
|
||||
declaration = "var" if kind == "variable" else "const"
|
||||
lines = render_intro(
|
||||
f"### `{member['name']}`", declaration, member
|
||||
)
|
||||
if kind == "variable":
|
||||
lines.append(f"类型:{member['type']}")
|
||||
else:
|
||||
if member.get("type"):
|
||||
lines.extend([f"类型:{member['type']}", ""])
|
||||
lines.append(f"值:`{render_scalar(member['value'])}`")
|
||||
return lines
|
||||
|
||||
|
||||
def render_unit(unit, where):
|
||||
lines = render_intro(f"## `{unit['name']}`", "unit", unit)
|
||||
for index, member in enumerate(unit["members"]):
|
||||
lines.extend(render_unit_member(member, index, where))
|
||||
lines.append("")
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def render_scalar(value):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def render_declaration(declaration, index):
|
||||
kind = declaration["kind"]
|
||||
where = f"declarations[{index}]"
|
||||
if kind == "function":
|
||||
return render_top_level_function(declaration, index)
|
||||
if kind == "class":
|
||||
return render_class(declaration, 2, where, page_root=True)
|
||||
return render_unit(declaration, where)
|
||||
|
||||
|
||||
def render_page(data):
|
||||
"""Validate and render a whole leaf page."""
|
||||
declarations = validate_page(data)
|
||||
out = [f"# {data['module']}", ""]
|
||||
for index, declaration in enumerate(declarations):
|
||||
out.extend(render_declaration(declaration, index))
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def format_markdown(text):
|
||||
"""Format generated Markdown with the repository-pinned Prettier."""
|
||||
npx = shutil.which("npx")
|
||||
if not npx:
|
||||
die("未找到 Prettier;请先在仓库根目录运行 `npm install`")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
npx,
|
||||
"--no-install",
|
||||
"prettier",
|
||||
"--config",
|
||||
str(PRETTIER_CONFIG),
|
||||
"--parser",
|
||||
"markdown",
|
||||
],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "未知错误"
|
||||
die(f"Prettier 格式化失败:{detail}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def output_path(data, scope):
|
||||
"""Build the leaf-page destination from the recording file's relative path."""
|
||||
relative = data.get("path")
|
||||
@@ -204,10 +732,37 @@ def output_path(data, scope):
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
"""Atomically replace path and remove the temporary file on failure."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
finally:
|
||||
if temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="从 YAML/JSON 录入文件生成 TSL 函数文档")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="从 YAML/JSON 录入文件生成 TSL API 文档",
|
||||
add_help=False,
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--help",
|
||||
action="help",
|
||||
help="显示本帮助并退出(不提供 -h 短选项)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
metavar="INPUT_FILE",
|
||||
@@ -231,10 +786,9 @@ def main(argv=None):
|
||||
die(f"input not found: {in_path}")
|
||||
data = load_entries(in_path, args.format)
|
||||
|
||||
text = render_page(data)
|
||||
text = format_markdown(render_page(data))
|
||||
out_path = output_path(data, args.scope)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(text, encoding="utf-8", newline="\n")
|
||||
atomic_write(out_path, text)
|
||||
print(
|
||||
f"wrote {out_path}",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint TSL codegen function-doc markdown against the house standard.
|
||||
"""Lint TSL codegen API Markdown against the house standard.
|
||||
|
||||
The standard lives in tools/tsl-codegen/STANDARD.md.
|
||||
Each `## `sig`` / `### `sig`` heading starts one function entry. Rules split
|
||||
into hard errors (CI-blocking) and soft warnings (style
|
||||
convergence over the ~12k existing entries).
|
||||
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:
|
||||
- missing/empty description (first prose line after the signature)
|
||||
- missing `返回:类型`
|
||||
- signature has parameters but the entry has no parameter table
|
||||
- signature has no parameters but a parameter table is present
|
||||
- parameter table header is not the fixed 参数 / 类型 / 说明 三列
|
||||
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 `可选。`
|
||||
@@ -29,12 +25,26 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Entry heading: `## `sig`` or `### `sig``. Matches the index generator's rule
|
||||
# so the linter and the tsv agree on what a function entry is.
|
||||
ENTRY_RE = re.compile(r"^(#{2,3})(?!#)\s+`(.+?)`\s*$")
|
||||
RETURN_RE = re.compile(r"^返回[::]")
|
||||
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"(?<!\\)\|")
|
||||
@@ -43,20 +53,6 @@ SEP_CELL_RE = re.compile(r"^:?-+:?$")
|
||||
PARAM_HEADER = ["参数", "类型", "说明"]
|
||||
|
||||
|
||||
def iter_entries(lines):
|
||||
"""Yield (start, end, signature): each entry spans one signature heading
|
||||
to the next. Category headings without backticks fall to the tail of the
|
||||
preceding entry (harmless — checks anchor on the entry's head)."""
|
||||
starts = [
|
||||
(idx, m.group(2))
|
||||
for idx, line in enumerate(lines)
|
||||
if (m := ENTRY_RE.match(line))
|
||||
]
|
||||
for i, (start, sig) in enumerate(starts):
|
||||
end = starts[i + 1][0] if i + 1 < len(starts) else len(lines)
|
||||
yield start, end, sig
|
||||
|
||||
|
||||
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
|
||||
@@ -82,6 +78,16 @@ def has_params(sig):
|
||||
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())
|
||||
@@ -130,16 +136,38 @@ def find_description(body):
|
||||
stripped = raw.strip()
|
||||
if not stripped or in_fence:
|
||||
continue
|
||||
if stripped.startswith("<!--"): # tags or other comment: skip
|
||||
if DECLARATION_LINE_RE.fullmatch(stripped):
|
||||
continue
|
||||
if stripped.startswith("|") or stripped.startswith("#") \
|
||||
or RETURN_RE.match(stripped):
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -155,10 +183,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
RETURN_RE.match(raw.strip())
|
||||
for _, raw, in_fence in body if not in_fence
|
||||
)
|
||||
if not has_return:
|
||||
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)
|
||||
@@ -176,6 +233,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
"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:
|
||||
@@ -195,14 +285,211 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
"空的 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 start, end, sig in iter_entries(lines):
|
||||
check_entry(display, lines, start, end, sig, findings)
|
||||
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):
|
||||
@@ -219,7 +506,16 @@ def gather_targets(paths, root):
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="校验 Markdown 文件或目录")
|
||||
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="要递归校验的目录")
|
||||
|
||||
Reference in New Issue
Block a user