Files
playbook/skills/tsl-api-reference/scripts/lookup.py
T

516 lines
16 KiB
Python
Raw 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
import unicodedata
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",
)
SEARCH_FIELD_WEIGHTS = (
("name", 120),
("qualified_name", 120),
("signature", 90),
("tags", 70),
("summary", 50),
("module", 30),
("scope", 30),
("owner", 25),
("kind", 20),
("binding", 20),
("visibility", 20),
)
# Complete coverage in curated semantic text must outrank incidental short
# aliases in composite identifiers, such as "index" and "max".
SEMANTIC_COVERAGE_BONUSES = (
("tags", 240),
("summary", 200),
)
HELP_EPILOG = """\
检索分两步,--kw 的摘要不能直接作为 API 事实:
1. 先按关键词取候选(只有摘要表格,不含条目正文)
lookup.py --kw 数组 去重
2. 再按候选里的 qualified_name 取回条目正文
lookup.py --name arrDropDuplicate
已知确切名称时可以直接用 --name,跳过第 1 步。
只查询一个 API scope 时使用 --scope,例如 --scope builtin 或 --scope dotnet。
退出码:
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 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 normalize(value):
return unicodedata.normalize("NFKC", value).casefold().strip()
def field_match_score(value, variant, weight):
normalized = normalize(value)
if not normalized or not variant:
return 0
if normalized == variant:
return weight + 20
if variant in normalized:
return weight
return 0
def term_score(row, term):
best = 0
for field, weight in SEARCH_FIELD_WEIGHTS:
score = field_match_score(row.get(field, ""), term, weight)
if score:
best = max(best, score)
return best
def field_covers_terms(row, field, terms):
value = normalize(row.get(field, ""))
return bool(value) and all(term in value for term in terms)
def keyword_sort_key(row):
return (
normalize(row.get("qualified_name", "") or row.get("name", "")),
normalize(row.get("signature", "")),
normalize(row.get("scope", "")),
row.get("page", ""),
row.get("anchor", ""),
)
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):
normalized_terms = [normalize(term) for term in terms]
ranked = []
for row in rows:
scores = [term_score(row, term) for term in normalized_terms]
if scores and all(scores):
coverage_bonus = sum(
bonus
for field, bonus in SEMANTIC_COVERAGE_BONUSES
if field_covers_terms(row, field, normalized_terms)
)
ranked.append((sum(scores) + coverage_bonus, row))
ranked.sort(key=lambda item: (-item[0], *keyword_sort_key(item[1])))
return [row for _, row in ranked]
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 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、tags、summary、module、scope、kind、binding、"
"visibility、owner 和 qualified_name 中做字面子串匹配,按字段加权并稳定排序。"
"同义词由索引的 tags 字段提供。"
"大小写不敏感,只返回摘要表格,不含条目正文",
)
parser.add_argument(
"--tsv",
metavar="PATH",
help="显式指定 function_index.tsv;默认只使用本 skill 内置的 "
"data/function_index.tsv,不从工作目录回退查找旧索引",
)
parser.add_argument(
"--scope",
type=non_empty,
metavar="SCOPE",
help="只查询指定 scope,大小写不敏感;作用于 --name 和 --kw。"
"内置索引当前提供 builtin 与 dotnet",
)
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
if 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
if args.scope is not None:
requested_scope = normalize(args.scope)
available_scopes = sorted(
{row["scope"] for row in rows if row.get("scope")},
key=normalize,
)
if requested_scope not in {normalize(scope) for scope in available_scopes}:
print(
f"ERROR: unknown scope {args.scope!r}; available scopes: "
f"{', '.join(available_scopes)}",
file=sys.stderr,
)
return 2
rows = [row for row in rows if normalize(row["scope"]) == requested_scope]
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")
if not matches:
print("No matching API candidates; do not infer an API name.")
return 0
if __name__ == "__main__":
raise SystemExit(main())