Migrate reference pages to declaration-line storage and rebuild the 13-column index. Add qualified member lookup plus regression and end-to-end coverage.
458 lines
14 KiB
Python
458 lines
14 KiB
Python
#!/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())
|