昨天我們定義了呼叫邊(CallEdge)的資料結構,並在 SQLite 中建置了 calls 資料表。
要建立呼叫關係,第一步不是憑空猜測架構圖,而是回到語法樹,精確找出每個函式內部到底呼叫了誰。
在 Python 的 AST 中,所有的函式調用事件都會表現為一個 ast.Call 節點。今天我們的目標:擴充 AST 解析器,走訪函式本體中的 ast.Call,精準擷取「Caller 符號」、「被呼叫的名稱(Callee Name)」以及「發生的行號」。
ast.Call 節點長什麼樣?Python 的呼叫表達式主要有兩種常見形態:
build_index()
node.func 是一個 ast.Name 節點。node.func.id(即 "build_index")。client.search(...)
self.validate()
node.func 是一個 ast.Attribute 節點。node.func.attr(如 "search"、"validate"),或是遞迴展開為 "client.search"。為了在第一版保持解析穩定,我們先提取可識別的最底層名稱(或屬性鍊),並保留其呼叫所在的真實行號。
CallVisitor 走訪器在 app/call_graph.py 中實作專門萃取呼叫事件的 AST Visitor:
# app/call_graph.py (擴充 AST 呼叫萃取邏輯)
import ast
from pathlib import Path
from typing import List, Optional
from app.call_graph import CallEdge
class CallVisitor(ast.NodeVisitor):
def __init__(self, rel_path: str):
self.rel_path = rel_path
self.scope_stack: List[str] = []
self.edges: List[CallEdge] = []
def visit_ClassDef(self, node: ast.ClassDef):
# 追蹤當前類別範圍
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef):
self._handle_func(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
self._handle_func(node)
def _handle_func(self, node):
func_name = ".".join(self.scope_stack + [node.name])
self.scope_stack.append(node.name)
# 走訪函式內部所有子節點
self.generic_visit(node)
self.scope_stack.pop()
def visit_Call(self, node: ast.Call):
# 只有在函式內部發生的呼叫才記錄 caller;頂層全域呼叫記為 <module>
current_caller = ".".join(self.scope_stack) if self.scope_stack else "<module>"
callee_name = self._resolve_callee_name(node.func)
if callee_name:
self.edges.append(CallEdge(
caller_path=self.rel_path,
caller_symbol=current_caller,
callee_name=callee_name,
line=node.lineno
))
# 繼續走訪參數內部可能存在的巢狀呼叫 (如 foo(bar()))
self.generic_visit(node)
def _resolve_callee_name(self, node: ast.AST) -> Optional[str]:
"""解析呼叫目標名稱,支援 Name 與 Attribute 節點"""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
# 取屬性名稱,例如 client.search -> "search" (或保存完整屬性)
value_name = self._resolve_callee_name(node.value)
if value_name:
return f"{value_name}.{node.attr}"
return node.attr
return None
tests/unit/test_call_visitor.py在 tests/unit/test_call_visitor.py 驗證函式呼叫的擷取與行號精確度:
# tests/unit/test_call_visitor.py
import ast
from app.call_graph import CallVisitor
def test_extract_calls_from_function_and_methods():
source_code = (
"def setup():\n"
" init_logger()\n" # 第 2 行呼叫 init_logger
"\n"
"class Pipeline:\n"
" def run(self):\n"
" data = self.fetch()\n" # 第 6 行呼叫 self.fetch
" process(data)\n" # 第 7 行呼叫 process
)
tree = ast.parse(source_code)
visitor = CallVisitor(rel_path="src/pipeline.py")
visitor.visit(tree)
edges = visitor.edges
# 驗證總共抓到 3 次呼叫
assert len(edges) == 3
# 1. 頂層函式呼叫
assert edges[0].caller_path == "src/pipeline.py"
assert edges[0].caller_symbol == "setup"
assert edges[0].callee_name == "init_logger"
assert edges[0].line == 2
# 2. 類別成員方法呼叫
assert edges[1].caller_symbol == "Pipeline.run"
assert edges[1].callee_name == "self.fetch"
assert edges[1].line == 6
assert edges[2].caller_symbol == "Pipeline.run"
assert edges[2].callee_name == "process"
assert edges[2].line == 7
執行測試確認通過:
uv run pytest tests/unit/test_call_visitor.py -v
tests/unit/test_call_visitor.py::test_extract_calls_from_function_and_methods PASSED [100%]
============================== 1 passed in 0.04s ==============================
mobileai-local-rag 呼叫事件將 CallVisitor 整合進專案索引流程,掃描目標專案全部 7 支業務檔案:
uv run python -m app.cli index-calls
終端機輸出報告:
{
"scanned_files": 7,
"extracted_call_edges": 42,
"status": "ready"
}
在目標專案中,語法樹一共抓出了 42 處具體的函式調用事件,每一筆都標記著確切的 caller、callee 與行號。
今天我們完成了「看見程式碼動態關係」的第一步:
ast.Call 將一般函式呼叫與物件方法調用全部轉換成 CallEdge。但問題來了:現在 AST 看到的被呼叫端很多只是簡短名稱(例如只看到呼叫了 build_index 或 load)。如果不同檔案有多個同名函式,它到底呼叫了哪一個?
明天在 Day 23 中,我們將實作 符號解析(Symbol Resolution),結合 Day 10 的 Symbol 索引,把簡短的呼叫名稱解析成具體的跨檔案唯一符號!