3
0

More than 1 year has passed since last update.

VSCodeのクラスや関数の階層構造を見やすくしようとした話

Last updated at Posted at 2022-11-09

この記事は、東京大学工学部電子情報工学科/電気電子工学科の後期実験「大規模ソフトウェアを手探る」のレポートとして作成されました。

コード内の関数やクラスを一覧で見たい

VSCodeで大規模なコードを編集して開発する場面になった時、例えばこのファイルはどのような機能を担っているのか、それはどのような関数やクラスによって実装されているのか、俯瞰で見たいと思ったことがあると思います。

私たちは、可能であれば関数やクラスの一覧だけではなく、その階層構造まで(例えばクラスの中で定義されているメンバ関数や、関数の中で一時的に定義されている関数など)表せないかと考え、それを目標に始めました。

以下ではこの問題を

  1. コードの階層構造を分析する
  2. それを何らかの方法でVSCode上に表示する

という2つに分割して考えます。

ソースコードを手探る

サイドバーのタブを手探る

まずVSCode上にどのように表示するかと考えた時に、真っ先に考えたのはサイドバーにタブを追加する方法です。
コードの階層構造を表示するための専用のタブを作るべく、まずは元々ある「Explorer」「Search」などのタブのコードを参考にしようと考えました。

まずはExplorerで検索し、見つけた該当コードが下になります。

src/vs/workbench/contrib/files/browser/explorerViewlet.ts
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 });

どうやらこのコードでは

  1. idtitleなどを持つオブジェクトを作る
  2. 1をviewContainerRegistry.registerViewContainer関数に渡す(その時にViewContainerLocation.Sidebarと、コンテナの中での表示位置を表すオプションも指定しているように見えます)
  3. 2の関数の返り値をVIEW_CONTAINERというViewContainer型の変数に受け取り、export

という手順を経ているようです。
ここからサイドバーのページはViewContainerという単位で管理されており、それを全て保持しておくViewContainerRegistryという置き場所があって、そこのserviceとしてregisterViewContainerという関数が用意されているものだと予想できます。

ちなみに、viewRegistryの出処を探ると

src/vs/workbench/contrib/files/browser/explorerViewlet.ts
import { Registry } from 'vs/platform/registry/common/platform';

// ...(省略)...

const viewContainerRegistry = Registry.as<IViewContainersRegistry>(Extensions.ViewContainersRegistry);

となっていて、import元では

src/vs/platform/registry/common/platform.ts
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という変数で検索をかけました。
今回自分がやりたいことと関係の有りそうな部分を考えると、下のような記述が見つかりました。

src/vs/workbench/contrib/files/browser/explorerViewlet.ts
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.registerViewsviewsRegistry.deregisterViewsで操作し、そこの操作先としてVIEW_CONTAINERを指定しているということになります。

Open Definition to Sideを手探る

後述するように、途中から方針を「クラスや関数の階層構造が書かれたテキストファイルを生成し、それを右画面に開く」という方針に変更しました。下はそのために読み解いたコードです。
生成したファイルを画面分割で右側に開きたかったため、まずは「Open to the Side」と文字列検索をかけ、Open to the Sideというラベルを定義している部分を見つけました。

src/vs/workbench/contrib/files/browser/fileActions.contribution.ts
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で

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されていることがわかり、

src/vs/workbench/contrib/files/browser/fileCommands.ts
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をクリックした時の動作だということがわかります。

実装

サイドバーへのタブを追加

以上のことを踏まえて、実装したのが下のコードになります。

src/vs/workbench/contrib/sourceTree/browser/sourceTree.contribution.ts
+ /*---------------------------------------------------------------------------------------------
+  *  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);
+ 

ここでやっていることは

  1. ViewContainerRegistry.registerViewContainerを呼び出してViewをRegistryに追加し、変数viewContainerに返り値を受ける
  2. viewDescriptorを定義
  3. 一番下でviewDescriptorを登録

という流れです。
また、コード中にSourceTreeViewというクラスも登場しますが、これも下のように定義したクラスです。

src/vs/workbench/contrib/sourceTree/browser/sourceTreeView.ts
+ /*---------------------------------------------------------------------------------------------
+  *  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に

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のソースコードのみを対象としました。

src/vs/workbench/contrib/files/browser/fileCommands.ts
// ...(省略)...
+ 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);
+ 		}
+ 	}
+ });
+ // ...(以下省略)...
src/vs/workbench/contrib/files/browser/pickup.ts
+ /*---------------------------------------------------------------------------------------------
+  *  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と同じ要領で作ったファイルを開く、という手順です。

結果

  1. 下のように、SourceTreeを表示するためのタブが新たに追加されました。
    image.png

  2. 下のようなtest.pyを用意し、

test.py
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をクリックすると
スクリーンショット (14).png
image.png

このように関数とクラスの定義をまとめたテキストファイルが生成され、それが右側に開かれています。

ビルド方法について

ビルドのやり方についてはこの記事で説明しています

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0