#!/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\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())