Files
csh 13be5ea0aa feat(tsl-api-reference): index class and unit APIs
Migrate reference pages to declaration-line storage and rebuild the
13-column index.

Add qualified member lookup plus regression and end-to-end coverage.
2026-07-29 15:47:46 +08:00

458 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Query bundled TSL API reference data."""
import argparse
import re
import sys
from pathlib import Path
TOP_LEVEL_RE = re.compile(r"^##(?!#)\s+`(.+?)`\s*$")
PLAIN_H2_RE = re.compile(r"^##(?!#)\s+")
BARE_ENTRY_RE = re.compile(r"^(#{3,4})(?!#)\s+`(.+?)`\s*$")
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+")
H3_RE = re.compile(r"^###(?!#)\s+")
DECLARATION_RE = re.compile(r"^声明:(.*?)\s*$")
TOP_LEVEL_TYPES = {"function", "class", "unit"}
FENCE_RE = re.compile(r"^\s*(```|~~~)")
CLASS_MEMBER_LABELS = {
"function",
"class function",
"property",
"field",
"static field",
"const",
"static const",
}
UNIT_DIRECT_LABELS = {"function", "var", "const", "class"}
SKILL_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_TSV = SKILL_ROOT / "data" / "function_index.tsv"
DEFAULT_CODEGEN_ROOT = SKILL_ROOT / "references" / "codegen"
REQUIRED_COLUMNS = (
"name",
"scope",
"module",
"signature",
"page",
"anchor",
"tags",
"summary",
)
OPTIONAL_COLUMNS = (
"kind",
"binding",
"visibility",
"owner",
"qualified_name",
)
HELP_EPILOG = """\
检索分两步,--kw 的摘要不能直接作为 API 事实:
1. 先按关键词取候选(只有摘要表格,不含条目正文)
lookup.py --kw 数组 去重
2. 再按候选里的 qualified_name 取回条目正文
lookup.py --name arrDropDuplicate
已知确切名称时可以直接用 --name,跳过第 1 步。
退出码:
0 取回成功;--name 无匹配也是 0(打印提示,不算错误)
1 function_index.tsv 缺失、格式错误,或候选指向的条目正文找不到
2 参数不合法(缺少动作、空值、--limit 小于 1、使用短选项或缩写)
"""
def non_empty(value):
if not value.strip():
raise argparse.ArgumentTypeError("must not be empty")
return value
def find_tsv(start):
"""Walk upward from start and find a bundled or legacy function_index.tsv."""
for directory in [start, *start.parents]:
candidates = [
directory / "data" / "function_index.tsv",
directory / "function_index.tsv",
directory / "docs" / "tsl" / "codegen" / "function_index.tsv",
]
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def load_rows(tsv_path):
lines = tsv_path.read_text(encoding="utf-8").splitlines()
if not lines:
return []
header = lines[0].split("\t")
missing = [column for column in REQUIRED_COLUMNS if column not in header]
if missing:
raise ValueError(f"missing required columns: {', '.join(missing)}")
if len(header) != len(set(header)):
raise ValueError("duplicate column names")
rows = []
for line_number, line in enumerate(lines[1:], start=2):
if not line.strip():
continue
values = line.split("\t")
if len(values) != len(header):
raise ValueError(
f"line {line_number} has {len(values)} columns; "
f"expected {len(header)}"
)
row = dict(zip(header, values))
for column in OPTIONAL_COLUMNS:
row.setdefault(column, "")
rows.append(row)
return rows
def search_exact(rows, name):
key = name.casefold()
return [
row
for row in rows
if key
in {
row.get("name", "").casefold(),
row.get("qualified_name", "").casefold(),
}
]
def search_keyword(rows, terms):
lowered_terms = [term.lower() for term in terms]
matches = []
for row in rows:
haystack = " ".join(
[
row.get("name", ""),
row.get("signature", ""),
row.get("module", ""),
row.get("tags", ""),
row.get("summary", ""),
row.get("kind", ""),
row.get("binding", ""),
row.get("visibility", ""),
row.get("owner", ""),
row.get("qualified_name", ""),
]
).casefold()
if all(term in haystack for term in lowered_terms):
matches.append(row)
return matches
def slug(text):
return re.sub(r"[^a-z0-9_]", "", text.casefold())
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 top_level_header_end(lines, start, flags):
for index in range(start + 1, len(lines)):
if flags[index]:
continue
if HEADING_RE.match(lines[index]):
return index
return len(lines)
def declaration_type(lines, start, end, flags):
for index in range(start + 1, end):
if flags[index]:
continue
stripped = lines[index].strip()
if not stripped:
continue
match = DECLARATION_RE.fullmatch(stripped)
return match.group(1).strip().casefold() if match else ""
return ""
def markdown_heading(
lines, index, root_kind, unit_class_open, flags
):
"""Return level, visible title, signature, and anchor base for one API."""
bare = BARE_ENTRY_RE.match(lines[index])
if not bare:
return None
level = len(bare.group(1))
signature = bare.group(2)
end = top_level_header_end(lines, index, flags)
declaration = declaration_type(lines, index, end, flags)
if declaration == "static function":
return None
if root_kind == "class":
if level != 3 or declaration not in CLASS_MEMBER_LABELS:
return None
elif root_kind == "unit":
if level == 3 and declaration not in UNIT_DIRECT_LABELS:
return None
if level == 4 and (
not unit_class_open or declaration not in CLASS_MEMBER_LABELS
):
return None
if level not in {3, 4}:
return None
else:
return None
return level, signature, signature, slug(signature)
def iter_markdown_api_headings(lines):
flags = fence_flags(lines)
root_kind = ""
unit_class_open = False
for index, line in enumerate(lines):
if flags[index]:
continue
if PLAIN_H2_RE.match(line):
root_kind = ""
unit_class_open = False
top_level = TOP_LEVEL_RE.match(line)
if not top_level:
continue
signature = top_level.group(1)
end = top_level_header_end(lines, index, flags)
root_kind = declaration_type(lines, index, end, flags)
if root_kind:
yield index, (
2,
signature,
signature,
slug(signature.split("(", 1)[0]),
)
continue
if not root_kind:
continue
if root_kind == "unit" and H3_RE.match(line):
unit_class_open = False
bare = BARE_ENTRY_RE.match(line)
if root_kind == "unit" and bare and len(bare.group(1)) == 3:
end = top_level_header_end(lines, index, flags)
unit_class_open = (
declaration_type(lines, index, end, flags) == "class"
)
heading = markdown_heading(
lines, index, root_kind, unit_class_open, flags
)
if heading:
yield index, heading
def slice_entry(codegen_root, page, signature, anchor):
md = codegen_root / page
if not md.is_file():
return ""
lines = md.read_text(encoding="utf-8").splitlines()
start = None
seen_anchors = {}
for idx, heading in iter_markdown_api_headings(lines):
level, _, entry_signature, base_anchor = heading
occurrence = seen_anchors.get(base_anchor, 0)
seen_anchors[base_anchor] = occurrence + 1
entry_anchor = base_anchor if occurrence == 0 else f"{base_anchor}-{occurrence}"
if entry_anchor == anchor and entry_signature == signature:
start = idx
start_level = level
break
if start is None:
return ""
end = len(lines)
flags = fence_flags(lines)
for idx in range(start + 1, len(lines)):
if flags[idx]:
continue
heading_match = HEADING_RE.match(lines[idx])
if heading_match and len(heading_match.group(1)) <= start_level:
end = idx
break
block = lines[start:end]
while block and not block[-1].strip():
block.pop()
return "\n".join(block)
def declaration_term(row):
kind = row.get("kind", "") or "function"
binding = row.get("binding", "")
mapped = {
("method", "instance"): "function",
("method", "class"): "class function",
("field", "static"): "static field",
("constant", "static"): "static const",
}.get((kind, binding))
if mapped:
return mapped
return {
"constant": "const",
"variable": "var",
}.get(kind, kind)
def format_row(row):
return "\t".join(
[
row.get("qualified_name", "") or row.get("name", ""),
declaration_term(row),
row.get("signature", ""),
f"{row.get('page', '')}#{row.get('anchor', '')}",
row.get("summary", ""),
]
)
def codegen_root_for_tsv(tsv_path):
if tsv_path.parent.name == "data":
candidate = tsv_path.parent.parent / "references" / "codegen"
if candidate.is_dir():
return candidate
if tsv_path.parent.name == "codegen":
return tsv_path.parent
candidate = tsv_path.parent / "references" / "codegen"
if candidate.is_dir():
return candidate
return DEFAULT_CODEGEN_ROOT
def default_tsv_path():
if DEFAULT_TSV.is_file():
return DEFAULT_TSV
return find_tsv(Path.cwd())
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="检索 TSL API 参考数据,取回 API 的签名、参数与返回值事实。",
epilog=HELP_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
# 只接受完整拼写的长选项:不提供 -h 短选项,也不接受 --na / --li 这类
# 缩写。缩写打错时会静默命中另一个选项并以 rc=0 返回,看不出写错了。
add_help=False,
allow_abbrev=False,
)
parser.add_argument(
"--help",
action="help",
help="显示本帮助并退出(不提供 -h 短选项)",
)
group = parser.add_argument_group("动作(必选其一)").add_mutually_exclusive_group(
required=True
)
group.add_argument(
"--name",
type=non_empty,
help="按 API 名称精确取回条目正文;同时匹配简单名称与 qualified_name"
"大小写不敏感。简单成员名会返回所有 owner 下的同名 API"
"用完整限定名(如 DemoUnit.Document.Save)缩小到指定 class/unit。"
"这是唯一的事实来源",
)
group.add_argument(
"--kw",
nargs="+",
type=non_empty,
help="按关键词检索候选清单,多个词是 AND 关系(全部命中才返回);"
"逐行搜索 name、signature、module、tags、summary、kind、binding、"
"visibility、owner 和 qualified_name 这些列的拼接文本,"
"大小写不敏感且按子串匹配。只返回摘要表格,不含条目正文",
)
parser.add_argument(
"--tsv",
metavar="PATH",
help="显式指定 function_index.tsv;默认使用本 skill 内置的 data/function_index.tsv",
)
parser.add_argument(
"--limit",
type=int,
default=50,
metavar="N",
help="--kw 输出的候选条数上限,取值 >= 1(默认 %(default)s);"
"--name 取回正文时不受该上限影响",
)
args = parser.parse_args(argv)
if args.limit < 1:
parser.error("--limit must be >= 1")
tsv_path = Path(args.tsv) if args.tsv else default_tsv_path()
if not tsv_path or not tsv_path.is_file():
print(
"ERROR: function_index.tsv not found; reinstall tsl-api-reference "
"or pass --tsv PATH",
file=sys.stderr,
)
return 1
try:
rows = load_rows(tsv_path)
except (OSError, UnicodeError, ValueError) as error:
print(f"ERROR: failed to load {tsv_path}: {error}", file=sys.stderr)
return 1
if not rows:
print(
f"ERROR: {tsv_path} has no entries; reinstall tsl-api-reference "
"or pass --tsv PATH",
file=sys.stderr,
)
return 1
codegen_root = codegen_root_for_tsv(tsv_path)
if args.name is not None:
matches = search_exact(rows, args.name)
if not matches:
print(f"No TSL API named '{args.name}'. Try: --kw <中文关键词>")
return 0
entries = []
for row in matches:
body = slice_entry(
codegen_root, row["page"], row["signature"], row["anchor"]
)
if not body:
print(
f"ERROR: entry body not found: "
f"{row['page']}#{row['anchor']} ({row['signature']})",
file=sys.stderr,
)
return 1
entries.append((row, body))
for row, body in entries:
print(body)
print(
f"\n<!-- {row['scope']}/{row['module']} · "
f"{row['page']}#{row['anchor']} -->\n"
)
return 0
matches = search_keyword(rows, args.kw)
print(f"# {len(matches)} matches for: {' '.join(args.kw)}")
print("qualified_name\tdeclaration\tsignature\tpage#anchor\tsummary")
for row in matches[: args.limit]:
print(format_row(row))
if len(matches) > args.limit:
print(f"... {len(matches) - args.limit} more; refine keywords or raise --limit")
return 0
if __name__ == "__main__":
raise SystemExit(main())