چگونه با استفاده از APIهای زبان VS Code یک گراف کد در TypeScript بسازیم
پیمایش کدهای مدرن روزبهروز دشوارتر میشود. این لزوماً به این دلیل نیست که برنامهنویسان خودشان کدهای بیشتری مینویسند. دلیل اصلی این است که دستیارهای برنامهنویسی در حال تولید صد
پیمایش کدهای مدرن روزبهروز دشوارتر میشود. این لزوماً به این دلیل نیست که برنامهنویسان خودشان کدهای بیشتری مینویسند. دلیل اصلی این است که دستیارهای برنامهنویسی در حال تولید صدها یا حتی هزاران خط کد هستند و مشکل اصلی اکنون بازبینی کد (Code Review) شده است.
در دوران پیش از مدلهای زبانی بزرگ (LLM)، ممکن بود روزها وقت صرف کنید تا چند خط کد بنویسید. این بدان معنا بود که زمینه شما در هر پروژه همزمان با مشارکتتان به طور تدریجی رشد میکرد. شما فقط زمانی مجبور بودید کدهای جدید را بررسی و با آنها آشنا شوید که به یک تیم جدید ملحق میشدید یا شغلی جدید پیدا میکردید.
اما امروزه، یک دستور (Prompt) میتواند در عرض چند دقیقه هزاران خط کد را در میان صدها فایل تولید کند. در این مقیاس، قالب سنتی بررسی کد شروع به فروپاشی میکند و شما زمان بیشتری را صرف بازبینی کد میکنید تا نوشتن واقعی آن.
برای نمونه، اگر یک پروژه بزرگ TypeScript را باز کنید و بخواهید به یک سوال ظاهراً ساده پاسخ دهید مانند:
"چه چیزی این تابع را فراخوانی میکند؟"
احتمالاً با جستجو در فایلها شروع میکنید. ممکن است از قابلیت "Find References" ویرایشگر خود استفاده کنید. ممکن است بین تعاریف جابهجا شوید. ممکن است به دنبال واردسازیها (Imports)، صادراتها (Exports) و نام funções بگردید.
اما راه دیگری هم برای فکر کردن به این مسئله وجود دارد. بهجای اینکه یک پایگاه کد را به عنوان مجموعهای از فایلها در نظر بگیریم، میتوانیم آن را به شکل یک گراف مدلسازی کنیم.
توابع به گرهها (Nodes) و فراخوانیها به یالها (Edges) تبدیل میشوند.
هنگامی که کد به عنوان یک گراف نمایش داده شود، سوالاتی مانند "چه چیزی این تابع را فراخوانی میکند؟" یا "این تابع در نهایت چه چیزی را فراخوانی میکند؟" به مسائل پیمایش گراف (Graph Traversal) تبدیل میشوند.
در این آموزش، هسته یک گراف کد را با استفاده از TypeScript و APIهای زبان داخلی VS Code خواهیم ساخت. ما مفسر (Parser) TypeScript خودمان را نخواهیم نوشت؛ بلکه از اطلاعات معنایی که VS Code و افزونه زبان نصبشده از قبل فراهم میکنند، استفاده خواهیم کرد. نتیجه، گرافی خواهد بود که شامل فایلها، توابع، متدها و روابط فراخوانی است و میتواند در یک Webview در VS Code نمایش داده شود.
فهرست مطالب
- آنچه میسازیم پیشنیازها
- پیشنیازها
- ۱. آشنایی با APIهای زبان VS Code
- ۲. راهاندازی افزونه
- ۳. طراحی مدل داده گراف شناسههای پایدار نماد
- شناسههای پایدار نماد
- ۴. یافتن توابع و متدها
- ۵. یافتن نماد در یک موقعیت خاص
- ۶. حلوفصل سلسلهمراتب فراخوانی
- ۷. ساخت رجیستری نمادها
- ۸. پیمایش گراف با استفاده از BFS
- ۹. مدیریت چرخهها (Cycles)
- ۱۰. محدود کردن گراف
- ۱۱. فیلتر کردن فایلها
- ۱۲. چرا برخی از یالها بهصورت بیصدا ناپدید میشوند ۱. دادههای ساده را ذخیره میکند، نه موارد زنده. ۲. سن هر آیتم آمادهشده را ردیابی میکند. ۳. نتایج خالی مشکوک را دوباره تلاش میکند.
- ۱. دادههای ساده را ذخیره میکند، نه موارد زنده.
- ۲. سن هر آیتم آمادهشده را ردیابی میکند.
- ۳. نتایج خالی مشکوک را دوباره تلاش میکند.
- ۱۳. اتصال گراف به یک Webview
- ۱۴. تست کردن سازنده گراف
- ۱۵. محدودیتهای یک گراف کد
- نتیجهگیری
آنچه میسازیم
ما یک موتور کوچک گراف کد خواهیم ساخت که از API سلسلهمراتب فراخوانی VS Code برای کشف روابط بین توابع و متدها استفاده میکند، و سپس آن روابط را در چندین گام پیمایش میکند. در این مسیر، همزمانی، ارجاعات منسوخ ابزارهای زبانی، ذخیرهسازی در حافظه پنهان (Caching) و پیمایشهای تکراری را مدیریت خواهیم کرد تا گراف پایدار و کارآمد باقی بماند.
پیشنیازها
پیش از دنبال کردن این آموزش، باید با موارد زیر راحت باشید:
- TypeScript و برنامهنویسی ناهمگام پایه با async/await
- هر کد سازگار با VS Code، توابع API افزونههای VS Code، و دستور
vscode.commands.executeCommand - مفاهیم پایه گراف مانند گرهها (nodes)، یالها (edges)، و جستجوی اول سطح (BFS)
- کار با نگاشتها (maps)، آرایهها، و توابع جنریک (generic) در TypeScript
بزن بریم!
فرض کنید کد زیر را داریم:
function checkout() {
processPayment();
}
function processPayment() {
chargeCard();
}
function chargeCard() {
saveTransaction();
}
function saveTransaction() {
// persist transaction
}
ما میخواهیم کد منبع را به یک گراف تبدیل کنیم. برای یک پایگاه کد واقعی، گراف ممکن است چندین فایل را در بر بگیرد:
این پیادهسازی شامل دو بخش اصلی است. میزبان افزونه (extension host) از APIهای زبان VS Code برای کشف گراف استفاده میکند. وبویو (Webview) گراف حاصل را نمایش میدهد. بخش جذاب این معماری، سازنده گراف (graph builder) است.
۱. درک APIهای زبان در VS Code
VS Code از پیش چندین دستور را ارائه میدهد که افزونهها میتوانند برای پرسوجو درباره هوش زبانی از آنها استفاده کنند. برای این پروژه، چهار مورد بسیار مفید هستند:
| دستور (Command) | هدف (Purpose) |
|---|---|
vscode.executeDocumentSymbolProvider |
یافتن نمادها در یک سند |
vscode.prepareCallHierarchy |
تبدیل یک موقعیت به یک آیتم سلسلهمراتب فراخوانی |
vscode.provideIncomingCalls |
یافتن فراخوانندگان (Callers) |
vscode.provideOutgoingCalls |
یافتن فراخوانشوندگان (Callees) |
این APIها بالاتر از پیادهسازیهای خاصِ هر زبان قرار میگیرند. برای TypeScript و JavaScript، سرویس زبان TypeScript اطلاعات زیرین را فراهم میکند. سایر زبانها نیز قابلیتهای مشابهی را از طریق افزونهها و سرورهای زبانی خود ارائه میدهند، مانند gopls برای Go، rust-analyser برای Rust، و Pyright یا Pylance برای Python.
این موضوع به این دلیل اهمیتژرفی دارد که ما نیازی نداریم برای هر زبان، یک تجزیهکننده (parser) و موتور گراف فراخوانی جداگانه بسازیم. اگر یک افزونه زبانی، نمادهای سند و پشتیبانی از سلسلهمراتب فراخوانی را از طریق VS Code فراهم کند، همان معماری ساخت گراف میتواند مستقیماً از آن اطلاعات استفاده کند.
یک درخت نحو انتزاعی (AST) میتواند به شما بگوید که یک تابع حاوی یک عبارت فراخوانی است. اما بهطور خودکار به شما نمیگوید که آن فراخوانی، در میان ایمپورتها، فایلها، ماژولها، کلاسها، نامهای مستعار (aliases) و سایر ساختارهای زبانی، به کدام تابع اشاره دارد.
سرور زبان از قبل بیشتر این کارهای معنایی را انجام داده است. بنابراین بهجای ساختن یک تجزیهکننده و حلکننده نماد دیگر، میتوانیم اطلاعاتی را که VS Code از قبل میداند، از آن درخواست کنیم.
۲. راهاندازی افزونه
فایل package.json ما یک دستور را اعلام میکند:
{
"main": "./out/extension.js",
"engines": {
"vscode": "^1.85.0"
},
"activationEvents": [],
"contributes": {
"commands": [
{
"command": "codeGraphView.open",
"title": "Code Graph: Open Graph for Active File",
"icon": "$(type-hierarchy)"
}
],
"menus": {
"editor/title": [
{
"command": "codeGraphView.open",
"group": "navigation",
"when": "resourceLangId == typescript"
}
]
}
},
"dependencies": {
"elkjs": "^0.9.3"
}
}
میزبان افزونه و Webview در محیطهای مختلفی اجرا میشوند، بنابراین بهصورت جداگانه بستهبندی (bundle) میشوند. یک پیکربندی سادهشده با esbuild به شکل زیر خواهد بود:
const extensionConfig = {
entryPoints: ['src/extension.ts'],
bundle: true,
outfile: 'out/extension.js',
external: ['vscode'],
format: 'cjs',
platform: 'node',
};
const webviewConfig = {
entryPoints: ['webview/main.ts'],
bundle: true,
outfile: 'out/webview/main.js',
format: 'iife',
platform: 'browser',
};
کد افزونه در Node اجرا میشود، در حالی که کد Webview در محیط مرورگر اجرا میشود.
۳. طراحی مدل داده گراف
پیش از فراخوانی APIهای زبان، باید تصمیم بگیریم که گراف ما چه شکلی باشد. یک مدل مفید به این صورت است:
export interface SymbolRow {
id: string;
name: string;
kind: 'function' | 'method';
line: number;
character: number;
}
export interface FileNode {
id: string;
label: string;
file: string;
symbols: SymbolRow[];
}
export interface CallEdge {
id: string;
source: string;
target: string;
}
export interface GraphData {
rootFileId: string;
rootSymbolId?: string;
roots: string[];
files: FileNode[];
edges: CallEdge[];
truncated: boolean;
}
دو مفهوم مهم وجود دارد.
- یک
FileNodeشامل توابع یا متدهای متعلق به یک فایل است. - یک
CallEdgeنشاندهنده رابطه بین دو نماد است.
ما جهت یالها را ثابت نگه میداریم:
caller → callee
بنابراین اگر تابع checkout() تابع processPayment() را فراخوانی کند، گراف همیشه شامل موارد زیر است:
checkout → processPayment
حتی اگر آن رابطه را هنگام درخواست برای فراخوانیهای ورودی (incoming calls) کشف کرده باشیم.
شناسههای پایدار نماد (Stable Symbol IDs)
نام توابع یکتا نیستند. یک پروژه میتواند بهراحتی شامل موارد زیر باشد:
// users.ts
function save() {}
و:
// payments.ts
function save() {}
بنابراین ما به شناسایی نیاز داریم که بر اساس موقعیت نماد باشد.
function idOf(
uri: vscode.Uri,
pos: vscode.Position
): string {
return `${uri.toString()}#${pos.line}:${pos.character}`;
}
برای آیتمهای سلسلهمراتب فراخوانی، ما از selectionRange آنها استفاده میکنیم:
function itemId(
item: vscode.CallHierarchyItem
): string {
return idOf(
item.uri,
item.selectionRange.start
);
}
استفاده از selectionRange مفید است زیرا به جای کل بدنه یا محدوده اعلان، نام نماد را مشخص میکند. این شناسه پایدار به عنوان پایه و اساس برای حذف موارد تکراری عمل میکند. اگر یک تابع یکسان از مسیرهای مختلفی در گراف کشف شود، میتوانیم تشخیص دهیم که همه موارد کشفشده به یک گره اشاره دارند.
۴. پیدا کردن توابع و متدها
اولین قدم در ساخت گراف، کشف نمادها در فایل فعال است. VS Code نمادهای سند را از طریق روش زیر ارائه میدهد:
vscode.executeDocumentSymbolProvider
ما میتوانیم آن را به این صورت فراخوانی کنیم:
/*
* Get all symbols in a document using VS Code's
* built-in language service instead of parsing the code ourselves. This was we can get the methods, functions, variables within a document
*/
async function getDocumentSymbols(
uri: vscode.Uri
): Promise<vscode.DocumentSymbol[]> {
/*
* `vscode.executeDocumentSymbolProvider` delegates the analysis
* to the language provider registered for the document's language.
*/
const result =
await vscode.commands.executeCommand<
vscode.DocumentSymbol[] | undefined
>(
'vscode.executeDocumentSymbolProvider',
uri
);
// Return an empty list if no symbols are found.
return result ?? [];
}
نمادهای بازگرداندهشده یک ساختار درختی (سلسلهمراتب) تشکیل میدهند. برای مثال:
ما باید در آن سلسلهمراتب حرکت کنیم و نمادهایی را که میتوانند نمایانگر کد قابل فراخوانی باشند، جمعآوری کنیم.
// Check whether a symbol can be treated as a callable node.
const isCallableKind = (
kind: vscode.SymbolKind,
includeConstructors: boolean
) =>
kind === vscode.SymbolKind.Function ||
kind === vscode.SymbolKind.Method ||
(
includeConstructors &&
kind === vscode.SymbolKind.Constructor
);
ما میتوانیم درخت نمادها را به صورت بازگشتی بررسی کنیم:
// Recursively collect functions, methods, and variables from a symbol tree
function collectCandidates(
symbols: vscode.DocumentSymbol[],
isCallable: (
kind: vscode.SymbolKind
) => boolean,
out: vscode.DocumentSymbol[] = []
) {
for (const symbol of symbols) {
if (
isCallable(symbol.kind) ||
symbol.kind === vscode.SymbolKind.Variable
) {
out.push(symbol);
} else if (
symbol.children.length
) {
collectCandidates(
symbol.children,
isCallable,
out
);
}
}
return out;
}
متغیرها ارزش بررسی را دارند زیرا توابعی که به متغیرها اختصاص داده میشوند ممکن است توسط ابزارهای زبان به شکل متفاوتی گزارش شوند. برای مثال:
const handler = () => {
// ...
};
ممکن است نماد به عنوان یک متغیر گزارش شود، حتی اگر در سلسلهمراتب فراخوانی مشارکت داشته باشد.
۵. پیدا کردن نماد در یک موقعیت مشخص
هنگامی که کاربر گراف را برای یک متد خاص باز میکند، باید تعیین کنیم که کدام نماد شامل موقعیت مکاننما (cursor) است. از آنجا که نمادهای سند به صورت سلسلهمراتی هستند، میتوانیم عمیقترین نمادی را که حاوی آن موقعیت است به صورت بازگشتی پیدا کنیم.
// Find the most specific symbol containing a given position.
function symbolAt(
symbols: vscode.DocumentSymbol[],
position: vscode.Position
) {
for (const symbol of symbols) {
if (symbol.range.contains(position)) {
return (
symbolAt(
symbol.children,
position
) ?? symbol
);
}
}
return undefined;
}
این کار پلی بین ویرایشگر و گراف برای ما ایجاد میکند. کاربر مکانی را در فایل منبع انتخاب میکند. ما آن مکان را به یک نماد تبدیل میکنیم. سپس آن نماد را به یک آیتم سلسلهمراتب فراخوانی تبدیل (resolve) میکنیم.
۶. حل سلسلهمراتب فراخوانی (Call Hierarchy)
API سلسلهمراتب فراخوانی در دو مرحله کار میکند. اول:
position → CallHierarchyItem
سپس:
CallHierarchyItem → incoming/outgoing calls
ما میتوانیم آیتم را به این شکل آماده کنیم:
async function prepare(
uri: vscode.Uri,
position: vscode.Position
) {
// VS Code's language tooling already knows how to resolve
// symbols in a source file.
// So we can use it to prepare a call hierarchy for the symbol at this location.
const items =
await vscode.commands.executeCommand<
vscode.CallHierarchyItem[] | undefined
>(
'vscode.prepareCallHierarchy',
uri,
position
);
// The command returns an array of hierarchy items. In our case,
// we are interested in the symbol directly under the cursor,
// so we use the first result.
// Optional chaining also handles the case where no symbol
// could be resolved at the given position.
return items?.[0];
}
هنگامی که آیتم را در اختیار داشتیم، میتوانیم فراخوانندگان (callers) را درخواست کنیم:
async function callers(
item: vscode.CallHierarchyItem
) {
// Ask VS Code for all symbols that call this item.
const calls =
await vscode.commands.executeCommand<
vscode.CallHierarchyIncomingCall[] | undefined
>(
'vscode.provideIncomingCalls',
item
);
// Return the calling symbols, defaulting to an empty list when none are found.
return (
calls ?? []
).map(call => call.from);
}
یا فراخوانیشوندگان (callees) را:
async function callees(
item: vscode.CallHierarchyItem
) {
// Ask VS Code for all symbols called by this item.
const calls =
await vscode.commands.executeCommand<
vscode.CallHierarchyOutgoingCall[] | undefined
>(
'vscode.provideOutgoingCalls',
item
);
// Return the called symbols, defaulting to an empty list when none are found.
return (
calls ?? []
).map(call => call.to);
}
اگر داشته باشیم:
و فراخوانیهای ورودی به processPayment را درخواست کنیم، API زبان موارد زیر را به ما میدهد:
checkout
retryPayment
سپس آن نتایج را به شکل زیر استانداردسازی (نرمالسازی) میکنیم:
checkout → processPayment
retryPayment → processPayment
بنابراین، همان ساختار گراف میتواند هم پیمایش ورودی و هم خروجی را نمایش دهد.
۷. ساخت رجیستری نمادها
همانطور که در گراف پیشروی میکنیم، ممکن است یک نماد چندین بار ظاهر شود. این مورد را در نظر بگیرید:
ما باید یک گره برای C بسازیم، نه سه گره. یک رجیستری لایه حذف موارد تکراری را فراهم میکند.
class Registry {
// Keep files and their symbols separately so they can be reused across the graph.
private readonly files =
new Map<string, FileNode>();
// Store symbols by ID for fast lookup and duplicate detection.
private readonly rows =
new Map<string, SymbolRow>();
get size() {
return this.rows.size;
}
has(id: string) {
return this.rows.has(id);
}
register(
uri: vscode.Uri,
name: string,
kind: vscode.SymbolKind,
position: vscode.Position
): string {
// Generate a stable ID from the file and symbol position.
const id =
idOf(uri, position);
// Avoid registering the same symbol more than once.
if (this.rows.has(id)) {
return id;
}
const fileId =
uri.toString();
let file =
this.files.get(fileId);
// Create the file entry the first time we encounter it.
if (!file) {
file = {
id: fileId,
label:
vscode.workspace
.asRelativePath(uri),
file: uri.fsPath,
symbols: [],
};
this.files.set(
fileId,
file
);
}
// Normalize VS Code's symbol kind into the graph's simpler representation.
const row: SymbolRow = {
id,
name,
kind:
kind ===
vscode.SymbolKind.Method
? 'method'
: 'function',
line: position.line,
character:
position.character,
};
// Store the symbol globally and under its containing file.
this.rows.set(id, row);
file.symbols.push(row);
return id;
}
}Now the graph builder can repeatedly register symbols without worrying about duplicates.
۸. پیمایش گراف با استفاده از جستجوی سطح اول (BFS)
یک جستجوی منفرد در سلسلهمراتب فراخوانی، یک گام (hop) به ما میدهد. یک گراف کد مفید به چندین گام نیاز دارد. جستجو از ()A ممکن است به ما بگوید که ()B را فراخوانی میکند، اما هیچ اطلاعی درباره اینکه ()B در ادامه چه چیزی را فراخوانی میکند به ما نمیدهد.
برای ساخت یک گراف مفید، ما به طور مداوم این روابط را دنبال میکنیم: A → B → C → D. هر جستجو گراف را یک سطح دیگر گسترش میدهد، و به همین دلیل است که ما به یک استراتژی پیمایش مانند BFS نیاز داریم تا چندین گام را به روشی کارآمد کاوش کنیم.
برای مثال:
// Assuming a graph with 4 hops
A --> B --> C --> D --> E
اگر از A شروع کنیم و عمق سه را درخواست کنیم (۳ گام)، ما این را میخواهیم:
Depth 0: A
Depth 1: B
Depth 2: C
Depth 3: D
جستجوی سطح اول یا پهناساخت (BFS) گزینهای بسیار مناسب است زیرا گراف صراحتاً بر اساس عمق گام سازماندهی شده است. این پیمایش مرزها (frontier) را مدیریت میکند:
current frontier
↓
discover neighbors
↓
next frontier
↓
discover neighbors
یک پیادهسازی اولیه به این شکل است:
const walk = async (
start: Handle,
direction: 'incoming' | 'outgoing',
limit: number
) => {
// Traverse the call graph one level at a time, starting from the given symbol.
let frontier: Handle[] = [start];
for (
let depth = 0;
depth < limit &&
frontier.length > 0;
depth++
) {
// Resolve the next level in parallel, limiting concurrency to six lookups.
const results =
await mapLimit(
frontier,
6,
handle =>
oneHop(
handle,
direction
)
);
const next: Handle[] = [];
frontier.forEach(
(handle, index) => {
for (
const other
of results[index]
) {
// Register newly discovered symbols before adding their relationships.
if (
!registry.has(
other.node.id
)
) {
registry.register(
other.node.uri,
other.node.name,
other.node.kind,
other.node.pos
);
}
// Preserve the direction of the call relationship in the graph.
if (
direction === 'outgoing'
) {
addEdge(
handle.node.id,
other.node.id
);
} else {
addEdge(
other.node.id,
handle.node.id
);
}
next.push(other);
}
}
);
// Continue the traversal from the symbols discovered at this depth.
frontier = next;
}
};
تابع کمکی mapLimit تعداد درخواستهای همزمان به سرور زبان را تحت کنترل نگه میدارد:
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
// Run at most `limit` async operations at the same time.
const results =
new Array<R>(items.length);
let next = 0;
// Create workers that share the next available item.
const workers =
Array.from(
{
length:
Math.min(
limit,
items.length
),
},
async () => {
while (
next < items.length
) {
const index = next++;
results[index] =
await fn(
items[index]
);
}
}
);
await Promise.all(workers);
return results;
}
تفاوت کلیدی این است که BFS فقط یک محاسبات محلی است، در حالی که حل یک نماد اغلب مستلزم آن است که از ابزار زبان VS Code بخواهیم کار واقعی انجام دهد.
برای هر نمادی که بازدید میکنیم، Code Graph View ممکن است نیاز داشته باشد سرویس زبان را برای فراخوانیهای ورودی یا خروجیاش پرسوجو کند. این جستجوها میتوانند شامل تجزیه فایلهای منبع، حل نمادها و ارتباط با سرور زبان باشند. با رشد گراف، تعداد این درخواستها نیز به همراه آن رشد میکند.
بنابراین، حتی اگر خود پیمایش ساده باشد، انجام متوالی آن جستجوها میتواند کل روند را بسیار کند کند. mapLimit با اجازه دادن به اجرای همزمان چندین درخواست مستقل به ابزار زبان، این مشکل را حل میکند، در حالی که همچنان حدودی برای همزمانی تعیین میکند تا سرویس زبان تحت فشار قرار نگیرد.
۹. مدیریت چرخهها (حلقهها)
کد واقعی یک درخت نیست. بلکه یک گراف است. این بدان معناست که وجود چرخهها امری طبیعی است.
برای مثال:
یک پیمایش بازگشتی سادهلوحانه میتواند به طور نامحدود ادامه پیدا کند. بنابراین ما نیاز داریم آنچه را که قبلاً کاوش کردهایم ردیابی کنیم. اما یک نکته ظریف وجود دارد. یک مورد ساده:
Set<string>
اگر بتوان از عمقهای مختلف به همان گره رسید، همیشه کافی نیست. در عوض، میتوانیم هنگام بررسی یک گره، میزان عمق پیمایش باقیمانده را ذخیره کنیم.
const explored =
new Map<string, number>();
سپس:
// Track the deepest remaining traversal already performed for this node.
const key =
`${direction}:${node.id}`;
if (
(explored.get(key) ?? -1)
< remainingDepth
) {
// Revisit only when this traversal can explore deeper than before.
explored.set(
key,
remainingDepth
);
next.push(node);
}
این بدان معناست که اگر قبلاً به یک گره با یک گام باقیمانده رسیدهایم، اما بعداً آن را با سه گام باقیمانده کشف کنیم، مجاز هستیم دوباره آن را بررسی کنیم. این کار دقیقتر از برخورد با گره به عنوان «بازدید شده» است.
۱۰. محدود کردن گراف
یک گراف میتواند بسیار سریع رشد کند. یک تابع با اتصالات زیاد ممکن است دهها فراخواننده داشته باشد. هر کدام از آن فراخوانندهها نیز ممکن است دهها فراخواننده مخصوص به خود را داشته باشند. به همین دلیل، سازنده گراف باید محدودیتهای صریحی داشته باشد.
برای مثال:
const MAX_SYMBOLS = 400;
const MAX_CALLS_PER_SYMBOL = 50;
const HOP_CONCURRENCY = 6;
اگر گراف به یک محدودیت برسد، ما نمیخواهیم وانمود کنیم که گراف کامل است. در عوض:
let truncated = false;
و:
if (
registry.size >=
MAX_SYMBOLS
) {
truncated = true;
continue;
}
دادههای حاصل (GraphData) سپس میتوانند به رابط کاربری (UI) بگویند:
This graph was truncated.
این روش بهتر از آن است که اجازه دهیم یک پایگاه کد به طور غیرمنتظره بزرگ باعث شود افزونه (extension) به نظر برسد که قفل شده است.
۱۱. فیلتر کردن فایلها
سرور زبان ممکن است روابطی را به فایلهایی برگرداند که بخشی از برنامهای که در حال بررسی آن هستیم نیستند. این موارد میتوانند فایلهای ساخت (build) یا خروجیهایی باشند که توسط نصب وابستگیها یا عملوندهای ساخت/زمان اجرای خاص زبان ایجاد شدهاند. برای مثال، یک پروژه تایپاسکریپت میتواند به موارد زیر منجر شود:
node_modules
یک پروژه پایتون ممکن است به موارد زیر منجر شود:
site-packages
ما میتوانیم این مسیرها را قبل از اضافه کردن به گراف فیلتر کنیم.
const DEPENDENCY_DIRS =
/\/(node_modules|vendor|target|\.venv|venv|site-packages|__pycache__|build|obj|\.dart_tool)\//;
function isWorkspaceFile(
uri: vscode.Uri
): boolean {
if (
uri.scheme !== 'file' ||
DEPENDENCY_DIRS.test(uri.path)
) {
return false;
}
return !!vscode.workspace
.getWorkspaceFolder(uri);
}
این کار باعث میشود گراف روی فضای کاری کاربر متمرکز بماند. همچنین تمایز مهمی را بین هوش زبان و رفتار برنامه نشان میدهد. سرور زبان به ما میگوید چه چیزی را میتواند حلوفصل کند. سازنده گراف ما تصمیم میگیرد چه چیزی باید بخشی از گراف شود.
۱۲. چرا برخی از یالها به سکوت ناپدید میشوند
در یک گراف بزرگ، برخی از توابع که به وضوح یکدیگر را فراخوانی میکنند، ممکن است بدون هیچ ارتباطی به پایان برسند و هیچچیز خطایی را گزارش نکند. مشکل این است که یک CallHierarchyItem به حالت سرویس زبان گره خورده است. اگر آن حالت قدیمی شود، درخواست برای فراخوانندگان یا فراخوانشوندگان آن میتواند یک آرایه خالی برگرداند. از دیدگاه سازنده گراف، این دقیقاً شبیه به تابعی بدون فراخواننده به نظر میرسد.
در پیادهسازی VS Code، جلسات سلسلهمراتب فراخوانی برای تعداد محدودی از درخواستهای اخیر نگه داشته میشوند. خزنده (crawler) ما همچنین میتواند چندین جستجو را به طور همزمان در حال اجرا داشته باشد، بنابراین موارد قدیمیتر ممکن است در طول پیمایش گراف غیرقابل استفاده شوند. سازنده گراف با این موضوع به سه روش برخورد میکند.
۱. دادههای ساده را ذخیره میکند، نه موارد زنده (live items).
هر تابع با یک NodeRef نشان داده میشود که شامل شناسه (ID)، نشانی (URI)، نام، نوع و موقعیت آن است. نتایج یکگامه نیز به عنوان NodeRef کش میشوند. این کار اطلاعات کافی را برای بازسازی یک آیتم سلسلهمراتب فراخوانی در صورت نیاز به ما میدهد.
interface NodeRef {
id: string;
uri: vscode.Uri;
name: string;
kind: vscode.SymbolKind;
pos: vscode.Position;
}
۲. سن هر آیتم آمادهشده را ردیابی میکند.
یک شمارنده اپوک سراسری (global epoch counter) هر زمان که یک آیتم سلسلهمراتب فراخوانی جدید آماده میشود، افزایش مییابد. هر دسته (handle) اپوکی را که آیتم آن در آن ایجاد شده است ثبت میکند. اگر یک آیتم به اندازه کافی قدیمی شود، خزنده یک مورد تازه را از NodeRef ذخیرهشده آماده میکند.
۳. نتایج خالی مشکوک را مجدداً تلاش میکند.
یک نسخه اصلاحشده از oneHop به شکل زیر است:
const SESSION_WINDOW = 7;
// Refresh stale language-tooling references and retry once if necessary.
for (let attempt = 0; attempt < 2; attempt++) {
const stale =
!current.item ||
epoch - current.epoch > SESSION_WINDOW;
if (stale) {
// Re-resolve the symbol before using an expired CallHierarchyItem.
const fresh = await prepareFresh(
current.node.uri,
current.node.pos
);
if (!fresh) {
return [];
}
current = fresh;
}
const items =
await lookup(
current.item!,
direction
);
// A stale reference may return nothing, so invalidate it and retry once.
if (
items.length === 0 &&
attempt === 0 &&
epoch - current.epoch > SESSION_WINDOW
) {
current = {
node: current.node,
epoch: -1,
};
continue;
}
// Cache the resolved relationships to avoid repeating the language-tooling lookup.
hopCache.set(key, {
nodes: items.map(refOf),
at: Date.now(),
});
return items.map(child => ({
node: refOf(child),
item: child,
epoch: current.epoch,
}));
}
در اینجا، lookup شکل کوتاهشدهای برای دستور vscode.provideIncomingCalls یا vscode.provideOutgoingCalls است که قبلاً بحث شد. محدودیت دقیق جلسه یک جزئیات پیادهسازی در VS Code است تا چیزی که افزونه باید به آن وابسته باشد. بنابراین خزنده فرض نمیکند که یک محدودیت خاص همیشه وجود خواهد داشت. SESSION_WINDOW به سادگی یک آستانه محافظهکارانه برای بازنشانی دستههای قدیمی به ما میدهد.
این کار یالهای از دست رفته را کاهش میدهد، اما نمیتواند یک گراف کامل را تضمین کند. ابزارهای زبان همچنان ممکن است اطلاعات ناقصی برگردانند یا در حل برخی روابط شکست بخورند.
درس گستردهتر برای APIهای سرویس زبان فراتر از سلسلهمراتب فراخوانی اعمال میشود: آنچه را که برای بازسازی یک شیء از یک سرویس زبان نیاز دارید ذخیره کنید، نه خود شیء را. با یک نتیجه خالی از یک حالت قدیمی به عنوان به طور بالقوه ناشناخته برخورد کنید، نه به طور خودکار به عنوان هیچکدام.
۱۳. اتصال گراف به یک Webview
هنگامی که گراف ساخته شد، افزونه به جایی برای نمایش آن نیاز دارد. Webviewهای VS Code گزینهای کاملاً مناسب هستند.
میزبان افزونه (extension host) پنل را ایجاد میکند:
const panel =
vscode.window.createWebviewPanel(
'codeGraphView',
'Code Graph',
vscode.ViewColumn.Beside,
{
enableScripts: true,
retainContextWhenHidden: true,
}
);
گراف به عنوان دادههای قابل سریالسازی به Webview ارسال میشود:
panel.webview.postMessage({
command: 'graphData',
data: graphData,
});
Webview سپس میتواند آن را دریافت کند:
window.addEventListener(
'message',
event => {
const message =
event.data;
if (
message.command !==
'graphData'
) {
return;
}
renderGraph(
message.data
);
}
);
در این مرحله، سمت سرور زبان مشکل کامل شده است.
ما تبدیل کردهایم:
source code
به:
symbols + relationships
و سپس به:
GraphData
لایه تجسمسازی اکنون میتواند از آن دادهها برای رندر کردن گراف استفاده کند.
میتوان از کتابخانهای مانند ELK برای محاسبه موقعیتهای گراف بدون تأثیرگذاری بر منطق ساخت گراف استفاده کرد.
۱۴. تست کردن سازنده گراف
تست کردن این نوع افزونه میتواند دشوار باشد اگر هر تست نیاز به یک نمونه در حال اجرای VS Code و یک سرور زبان واقعی داشته باشد. رویکرد بهتر این است که منطق ساخت گراف را از خود VS Code ایزوله کنیم. خزنده واقعاً فقط به چند عملیات نیاز دارد:
prepareCallHierarchy
provideIncomingCalls
provideOutgoingCalls
ما میتوانیم پیادهسازی جعلی (Mock) از این دستورات ایجاد کنیم. برای مثال:
const sessions = new Map();
let sessionCounter = 0;
async function executeCommand(
command,
...args
) {
// Simulate VS Code creating a session when resolving a symbol.
if (
command ===
'vscode.prepareCallHierarchy'
) {
const id =
'session-' +
++sessionCounter;
sessions.set(id, true);
return [
createFakeItem(
args,
id
),
];
}
// Simulate call lookups that depend on a still-valid session.
if (
command ===
'vscode.provideIncomingCalls' ||
command ===
'vscode.provideOutgoingCalls'
) {
const item = args[0];
// Return nothing when the CallHierarchyItem belongs to an expired session.
if (
!sessions.has(
item.sessionId
)
) {
return [];
}
return getFakeCalls(
item
);
}
}
این ماک میتواند حالتهای خاص (Edge Cases) مانند وضعیت منقضیشدهی سلسلهمراتب فراخوانی را شبیهسازی کند. نکتهی مهم این است که اگر شبیهساز از محدودیت خاصی برای نشست (Session Limit) استفاده کند، باید آن را به عنوان یک مدل تستی در نظر گرفت، نه به طور خودکار به عنوان یک تضمین رسمی در APIهای VS Code.
سپس میتوانیم یک گراف قطعی (Deterministic) تولید کنیم و خروجی خزنده (Crawler) را با یک الگوریتم جستجوی سطح اول (BFS) مرجع و ساده مقایسه کنیم.
برای مثال:
flowchart LR
A[A] --> B[B]
A --> C[C]
B --> D[D]
C --> D
D --> E[E]
پیادهسازی مرجع، یالهای مورد انتظار را میشناسد. خزنده پروداکشن در برابر سرویس زبان جعلی اجرا میشود. اگر این دو نتیجه با هم متفاوت باشند، تست شکست میخورد. این رویکرد به ما اجازه میدهد منطق پیچیده گراف را بدون وابستگی کامل به زمان اجرای ویرایشگر (Runtime) تست کنیم.
15. محدودیتهای یک گراف کد
یک سلسلهمراتب فراخوانی مبتنی بر Language Server مفید است، اما نمایش کاملی از اجرای برنامه نیست. حل کردن برخی از روابط برای تحلیل استاتیک سلسلهمراتب فراخوانی میتواند دشوار یا غیرممکن باشد.
مثالها عبارتند از:
- پخش پویا (Dynamic Dispatch)
- بازتاب (Reflection)
- تکمیل وابستگی (Dependency Injection)
- تولیدکنندگان رویداد (Event Emitters)
- بازگشتبهها (Callbacks)
- کدهای تولیدشده در زمان اجرا (Runtime-generated Code)
- رفتارهای خاص فریمورک
این مورد را در نظر بگیرید:
eventEmitter.on(
'payment.completed',
handlePayment
);
یک توسعهدهنده ممکن است متوجه شود که این کد یک رابطه در زمان اجرا بین رویداد و handlePayment ایجاد میکند. یک گراف فراخوانی استاتیک ممکن است آن رابطه را به عنوان یک فراخوانی تابع معمولی نشان ندهد. بنابراین، کیفیت گراف تا حدی به سرور زبان (Language Server) و انواعی از روابط که میتواند حل کند بستگی دارد.
به همین دلیل است که گراف باید به عنوان یک تقریب معنایی در نظر گرفته شود، نه به عنوان یک مدل زمان اجرای بینقص. همچنین یک بعدِ وابسته به زبان نیز وجود دارد.
خودِ سازندهی گراف میتواند تا حد زیادی مستقل از زبان باقی بماند، اما افزونههای مختلف زبان ممکن است سطح متفاوتی از پشتیبانی را برای نمادهای سند (Document Symbols) و سلسلهمراتب فراخوانی فراهم کنند.
نتیجهگیری
ساخت یک گراف کد نیازی به نوشتن کامپایلر یا پیادهسازی یک تجزیهکننده (Parser) از صفر ندارد. VS Code در حال حاضر مقدار قابلتوجهی از اطلاعات معنایی را از طریق APIهای زبان خود در اختیار میگذارد.
فرآیند اصلی عبارت است از:
Document -> Document Symbols -> Call Hierarchy -> Graph Nodes + Edges -> BFS Traversal -> GraphData -> Visualization
مهمترین تصمیمات مهندسی درباره رسم کردن گراف نیستند. آنها درباره انتخاب یک مدل گراف مفید، ایجاد شناسههای پایدار برای نمادها، تفسیر درست فراخوانیهای ورودی و خروجی، کنترل عمق پیمایش و همروندی (Concurrency)، مدیریت چرخهها، و در نظر گرفتن وضعیت سرور زبان به عنوان چیزی که میتواند تغییر کند، هستند.
هنگامی که این قطعات در جای خود قرار گرفتند، تصویرسازی (Visualisation) به یک مسئلهی جداگانه تبدیل میشود. این جداسازی همان چیزی است که معماری را فراتر از یک افزونهی سادهی VS Code مفید میسازد.
همین مدل گراف در نهایت میتواند قدرتبخش اکتشاف وابستگی، تحلیل تأثیر تغییرات، نماهای معماری، انتخاب متن (Context) برای هوش مصنوعی، و روشهای دیگر برای ناوبری در پایگاههای کدِ روزافزون پیچیده باشد.
من یک نسخه کاری بر اساس این مفاهیم ساختهام که در آدرس زیر قابل دسترسی است: https://github.com/otobongfp/code-graph-view.
مشتاقانه منتظر دیدن تمام کارهای جذابی هستم که میتوانید با گرافها برای کمک به فرآیند مهندسی نرمافزار بسازید.
نظر مهندس بهمن آبادی: ساخت گراف کد با کمک APIهای زبان VS Code ابزاری قدرتمند برای درک کدهای تولید شده توسط هوش مصنوعی ارائه میدهد، اما مدیریت هوشمندانهی وضعیتهای منسوخ و محدود کردن عمق پیمایش، کلید پایداری و عملکرد آن است. به عنوان مدرس، پیشنهاد میکنم برای جلوگیری از افت پردازش در پروژههای بزرگ، حتماً مکانیزمهای Caching و مدیریت همزمانی (Concurrency) را پیادهسازی کنید.