この記事は、東京大学工学部電子情報工学科/電気電子工学科の後期実験「大規模ソフトウェアを手探る」のレポートとして作成されました。
コード内の関数やクラスを一覧で見たい
VSCodeで大規模なコードを編集して開発する場面になった時、例えばこのファイルはどのような機能を担っているのか、それはどのような関数やクラスによって実装されているのか、俯瞰で見たいと思ったことがあると思います。
私たちは、可能であれば関数やクラスの一覧だけではなく、その階層構造まで(例えばクラスの中で定義されているメンバ関数や、関数の中で一時的に定義されている関数など)表せないかと考え、それを目標に始めました。
以下ではこの問題を
- コードの階層構造を分析する
- それを何らかの方法でVSCode上に表示する
という2つに分割して考えます。
ソースコードを手探る
サイドバーのタブを手探る
まずVSCode上にどのように表示するかと考えた時に、真っ先に考えたのはサイドバーにタブを追加する方法です。
コードの階層構造を表示するための専用のタブを作るべく、まずは元々ある「Explorer」「Search」などのタブのコードを参考にしようと考えました。
まずはExplorerで検索し、見つけた該当コードが下になります。
export const VIEW_CONTAINER: ViewContainer = viewContainerRegistry.registerViewContainer({
id: VIEWLET_ID,
title: localize('explore', "Explorer"),
ctorDescriptor: new SyncDescriptor(ExplorerViewPaneContainer),
storageId: 'workbench.explorer.views.state',
icon: explorerViewIcon,
alwaysUseContainerInfo: true,
order: 0,
openCommandActionDescriptor: {
id: VIEWLET_ID,
title: { value: localize('explore', "Explorer"), original: 'Explorer' },
mnemonicTitle: localize({ key: 'miViewExplorer', comment: ['&& denotes a mnemonic'] }, "&&Explorer"),
keybindings: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyE },
order: 0
},
}, ViewContainerLocation.Sidebar, { isDefault: true });
どうやらこのコードでは
-
id
やtitle
などを持つオブジェクトを作る - 1を
viewContainerRegistry.registerViewContainer
関数に渡す(その時にViewContainerLocation.Sidebar
と、コンテナの中での表示位置を表すオプションも指定しているように見えます) - 2の関数の返り値を
VIEW_CONTAINER
というViewContainer
型の変数に受け取り、export
という手順を経ているようです。
ここからサイドバーのページはViewContainer
という単位で管理されており、それを全て保持しておくViewContainerRegistry
という置き場所があって、そこのserviceとしてregisterViewContainer
という関数が用意されているものだと予想できます。
ちなみに、viewRegistry
の出処を探ると
import { Registry } from 'vs/platform/registry/common/platform';
// ...(省略)...
const viewContainerRegistry = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry);
となっていて、import元では
class RegistryImpl implements IRegistry {
private readonly data = new Map<string, any>();
public add(id: string, data: any): void {
Assert.ok(Types.isString(id));
Assert.ok(Types.isObject(data));
Assert.ok(!this.data.has(id), 'There is already an extension with this id');
this.data.set(id, data);
}
public knows(id: string): boolean {
return this.data.has(id);
}
public as(id: string): any {
return this.data.get(id) || null;
}
}
export const Registry: IRegistry = new RegistryImpl();
と定義されており、どうやらRegistry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry);
によってIVIewContainerRegistry
としてレジストリを取得していることは間違いなさそうです。
次に、もっと深く知るために、今度は上でexportされているVIEW_CONTAINER
という変数で検索をかけました。
今回自分がやりたいことと関係の有りそうな部分を考えると、下のような記述が見つかりました。
export class ExplorerViewletViewsContribution extends Disposable implements IWorkbenchContribution {
// ...(途中省略)...
private registerViews(): void {
const viewDescriptors = viewsRegistry.getViews(VIEW_CONTAINER);
const viewDescriptorsToRegister: IViewDescriptor[] = [];
const viewDescriptorsToDeregister: IViewDescriptor[] = [];
// ...(途中省略)...
if (viewDescriptorsToRegister.length) {
viewsRegistry.registerViews(viewDescriptorsToRegister, VIEW_CONTAINER);
}
if (viewDescriptorsToDeregister.length) {
viewsRegistry.deregisterViews(viewDescriptorsToDeregister, VIEW_CONTAINER);
}
}
// ...(以下省略)...
}
ここでは、registerするべきViewとdeRegisterするべきViewをviewsRegistry.registerViews
とviewsRegistry.deregisterViews
で操作し、そこの操作先としてVIEW_CONTAINER
を指定しているということになります。
Open Definition to Sideを手探る
後述するように、途中から方針を「クラスや関数の階層構造が書かれたテキストファイルを生成し、それを右画面に開く」という方針に変更しました。下はそのために読み解いたコードです。
生成したファイルを画面分割で右側に開きたかったため、まずは「Open to the Side」と文字列検索をかけ、Open to the Sideというラベルを定義している部分を見つけました。
const openToSideCommand = {
id: OPEN_TO_SIDE_COMMAND_ID,
title: nls.localize('openToSide', "Open to the Side")
};
// ...(省略)...
MenuRegistry.appendMenuItem(MenuId.OpenEditorsContext, {
group: 'navigation',
order: 10,
command: openToSideCommand,
when: isFileOrUntitledResourceContextKey
});
// ...(省略)...
MenuRegistry.appendMenuItem(MenuId.ExplorerContext, {
group: 'navigation',
order: 10,
command: openToSideCommand,
when: ContextKeyExpr.and(ExplorerFolderContext.toNegated(), ResourceContextKey.HasResource)
});
このようにopenToSideCommand
というコマンドを用意し、MenuRegistry.appendMenuItem
関数でMenuRegistryに追加していることが読み取れます。
ここで、Open to the Sideをクリックした際の動作とこのコマンドがどのように結びついているのか考えた時に、openToSideCommand
が持つidという属性を通じて結びついているのではと考えました。
OPEN_TO_SIDE_COMMAND_ID
はsrc/vs/workbench/contrib/files/browser/fileConstants.tsで
export const OPEN_TO_SIDE_COMMAND_ID = 'explorer.openToSide';
このように定義されているもの以外ワークスペースに存在しなかったため、今度は「OPEN_TO_SIDE_COMMAND_ID」で検索をかけimportして利用している部分を探しました。
すると、src/vs/workbench/contrib/files/browser/fileCommands.tsでimportされていることがわかり、
KeybindingsRegistry.registerCommandAndKeybindingRule({
weight: KeybindingWeight.WorkbenchContrib,
when: ExplorerFocusCondition,
primary: KeyMod.CtrlCmd | KeyCode.Enter,
mac: {
primary: KeyMod.WinCtrl | KeyCode.Enter
},
id: OPEN_TO_SIDE_COMMAND_ID, handler: async (accessor, resource: URI | object) => {
const editorService = accessor.get(IEditorService);
const listService = accessor.get(IListService);
const fileService = accessor.get(IFileService);
const explorerService = accessor.get(IExplorerService);
const resources = getMultiSelectedResources(resource, listService, editorService, explorerService);
// Set side input
if (resources.length) {
const untitledResources = resources.filter(resource => resource.scheme === Schemas.untitled);
const fileResources = resources.filter(resource => resource.scheme !== Schemas.untitled);
const items = await Promise.all(fileResources.map(async resource => {
const item = explorerService.findClosest(resource);
if (item) {
// Explorer already resolved the item, no need to go to the file service #109780
return item;
}
return await fileService.stat(resource);
}));
const files = items.filter(i => !i.isDirectory);
const editors = files.map(f => ({
resource: f.resource,
options: { pinned: true }
})).concat(...untitledResources.map(untitledResource => ({ resource: untitledResource, options: { pinned: true } })));
await editorService.openEditors(editors, SIDE_GROUP);
}
}
});
このように利用されていることがわかりました。
さて、ここでhandler
というメンバ関数で定義されている部分を読むと、どうやらここがOpen to the Sideをクリックした時の動作だということがわかります。
実装
サイドバーへのタブを追加
以上のことを踏まえて、実装したのが下のコードになります。
+ /*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+ import { localize } from 'vs/nls';
+ import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
+ import { Registry } from 'vs/platform/registry/common/platform';
+ import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer';
+ import { Extensions as ViewExtensions, IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation } from 'vs/workbench/common/views';
+ import { sourceTreeIcon } from 'vs/workbench/contrib/sourceTree/browser/sourceTreeIcons';
+ import { SourceTreeView } from 'vs/workbench/contrib/sourceTree/browser/sourceTreeView';
+ import { VIEWLET_ID, VIEW_ID } from 'vs/workbench/contrib/sourceTree/common/sourceTree';
+
+ const viewContainerRegistry = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry);
+
+ const viewContainer = viewContainerRegistry.registerViewContainer({
+ id: VIEWLET_ID,
+ title: localize('source-tree', "Source Tree"),
+ ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [VIEWLET_ID, { mergeViewWithContainerWhenSingleView: true }]),
+ icon: sourceTreeIcon,
+ order: 0,
+ }, ViewContainerLocation.Sidebar, { doNotRegisterOpenCommand: true });
+
+ const viewDescriptor: IViewDescriptor = {
+ id: VIEW_ID,
+ containerIcon: sourceTreeIcon,
+ name: localize('source-tree', "Source Tree"),
+ ctorDescriptor: new SyncDescriptor(SourceTreeView),
+ order: 1,
+ canToggleVisibility: false,
+ };
+
+ // Register sourceTree default location to sidebar
+ Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([viewDescriptor], viewContainer);
+
ここでやっていることは
-
ViewContainerRegistry.registerViewContainer
を呼び出してViewをRegistryに追加し、変数viewContainer
に返り値を受ける -
viewDescriptor
を定義 - 一番下で
viewDescriptor
を登録
という流れです。
また、コード中にSourceTreeView
というクラスも登場しますが、これも下のように定義したクラスです。
+ /*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+ import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
+ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
+ import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
+ import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
+ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
+ import { IOpenerService } from 'vs/platform/opener/common/opener';
+ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
+ import { IThemeService } from 'vs/platform/theme/common/themeService';
+ import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane';
+ import { EditorResourceAccessor, SideBySideEditor } from 'vs/workbench/common/editor';
+ import { IViewDescriptorService } from 'vs/workbench/common/views';
+ import { IExplorerService } from 'vs/workbench/contrib/files/browser/files';
+ import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
+ import { URI } from 'vs/base/common/uri';
+ import { ILabelService } from 'vs/platform/label/common/label';
+
+ export class SourceTreeView extends ViewPane {
+ private activeFile: URI | undefined;
+ constructor(
+ options: IViewPaneOptions,
+ @IKeybindingService keybindingService: IKeybindingService,
+ @IContextMenuService contextMenuService: IContextMenuService,
+ @IConfigurationService configurationService: IConfigurationService,
+ @IContextKeyService contextKeyService: IContextKeyService,
+ @IViewDescriptorService viewDescriptorService: IViewDescriptorService,
+ @IInstantiationService instantiationService: IInstantiationService,
+ @IOpenerService openerService: IOpenerService,
+ @IThemeService themeService: IThemeService,
+ @ITelemetryService telemetryService: ITelemetryService,
+ @IEditorService private readonly editorService: IEditorService,
+ @IExplorerService private readonly explorerService: IExplorerService,
+ @ILabelService private readonly labelService: ILabelService,
+ ) {
+ super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService);
+
+ }
+
+ get name(): string {
+ if (this.activeFile === undefined) {
+ return 'Source Tree';
+ }
+ return this.labelService.getUriLabel(this.activeFile!);
+ }
+
+ override get title(): string {
+ return this.name;
+ }
+
+ override set title(_: string) {
+ // noop
+ }
+
+ override renderHeader(container: HTMLElement): void {
+ super.renderHeader(container);
+
+ const titleElement = container.querySelector('.title') as HTMLElement;
+ const setHeader = () => {
+ titleElement.textContent = this.name;
+ titleElement.title = this.name;
+ };
+ this._register(this.editorService.onDidActiveEditorChange(setHeader));
+ setHeader();
+
+ }
+
+ override renderBody(container: HTMLElement): void {
+ super.renderBody(container);
+ // When the active file changes, update the view
+ this._register(this.editorService.onDidActiveEditorChange(() => {
+ this.selectActiveFile();
+ }));
+ }
+
+ private selectActiveFile(): void {
+ const activeFile = EditorResourceAccessor.getCanonicalUri(this.editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY });
+ if (activeFile) {
+ this.explorerService.select(activeFile, true);
+ }
+ }
+ }
+
また、src/vs/workbench/workbench.common.main.tsに
// View Tree
+ import 'vs/workbench/contrib/sourceTree/browser/sourceTree.contribution';
+ import 'vs/workbench/contrib/sourceTree/browser/sourceTreeView';
というimport文も追加しています。
ワークベンチ側からimportをしてあげないとタブが追加されてくれないため、この2行は必須となっています。
しかし、本来であれば上のSourceTreeViewがレンダリングされるはずなのですが、どのように文字や図などをレンダーするのかがわからず、ここで断念しています。
コードの階層構造を表示
上で述べたようにSourceTreeViewの表示に失敗してしまったため、今度は「関数やクラスの定義の一覧を書いたテキストファイルを生成して、それを右側に開く」という方針に変更しました。
Open Definition to Sideを手探るを参考にして、下のように実装しました。
なお、今回はPythonのソースコードのみを対象としました。
// ...(省略)...
+ KeybindingsRegistry.registerCommandAndKeybindingRule({
+ weight: KeybindingWeight.WorkbenchContrib,
+ when: ExplorerFocusCondition,
+ id: VIEW_TREE_COMMAND_ID, handler: async (accessor, resource: URI | object) => {
+ // open side to show tree
+ const editorService = accessor.get(IEditorService);
+ const listService = accessor.get(IListService);
+ const fileService = accessor.get(IFileService);
+ const explorerService = accessor.get(IExplorerService);
+ const resources = getMultiSelectedResources(resource, listService, editorService, explorerService);
+
+ // Set side input
+ if (resources.length) {
+ const fileResources = resources.filter(resource => resource.scheme !== Schemas.untitled);
+
+ const items = await Promise.all(fileResources.map(async resource => {
+ const item = explorerService.findClosest(resource);
+ if (item) {
+ // Explorer already resolved the item, no need to go to the file service #109780
+ return item;
+ }
+
+ return await fileService.stat(resource);
+ }));
+ const originalFiles = items.filter(i => !i.isDirectory);
+ const formattedResources = [] as URI[];
+ for (const file of originalFiles) {
+ if (file === undefined) {
+ } else {
+ const filePath = file.resource.fsPath;
+ const funcListPath = filePath + '.functionList.text';
+ // generate function list file
+ const { classList, functionList: funcList } = extractDef(filePath);
+
+ // write file list file
+ fs.writeFileSync(funcListPath, '');
+ const writeStream = fs.createWriteStream(funcListPath);
+
+ // write class list
+ writeStream.write('[method]\n');
+ for (const line of funcList) {
+ writeStream.write(line + '\n');
+ }
+
+ writeStream.write('\n[class]\n');
+ for (const classLine of classList) {
+ writeStream.write(classLine + '\n');
+ }
+
+ writeStream.end();
+ formattedResources.push(URI.file(funcListPath));
+ }
+ }
+ const formattedItems = await Promise.all(formattedResources.map(async resource => {
+ const item = explorerService.findClosest(resource);
+ if (item) {
+ // Explorer already resolved the item, no need to go to the file service #109780
+ return item;
+ }
+
+ return await fileService.stat(resource);
+ }));
+ const formattedFiles = formattedItems.filter(i => !i.isDirectory);
+ const formattedEditors = formattedFiles.map(f => ({
+ resource: f.resource,
+ options: { pinned: true }
+ }));
+
+ await editorService.openEditors(formattedEditors, SIDE_GROUP);
+ }
+ }
+ });
+ // ...(以下省略)...
+ /*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+ /* eslint-disable local/code-import-patterns */
+ import * as fs from 'fs';
+
+ enum DeclareType {
+ function,
+ class,
+ }
+
+ class DeclItem {
+ private readonly _parent: DeclItem | undefined;
+ private readonly _children: (DeclItem)[] = [];
+ private readonly _type: DeclareType;
+ private readonly _spaces: number;
+ private readonly _text: string;
+ public readonly depth: number = 0;
+ constructor(
+ readonly parent: DeclItem | undefined,
+ readonly type: DeclareType,
+ readonly spaces: number,
+ readonly text: string,
+ ) {
+ this._parent = parent;
+ this._type = type;
+ this._spaces = spaces;
+ this._text = text;
+ this._parent?._children.push(this);
+ this.depth = this._parent === undefined ? 0 : this._parent.depth + 1;
+ }
+ get spaceCount() {
+ return this._spaces;
+ }
+
+ get parentItem() {
+ return this._parent;
+ }
+
+ get itemType() {
+ return this._type;
+ }
+
+ get title() {
+ return this._text;
+ }
+
+ get children() {
+ return this._children;
+ }
+ }
+
+ const countSpacesAtBeginning = (line: string): number => {
+ return line.search(/\S|$/);
+ };
+
+ const checkLineType = (line: string): DeclareType | undefined => {
+
+ const checkElementType = (element: string): DeclareType | undefined => {
+ if (element === 'def') {
+ return DeclareType.function;
+ } else if (element === 'class') {
+ return DeclareType.class;
+ } else {
+ return undefined;
+ }
+ };
+
+ const isBeforeDefElement = (element: string): boolean => {
+ return element === 'async' || element === '';
+ };
+
+ const elements = line.split(/\s+/);
+ for (const element of elements) {
+ if (isBeforeDefElement(element)) {
+ continue;
+ } else if (checkElementType(element) !== undefined) {
+ // functionまたはclassの定義文
+ return checkElementType(element);
+ }
+ }
+ return undefined;
+ };
+
+ const pushAllChildren = (parent: DeclItem, list: string[]) => {
+ list.push(parent.title);
+ for (const child of parent.children) {
+ pushAllChildren(child, list);
+ }
+ };
+
+ export const extractDef = (inputFilePath: string) => {
+ const roots = [] as DeclItem[];
+ let currentRoot: DeclItem | undefined = undefined;
+ let fileContent = '';
+ try {
+ fileContent = fs.readFileSync(inputFilePath, 'utf8');
+ } catch (err) {
+ }
+ const lines = fileContent.split('\n');
+ for (const line of lines) {
+ const lineType = checkLineType(line);
+ if (lineType !== undefined) {
+ // functionまたはclassの定義文
+ const spaces = countSpacesAtBeginning(line);
+ if (spaces === 0 || currentRoot === undefined) {
+ const newItem = new DeclItem(undefined, lineType, 0, line);
+ roots.push(newItem);
+ currentRoot = newItem;
+ } else {
+ let parent: DeclItem | undefined = currentRoot;
+ while (parent !== undefined && parent.spaceCount >= spaces) {
+ parent = parent.parentItem;
+ }
+ const newItem: DeclItem = new DeclItem(parent, lineType, spaces, line);
+ if (parent === undefined) {
+ roots.push(newItem);
+ }
+ currentRoot = newItem;
+ }
+ }
+ }
+ const classList = [] as string[];
+ const functionList = [] as string[];
+ for (const root of roots) {
+ if (root.itemType === DeclareType.class) {
+ pushAllChildren(root, classList);
+ } else {
+ pushAllChildren(root, functionList);
+ }
+ }
+ return {
+ classList,
+ functionList,
+ };
+ };
+
src/vs/workbench/contrib/files/browser/pickup.tsのextractDef
関数では、階層構造を1つのツリーとして考えた時、1つのノードに当たるものをDeclItemというクラスで定義し、コードからそのツリーを構成するという方法を取っています。
ツリーが作り終わったら深さ優先探索で全ノードを訪れ、それを文字列型として返します。
それを受け取ったsrc/vs/workbench/contrib/files/browser/fileCommands.tsのhandler
関数はそれを一行一行ファイルに書き込み、Open Definition to Sideと同じ要領で作ったファイルを開く、という手順です。
結果
def func1():
class Hoge:
def __init__(self):
self.greet = "Hello"
def func2():
print("hoge")
hoge = Hoge()
print(hoge.greet)
class Hoge2:
def func3():
print()
def func4():
print()
このtest.py
を右クリックし、下画像のように表示されるView Treeをクリックすると
このように関数とクラスの定義をまとめたテキストファイルが生成され、それが右側に開かれています。
ビルド方法について
ビルドのやり方についてはこの記事で説明しています