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