AST 提取与语言 registry 深度解析
这一页拆解 calldiff 如何从文件扩展名进入 Tree-sitter grammar,再把不同语言统一成 FunctionInfo 和 CallStep。
语言注册表先解决路由
▸扩展名到 extractor
src/languages/registry.ts:28 把 TypeScript、Python、Go、Rust 等 extractor 组成数组,src/languages/registry.ts:54 再构造 extension map,src/languages/registry.ts:69 的 detectLanguage 因此只需要按 extname 查表。这个设计让 src/extract.ts:34 不必包含任何语言分支。
28const extractors: LanguageExtractor[] = [ 29 typescriptExtractor, 30 typescriptreactExtractor, 31 javascriptExtractor, 32 javascriptreactExtractor, 33 pythonExtractor, 34 goExtractor, 35 rustExtractor, 36 javaExtractor, 37 rubyExtractor, 38 cExtractor, 39 cppExtractor, 40 csharpExtractor, 41 phpExtractor, 42 kotlinExtractor, 43 swiftExtractor, 44 scalaExtractor, 45 luaExtractor, 46 elixirExtractor, 47 bashExtractor, 48 haskellExtractor, 49 zigExtractor, 50 solidityExtractor, 51 ocamlExtractor, 52]; 53 54const byExtension = new Map<string, LanguageExtractor>(); 55for (const extractor of extractors) { 56 for (const ext of extractor.extensions) { 57 byExtension.set(ext.toLowerCase(), extractor); 58 } 59} 60 61export function listSupportedExtensions(): string[] { 62 return [...byExtension.keys()].sort(); 63} 64 65export function listSupportedLanguages(): string[] { 66 return extractors.map((e) => e.id); 67} 68 69export function detectLanguage(file: string): LanguageExtractor | null { 70 const ext = extname(file).toLowerCase(); 71 return byExtension.get(ext) ?? null; 72} 73 74export function getExtractor(id: string): LanguageExtractor | null { 75 return extractors.find((e) => e.id === id) ?? null; 76} 77 78export { extractors };
▸grammar cache 处理 native 包差异
src/languages/grammars.ts:104 先尝试本地依赖,失败后落到 src/languages/grammars.ts:128 的 cache 目录,并在 src/languages/grammars.ts:132 用 npm install 拉 grammar。src/languages/grammars.ts:92 的 INSTALL_SPEC 则说明某些 grammar 需要 pin 版本才能稳定 require。
92/** Packages that need a pinned install spec (latest breaks createRequire). */ 93const INSTALL_SPEC: Record<string, string> = { 94 "tree-sitter-c-sharp": "tree-sitter-c-sharp@0.23.1", 95 // 0.4+ is ESM-with-TLA; 0.2.0 is CJS and loads via createRequire. 96 "@tree-sitter-grammars/tree-sitter-lua": 97 "@tree-sitter-grammars/tree-sitter-lua@0.2.0", 98}; 99 100/** 101 * Install an npm grammar package into the shared cache if missing, then require it. 102 * Reuses the cache across CLI invocations. 103 */ 104export function loadGrammarPackage(npmPackage: string): GrammarModule { 105 // Prefer the app's own dependency when present (e.g. tree-sitter-typescript). 106 try { 107 const localRequire = createRequire(import.meta.url); 108 try { 109 return localRequire(npmPackage) as GrammarModule; 110 } catch (err) { 111 const code = (err as NodeJS.ErrnoException)?.code; 112 const msg = err instanceof Error ? err.message : String(err); 113 if ( 114 code === "ERR_REQUIRE_ASYNC_MODULE" || 115 msg.includes("top-level await") 116 ) { 117 const entry = localRequire.resolve(npmPackage); 118 const packageRoot = join(entry, "..", ".."); 119 const binding = loadNativeBinding(packageRoot); 120 if (binding) return binding; 121 } 122 throw err; 123 } 124 } catch { 125 // fall through to cache 126 } 127 128 const cacheDir = grammarCacheDir(); 129 if (!packageInstalled(cacheDir, npmPackage)) { 130 ensureCachePackageJson(cacheDir); 131 const installSpec = INSTALL_SPEC[npmPackage] ?? npmPackage; 132 execFileSync( 133 "npm", 134 [ 135 "install", 136 "--prefix", 137 cacheDir, 138 "--no-save", 139 "--no-fund", 140 "--no-audit", 141 "--legacy-peer-deps", 142 installSpec, 143 ], 144 { 145 stdio: ["ignore", "pipe", "pipe"], 146 env: process.env, 147 }, 148 ); 149 } 150 151 const require = createRequire(join(cacheDir, "package.json")); 152 const packageRoot = join(cacheDir, "node_modules", npmPackage); 153 return requireGrammar(require, npmPackage, packageRoot); 154}
TypeScript extractor 的语义取舍
▸calleeKey 决定调用身份
src/languages/typescript.ts:76 的 calleeKey 是调用图质量的核心:identifier 直接返回函数名,this.method 会被归到当前 class,object.property 会生成 object.property。src/languages/typescript.ts:84 明确跳过 subscript expression,这避免把动态 key 误判成稳定调用。
76function calleeKey(node: SyntaxNode, className: string | null): string | null { 77 if (node.type === "identifier") return node.text; 78 if (node.type === "this") return className; 79 80 if ( 81 node.type === "member_expression" || 82 node.type === "subscript_expression" 83 ) { 84 if (node.type === "subscript_expression") return null; 85 86 const object = node.namedChild(0); 87 const property = 88 namedChildren(node).find( 89 (c) => 90 c.type === "property_identifier" || 91 c.type === "private_property_identifier", 92 ) ?? null; 93 if (!object || !property) return null; 94 95 const propName = property.text; 96 if (object.type === "this" && className) { 97 return `${className}.${propName}`; 98 } 99 if (object.type === "identifier") { 100 return `${object.text}.${propName}`; 101 } 102 if (className) return `${className}.${propName}`; 103 return propName; 104 } 105 106 return null; 107}
▸分支变成 CallStep,不被扁平化
src/languages/typescript.ts:160 识别 if_statement,src/languages/typescript.ts:174 写入 branch step,并在 src/languages/typescript.ts:184 到 src/languages/typescript.ts:220 保留 else-if / else 链。这样 render 时才知道这些是互斥路径,而不是连续调用。
160 if (type === "if_statement") { 161 const test = 162 childByType(node, "parenthesized_expression") ?? 163 namedChildren(node).find((c) => c.type !== "else_clause") ?? 164 null; 165 const kids = namedChildren(node); 166 const consequent = 167 kids.find( 168 (c) => 169 c.type !== "parenthesized_expression" && c.type !== "else_clause", 170 ) ?? null; 171 const elseClause = childByType(node, "else_clause"); 172 const cond = test ? condText(test) : ""; 173 174 steps.push({ 175 type: "branch", 176 key: branchKey("if", cond), 177 label: test ? `if (${condText(test)})` : "if", 178 ...locFromNode(file, test ?? node), 179 children: consequent 180 ? collectStatements(file, statementsOf(consequent), className) 181 : [], 182 }); 183 184 let current = elseClause; 185 while (current) { 186 const inner = current.namedChild(0); 187 if (!inner) break; 188 189 if (inner.type === "if_statement") { 190 const elseTest = 191 childByType(inner, "parenthesized_expression") ?? null; 192 const elseKids = namedChildren(inner); 193 const elseConsequent = 194 elseKids.find( 195 (c) => 196 c.type !== "parenthesized_expression" && 197 c.type !== "else_clause", 198 ) ?? null; 199 const elseCond = elseTest ? condText(elseTest) : ""; 200 steps.push({ 201 type: "branch", 202 key: branchKey("else-if", elseCond), 203 label: elseTest ? `else if (${condText(elseTest)})` : "else if", 204 ...locFromNode(file, elseTest ?? current), 205 children: elseConsequent 206 ? collectStatements(file, statementsOf(elseConsequent), className) 207 : [], 208 }); 209 current = childByType(inner, "else_clause"); 210 continue; 211 } 212 213 steps.push({ 214 type: "branch", 215 key: branchKey("else", ""), 216 label: "else", 217 ...locFromNode(file, current), 218 children: collectStatements(file, statementsOf(inner), className), 219 }); 220 break; 221 } 222 return;
▸JSX 组件也作为调用处理
src/languages/typescript.ts:109 把 PascalCase JSX 和 member expression 当成组件调用,src/languages/typescript.ts:239 到 src/languages/typescript.ts:276 还会把 attribute 和 children 里的调用挂到组件节点下面。这让 React/TSX 的 UI flow 可以进入同一个 call tree。
239 } else if (type === "jsx_element") { 240 const opening = childByType(node, "jsx_opening_element"); 241 const childNodes = namedChildren(node).filter( 242 (c) => 243 c.type !== "jsx_opening_element" && c.type !== "jsx_closing_element", 244 ); 245 const fromAttrs: CallStep[] = []; 246 if (opening) { 247 for (const attr of namedChildren(opening)) { 248 if ( 249 attr.type === "jsx_attribute" || 250 attr.type === "jsx_expression" 251 ) { 252 fromAttrs.push(...collectStatements(file, [attr], className)); 253 } 254 } 255 } 256 const nested = [ 257 ...fromAttrs, 258 ...collectStatements(file, childNodes, className), 259 ]; 260 if (opening) { 261 const key = jsxCalleeKey(opening); 262 if (key) { 263 if (nested.length > 0) { 264 steps.push({ 265 type: "call", 266 key, 267 ...locFromNode(file, opening), 268 children: nested, 269 }); 270 } else { 271 addCall(key, opening); 272 } 273 return; 274 } 275 } 276 for (const step of nested) steps.push(step); 277 return; 278 } else if (type === "jsx_self_closing_element") { 279 const attrNodes = namedChildren(node).filter( 280 (c) => c.type === "jsx_attribute" || c.type === "jsx_expression", 281 ); 282 const nested = collectStatements(file, attrNodes, className); 283 const key = jsxCalleeKey(node); 284 if (key) { 285 if (nested.length > 0) { 286 steps.push({ 287 type: "call", 288 key, 289 ...locFromNode(file, node), 290 children: nested, 291 }); 292 } else { 293 addCall(key, node); 294 } 295 } else { 296 for (const step of nested) steps.push(step); 297 } 298 return; 299 } 300 301 for (const child of namedChildren(node)) { 302 walkExpr(child); 303 } 304 }; 305 306 for (const stmt of statements) { 307 walkExpr(stmt); 308 } 309 310 return steps;
统一输出契约
src/languages/typescript.ts:325 的 functionFromParts 统一填充 key、label、steps 和 exported,src/languages/typescript.ts:388 的 handleClass 则把 method、constructor、public field arrow function 映射到同一套 FunctionInfo。这个收敛点保证 src/calltree.ts:107 可以不区分函数来源。
325function functionFromParts( 326 file: string, 327 key: string, 328 label: string, 329 params: SyntaxNode | null, 330 body: SyntaxNode | null, 331 exported: boolean, 332 start: number, 333 end: number, 334 className: string | null, 335): FunctionInfo { 336 const paramsLabel = getParamsLabel(params); 337 return { 338 key, 339 label: `${label}${paramsLabel}`, 340 file, 341 steps: collectStepsFromBody(file, body, className), 342 exported, 343 start, 344 end, 345 }; 346} 347 348function handleFunctionNode( 349 file: string, 350 node: SyntaxNode, 351 name: string | null, 352 exported: boolean, 353 className: string | null, 354 functions: FunctionInfo[], 355) { 356 if (!name) return; 357 const key = className ? `${className}.${name}` : name; 358 const params = childByType(node, "formal_parameters"); 359 const body = 360 childByType(node, "statement_block") ?? 361 namedChildren(node).find( 362 (c) => 363 c.type !== "formal_parameters" && 364 c.type !== "type_parameters" && 365 c.type !== "type_annotation" && 366 c.type !== "identifier" && 367 c.type !== "accessibility_modifier" && 368 c.type !== "async" && 369 c.type !== "readonly", 370 ) ?? 371 null; 372 373 functions.push( 374 functionFromParts( 375 file, 376 key, 377 key, 378 params, 379 body, 380 exported, 381 node.startIndex, 382 node.endIndex, 383 className, 384 ), 385 ); 386} 387 388function handleClass( 389 file: string, 390 node: SyntaxNode, 391 exported: boolean, 392 functions: FunctionInfo[], 393) { 394 const nameNode = 395 childByType(node, "type_identifier") ?? childByType(node, "identifier"); 396 const className = nameNode?.text ?? null; 397 if (!className) return; 398 399 const body = childByType(node, "class_body"); 400 if (!body) return; 401 402 for (const element of namedChildren(body)) { 403 if (element.type === "method_definition") { 404 const keyNode = 405 childByType(element, "property_identifier") ?? 406 childByType(element, "private_property_identifier") ?? 407 childByType(element, "computed_property_name"); 408 const methodName = keyNode?.text ?? null; 409 const isConstructor = methodName === "constructor"; 410 if (!methodName) continue; 411 412 const accessibility = childByType(element, "accessibility_modifier"); 413 const methodExported = exported || accessibility?.text === "public"; 414 415 const params = childByType(element, "formal_parameters"); 416 const fnBody = childByType(element, "statement_block"); 417 const key = isConstructor 418 ? `${className}.constructor` 419 : `${className}.${methodName}`; 420 const label = isConstructor ? `new ${className}()` : key; 421 422 functions.push( 423 functionFromParts( 424 file, 425 key, 426 label, 427 params, 428 fnBody, 429 methodExported, 430 element.startIndex, 431 element.endIndex, 432 className, 433 ), 434 ); 435 } 436 437 if (element.type === "public_field_definition") { 438 const keyNode = childByType(element, "property_identifier"); 439 const value = 440 childByType(element, "arrow_function") ?? 441 childByType(element, "function_expression"); 442 if (keyNode && value) { 443 handleFunctionNode( 444 file, 445 value, 446 keyNode.text, 447 exported, 448 className, 449 functions, 450 ); 451 } 452 } 453 } 454}
测试如何压住 extractor 边界
▸private 与 class field
test/callstack-diff.test.ts:188 证明 private method 会进入调用树,test/callstack-diff.test.ts:212 证明 class field arrow function 也能被跟踪。它们分别对应 src/languages/typescript.ts:404 的 private_property_identifier 和 src/languages/typescript.ts:437 的 public_field_definition 分支。
188test("indexes and expands #private methods", ({ expectCallstack }) => { 189 expectCallstack( 190 ` 191 export class Vault { 192 open() { 193 this.#unlock(); 194 } 195 #unlock() { 196 prep(); 197 + audit(); 198 } 199 } 200 function prep() {} 201 + function audit() {} 202 `, 203 "Vault.open", 204 ).toEqual(` 205 Vault.open() 206 └─ Vault.#unlock() 207 ├─ prep() 208 + └─ audit() 209 `); 210}); 211 212test("follows class field arrow functions", ({ expectCallstack }) => { 213 expectCallstack( 214 ` 215 export class Runner { 216 start() { 217 this.helper(); 218 } 219 helper = () => { 220 work(); 221 + extra(); 222 }; 223 } 224 function work() {} 225 + function extra() {} 226 `, 227 "Runner.start", 228 ).toEqual(` 229 Runner.start() 230 └─ Runner.helper() 231 ├─ work() 232 + └─ extra() 233 `); 234});
Extractor 的边界
▸动态调用仍是盲区
src/languages/typescript.ts:84 跳过 subscript expression 是正确的保守选择,但也意味着 registry[ name ]()、依赖注入容器和框架装饰器不会被完整展开。这个工具适合 review call flow,不适合替代类型系统。