@@ -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