Repository: nicoespeon/abracadabra
Pull request #173 (2020-10-06 12:48:32)

Extract class
Hi! Sorry I took so long.

I connect Extract Class refactoring to abracadabra. I skip implementations for RefactoringWithActionProvider and register command/provider directly. I hope you fix it yourself if needed.

There are some bugs for not trivial situations like class extraction from children class, getters/setters. But I don't have a definite solution. I will help with it if a issue opens.


Patch:
diff --git a/package.json b/package.json
index fdab09ee..00424747 100644
--- a/package.json
+++ b/package.json
@@ -84,7 +84,8 @@
     "jscodeshift": "0.6.4",
     "pluralize": "8.0.0",
     "react-codemod": "5.1.1",
-    "recast": "0.20.3"
+    "recast": "0.20.3",
+    "ts-morph": "^8.1.0"
   },
   "activationEvents": [
     "onCommand:abracadabra.addBracesToArrowFunction",
diff --git a/src/editor/error-reason.ts b/src/editor/error-reason.ts
index d3e07f2e..7951646b 100644
--- a/src/editor/error-reason.ts
+++ b/src/editor/error-reason.ts
@@ -1,6 +1,7 @@
 export { ErrorReason, toString };
 
 enum ErrorReason {
+  CanNotExtractClass,
   DidNotFindFunctionDeclarationToConvert,
   DidNotFindLetToConvertToConst,
   DidNotFindSwitchToConvert,
@@ -46,6 +47,9 @@ enum ErrorReason {
 
 function toString(reason: ErrorReason): string {
   switch (reason) {
+    case ErrorReason.CanNotExtractClass:
+      return didNotFind("a class to extract");
+
     case ErrorReason.DidNotFindFunctionDeclarationToConvert:
       return didNotFind("a function declaration to convert");
 
diff --git a/src/extension.ts b/src/extension.ts
index 2a8a7aa0..be97d470 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -36,6 +36,9 @@ import replaceBinaryWithAssignment from "./refactorings/replace-binary-with-assi
 import splitDeclarationAndInitialization from "./refactorings/split-declaration-and-initialization";
 import splitIfStatement from "./refactorings/split-if-statement";
 import simplifyTernary from "./refactorings/simplify-ternary";
+import { ExtractClassActionProvider } from "./refactorings/extract-class/ExtractClassActionProvider";
+import { ExtractClassCommand } from "./refactorings/extract-class/ExtractClassCommand";
+import { ABRACADABRA_EXTRACT_CLASS_COMMAND } from "./refactorings/extract-class/EXTRACT_CLASS_COMMAND";
 
 const TS_LANGUAGES = ["typescript", "typescriptreact"];
 const REACT_LANGUAGES = ["javascriptreact", "typescriptreact"];
@@ -97,6 +100,13 @@ export function activate(context: vscode.ExtensionContext) {
     )
   );
 
+  context.subscriptions.push(
+    vscode.commands.registerTextEditorCommand(
+      ABRACADABRA_EXTRACT_CLASS_COMMAND,
+      ExtractClassCommand.execute
+    )
+  );
+
   TS_LANGUAGES.forEach((language) => {
     vscode.languages.registerCodeActionsProvider(
       language,
@@ -153,6 +163,12 @@ export function activate(context: vscode.ExtensionContext) {
         providedCodeActionKinds: [vscode.CodeActionKind.RefactorRewrite]
       }
     );
+
+    vscode.languages.registerCodeActionsProvider(
+      language,
+      new ExtractClassActionProvider(),
+      { providedCodeActionKinds: [vscode.CodeActionKind.RefactorExtract] }
+    );
   });
 }
 
diff --git a/src/refactorings/extract-class/ClassNode.ts b/src/refactorings/extract-class/ClassNode.ts
new file mode 100644
index 00000000..0dfc1c56
--- /dev/null
+++ b/src/refactorings/extract-class/ClassNode.ts
@@ -0,0 +1,17 @@
+export interface ClassNode {
+	name: string;
+	getInstanceMembers(): InstanceMember[];
+	getInstanceMember(fieldName: string): InstanceMember;
+	initPrivatePropertyFor(classNode: ClassNode): InstanceMember;
+	createClassNodeWithSameInstanceMembers(name: string): ClassNode;
+	serialize(): string;
+}
+
+export interface InstanceMember {
+	name: string;
+	isPrivate(): boolean;
+	getDependencyNames(): string[];
+	delegateTo(field: InstanceMember): void;
+	markAsPublic(): void;
+	remove(): void;
+}
diff --git a/src/refactorings/extract-class/ClassRefactor.ts b/src/refactorings/extract-class/ClassRefactor.ts
new file mode 100644
index 00000000..f0f73145
--- /dev/null
+++ b/src/refactorings/extract-class/ClassRefactor.ts
@@ -0,0 +1,131 @@
+import { ClassNode, InstanceMember } from "./ClassNode";
+
+export class ClassRefactor {
+  constructor(private node: ClassNode) {}
+
+  extractClass(className: string, fieldNames: string[]): ClassRefactor {
+    const extractingNode = this.node.createClassNodeWithSameInstanceMembers(
+      className
+    );
+    const extracted = new ExtractingClassRefactor(
+      this.node,
+      extractingNode,
+      fieldNames
+    );
+    const source = new SourceClassRefactor(this.node, extractingNode);
+    extracted.removeFieldsOmitExtractingWithDependencies();
+    source.delegateCallsToExtractedClass();
+    source.deleteUnusedPrivateMovedFields();
+    extracted.markDelegatedFieldsAsPublic();
+    return new ClassRefactor(extractingNode);
+  }
+
+  serialize(): string {
+    return this.node.serialize();
+  }
+}
+
+class ExtractingClassRefactor {
+  constructor(
+    private sourceNode: ClassNode,
+    private clonedNode: ClassNode,
+    private fieldsToExtract: string[]
+  ) {}
+
+  removeFieldsOmitExtractingWithDependencies(): void {
+    const allFields = this.clonedNode.getInstanceMembers();
+    const dependencyFieldNames = this.fieldsToExtract.flatMap((fieldName) =>
+      this.getFieldDependenciesRecursively(fieldName)
+    );
+    const usedFieldNames = new Set([
+      ...this.fieldsToExtract,
+      ...dependencyFieldNames
+    ]);
+    const fieldsToRemove = allFields.filter(
+      (field) => !usedFieldNames.has(field.name)
+    );
+    fieldsToRemove.forEach((field) => field.remove());
+  }
+
+  private getFieldDependenciesRecursively(fieldName: string): string[] {
+    return new FieldDependencies(this.clonedNode, fieldName).getAll();
+  }
+
+  markDelegatedFieldsAsPublic(): void {
+    const existingFieldNames = new Set(
+      this.sourceNode.getInstanceMembers().map((field) => field.name)
+    );
+    const fieldsToExtract = new Set(this.fieldsToExtract);
+    this.clonedNode
+      .getInstanceMembers()
+      .filter(
+        (field) =>
+          fieldsToExtract.has(field.name) || existingFieldNames.has(field.name)
+      )
+      .forEach((field) => field.markAsPublic());
+  }
+}
+
+class FieldDependencies {
+  private dependencies: Set<string> = new Set();
+
+  constructor(private node: ClassNode, fieldName: string) {
+    this.fillDependenciesRecursively(fieldName);
+  }
+
+  getAll(): string[] {
+    return [...this.dependencies.values()];
+  }
+
+  private fillDependenciesRecursively(fieldName: string): void {
+    this.node
+      .getInstanceMember(fieldName)
+      .getDependencyNames()
+      .filter((name) => !this.dependencies.has(name))
+      .forEach((name) => {
+        this.dependencies.add(name);
+        this.fillDependenciesRecursively(name);
+      });
+  }
+}
+
+class SourceClassRefactor {
+  constructor(
+    private sourceNode: ClassNode,
+    private extractedNode: ClassNode
+  ) {}
+
+  delegateCallsToExtractedClass(): void {
+    const extractedClassProp = this.sourceNode.initPrivatePropertyFor(
+      this.extractedNode
+    );
+    this.extractedNode
+      .getInstanceMembers()
+      .map((field) => this.sourceNode.getInstanceMember(field.name))
+      .forEach((field) => field.delegateTo(extractedClassProp));
+  }
+
+  deleteUnusedPrivateMovedFields(): void {
+    const fieldsToRemove = this.getUnusedPrivateMovedFields();
+    fieldsToRemove.forEach((field) => field.remove());
+    if (this.getUnusedPrivateMovedFields().length) {
+      this.deleteUnusedPrivateMovedFields();
+    }
+  }
+
+  private getUnusedPrivateMovedFields(): InstanceMember[] {
+    const allFields = this.sourceNode.getInstanceMembers();
+    const usedFieldNames = new Set(
+      allFields.flatMap((field) => field.getDependencyNames())
+    );
+    const extractedFieldNames = new Set(this.getExtractedFieldNames());
+    return allFields
+      .filter((field) => field.isPrivate())
+      .filter((field) => !usedFieldNames.has(field.name))
+      .filter((field) => extractedFieldNames.has(field.name));
+  }
+
+  private getExtractedFieldNames(): string[] {
+    return this.extractedNode.getInstanceMembers().map((field) => field.name);
+  }
+}
diff --git a/src/refactorings/extract-class/EXTRACT_CLASS_COMMAND.ts b/src/refactorings/extract-class/EXTRACT_CLASS_COMMAND.ts
new file mode 100644
index 00000000..8ce85ed9
--- /dev/null
+++ b/src/refactorings/extract-class/EXTRACT_CLASS_COMMAND.ts
@@ -0,0 +1,2 @@
+export const EXTRACT_CLASS_COMMAND = "extractClass";
+export const ABRACADABRA_EXTRACT_CLASS_COMMAND = `abracadabra.${EXTRACT_CLASS_COMMAND}`;
diff --git a/src/refactorings/extract-class/ExtractClassActionProvider.ts b/src/refactorings/extract-class/ExtractClassActionProvider.ts
new file mode 100644
index 00000000..55c712e5
--- /dev/null
+++ b/src/refactorings/extract-class/ExtractClassActionProvider.ts
@@ -0,0 +1,67 @@
+import * as vscode from "vscode";
+import { ABRACADABRA_EXTRACT_CLASS_COMMAND } from "./EXTRACT_CLASS_COMMAND";
+import { classNameMatcher } from "./classNameMatcher";
+
+export class ExtractClassActionProvider implements vscode.CodeActionProvider {
+  provideCodeActions(
+    document: vscode.TextDocument,
+    range: vscode.Range | vscode.Selection
+  ): vscode.ProviderResult<(vscode.Command | vscode.CodeAction)[]> {
+    const matcher = new ExtractClassActionProviderMatcher(document, range);
+    if (matcher.isCursorAtClassNameRange()) {
+      return [this.createExtractClassCommand()];
+    }
+    return [];
+  }
+
+  private createExtractClassCommand() {
+    const action = new vscode.CodeAction(
+      "Extract class ✨",
+      vscode.CodeActionKind.RefactorExtract
+    );
+    action.command = {
+      command: ABRACADABRA_EXTRACT_CLASS_COMMAND,
+      title: "Extract class",
+      tooltip: "Select instance members to extract"
+    };
+    return action;
+  }
+}
+
+class ExtractClassActionProviderMatcher {
+  constructor(
+    private document: vscode.TextDocument,
+    private range: vscode.Range | vscode.Selection
+  ) {}
+
+  isCursorAtClassNameRange(): boolean {
+    if (this.isClassDeclarationLine()) {
+      return this.getClassNameRange().contains(this.range);
+    }
+    return false;
+  }
+
+  private isClassDeclarationLine(): boolean {
+    return !!this.getClassNameAtLine();
+  }
+
+  private getClassNameAtLine(): string {
+    return classNameMatcher.findAt(this.getLineText()) ?? "";
+  }
+
+  private getLineText(): string {
+    return this.document.lineAt(this.range.start.line).text;
+  }
+
+  private getClassNameRange(): vscode.Range {
+    const className = this.getClassNameAtLine();
+    const classNameStartIndex = this.getLineText().indexOf(className);
+    return new vscode.Range(
+      new vscode.Position(this.range.start.line, classNameStartIndex - 1),
+      new vscode.Position(
+        this.range.start.line,
+        classNameStartIndex + className.length
+      )
+    );
+  }
+}
diff --git a/src/refactorings/extract-class/ExtractClassCommand.ts b/src/refactorings/extract-class/ExtractClassCommand.ts
new file mode 100644
index 00000000..0117454a
--- /dev/null
+++ b/src/refactorings/extract-class/ExtractClassCommand.ts
@@ -0,0 +1,152 @@
+import * as vscode from "vscode";
+import { Project, ClassDeclaration, ParameterDeclaration } from "ts-morph";
+import { ClassRefactor } from "./ClassRefactor";
+import { TypescriptClassNode } from "./TypescriptClassNode";
+import { UmlNotation } from "./UmlNotation";
+import { assert } from "./utils/assert";
+import { classNameMatcher } from "./classNameMatcher";
+
+export class ExtractClassCommand {
+  static execute(textEditor: vscode.TextEditor): Promise<void> {
+    const classNode = new ClassParser(textEditor).parseClassAtCurrentLine();
+    const quickPick = new QuickPick(classNode);
+    const textDocument = new TextDocument(textEditor);
+    const operation = new ExtractClassCommand(
+      classNode,
+      quickPick,
+      textDocument
+    );
+    return operation.execute();
+  }
+
+  private readonly extractingClassName = "ExtractedClass";
+
+  private constructor(
+    private node: ClassDeclaration,
+    private quickPick: QuickPick,
+    private textDocument: TextDocument
+  ) {}
+
+  async execute(): Promise<void> {
+    try {
+      await this.tryExecute();
+    } catch (error) {
+      vscode.debug.activeDebugConsole.appendLine(error);
+      vscode.window.showErrorMessage("Class extraction failed");
+    }
+  }
+
+  private async tryExecute(): Promise<void> {
+    const fieldsToExtract = await this.quickPick.pickFieldsToExtract();
+    if (fieldsToExtract?.length) {
+      await this.extractClassAndWrite(fieldsToExtract);
+    } else {
+      vscode.window.showInformationMessage("Class extraction canceled");
+    }
+  }
+
+  private async extractClassAndWrite(fieldsToExtract: string[]): Promise<void> {
+    const initialSourceText = this.node.getFullText();
+    const source = new ClassRefactor(new TypescriptClassNode(this.node));
+    const extracted = source.extractClass(
+      this.extractingClassName,
+      fieldsToExtract
+    );
+    await this.textDocument.replace(
+      initialSourceText,
+      this.getResultString(source, extracted)
+    );
+  }
+
+  private getResultString(
+    source: ClassRefactor,
+    extracted: ClassRefactor
+  ): string {
+    return `${source.serialize()}\n\n${extracted.serialize()}`;
+  }
+}
+
+class ClassParser {
+  constructor(private textEditor: vscode.TextEditor) {}
+
+  parseClassAtCurrentLine(): ClassDeclaration {
+    return this.findClassAtFile(this.getClassNameAtCurrentLine());
+  }
+
+  private getClassNameAtCurrentLine() {
+    const className = classNameMatcher.findAt(this.getCurrentLineText());
+    assert(className, "Class name text not found");
+    return className;
+  }
+
+  private getCurrentLineText(): string {
+    return this.textEditor.document.lineAt(this.textEditor.selection.start)
+      .text;
+  }
+
+  private findClassAtFile(className: string): ClassDeclaration {
+    const project = new Project();
+    const file = project.createSourceFile("", this.getFileText());
+    return file.getClassOrThrow(className);
+  }
+
+  private getFileText(): string {
+    return this.textEditor.document.getText();
+  }
+}
+
+class TextDocument {
+  constructor(private textEditor: vscode.TextEditor) {}
+
+  async replace(searchValue: string, replaceValue: string): Promise<void> {
+    const source = this.textEditor.document.getText();
+    const result = source.replace(searchValue, replaceValue);
+    await this.textEditor.edit((edit) =>
+      edit.replace(this.getFullDocumentRange(), result)
+    );
+    await this.formatDocument();
+  }
+
+  private getFullDocumentRange(): vscode.Range {
+    return new vscode.Range(
+      new vscode.Position(0, 0),
+      new vscode.Position(Infinity, Infinity)
+    );
+  }
+
+  private async formatDocument(): Promise<void> {
+    const docUri = this.textEditor.document.uri;
+    const textEdits = (await vscode.commands.executeCommand(
+      "vscode.executeFormatDocumentProvider",
+      docUri
+    )) as vscode.TextEdit[];
+    const edit = new vscode.WorkspaceEdit();
+    for (const textEdit of textEdits) {
+      edit.replace(docUri, textEdit.range, textEdit.newText);
+    }
+    await vscode.workspace.applyEdit(edit);
+  }
+}
+
+class QuickPick {
+  constructor(private node: ClassDeclaration) {}
+
+  async pickFieldsToExtract(): Promise<string[] | undefined> {
+    const umlNotations = this.getFieldUmlNotations();
+    const selectedItems = await vscode.window.showQuickPick(umlNotations, {
+      placeHolder: "Select methods to extract",
+      canPickMany: true
+    });
+    return selectedItems?.map((fieldName) =>
+      UmlNotation.parseFieldNameFromUmlNotation(fieldName)
+    );
+  }
+
+  private getFieldUmlNotations(): string[] {
+    return this.node
+      .getInstanceMembers()
+      .filter((field) => !(field instanceof ParameterDeclaration))
+      .map(UmlNotation.fromInstanceMember)
+      .map((field) => field.serialize());
+  }
+}
diff --git a/src/refactorings/extract-class/TextMatcher.ts b/src/refactorings/extract-class/TextMatcher.ts
new file mode 100644
index 00000000..cf1188da
--- /dev/null
+++ b/src/refactorings/extract-class/TextMatcher.ts
@@ -0,0 +1,19 @@
+export class TextMatcher {
+  constructor(private pattern: string = "") {}
+
+  after(pattern: string): TextMatcher {
+    return new TextMatcher(this.pattern + `(?<=${pattern}\\s*)`);
+  }
+
+  getWord(): TextMatcher {
+    return new TextMatcher(this.pattern + "\\w+");
+  }
+
+  findAt(source: string): string | undefined {
+    return source.match(this.toRegExp())?.[0];
+  }
+
+  toRegExp(): RegExp {
+    return new RegExp(this.pattern);
+  }
+}
diff --git a/src/refactorings/extract-class/TypescriptClassNode.ts b/src/refactorings/extract-class/TypescriptClassNode.ts
new file mode 100644
index 00000000..ffa6e60b
--- /dev/null
+++ b/src/refactorings/extract-class/TypescriptClassNode.ts
@@ -0,0 +1,157 @@
+import { ClassNode, InstanceMember } from "./ClassNode";
+import {
+  ClassDeclaration,
+  ClassInstanceMemberTypes,
+  MethodDeclaration,
+  Scope,
+  PropertyDeclaration,
+  ParameterDeclaration
+} from "ts-morph";
+import { camelCase } from "change-case";
+import { parseClassDeclaration } from "./parseClassDeclaration";
+
+export class TypescriptClassNode implements ClassNode {
+  static from(source: string): TypescriptClassNode {
+    const node = parseClassDeclaration(source);
+    return new TypescriptClassNode(node);
+  }
+
+  get name(): string {
+    return this.node.getName() ?? "";
+  }
+
+  constructor(private node: ClassDeclaration) {}
+
+  getInstanceMembers(): InstanceMember[] {
+    return this.node
+      .getInstanceMembers()
+      .map((field) => TypescriptInstanceMemberCode.create(field));
+  }
+
+  getInstanceMember(fieldName: string): InstanceMember {
+    return TypescriptInstanceMemberCode.create(
+      this.node.getInstanceMemberOrThrow(fieldName)
+    );
+  }
+
+  initPrivatePropertyFor(classNode: TypescriptClassNode): InstanceMember {
+    const prop = this.node.addProperty({
+      scope: Scope.Private,
+      name: camelCase(classNode.name),
+      type: classNode.name,
+      initializer: `new ${classNode.name}(${classNode
+        .getCtorParams()
+        .map((param) => `this.${param.getName()}`)
+        .join(", ")})`,
+      trailingTrivia: "\n\n"
+    });
+    prop.setOrder(0);
+    return TypescriptInstanceMemberCode.create(prop);
+  }
+
+  private getCtorParams(): ParameterDeclaration[] {
+    return this.node.getConstructors()[0]?.getParameters() ?? [];
+  }
+
+  createClassNodeWithSameInstanceMembers(name: string): ClassNode {
+    const clone = TypescriptClassNode.from(
+      this.serialize().replace(/class \w+/, `class ${name}`)
+    );
+    clone.node.getStaticMembers().forEach((field) => field.remove());
+    clone.node.getDecorators().forEach((field) => field.remove());
+    clone.node
+      .getImplements()
+      .forEach((_, i) => clone.node.removeImplements(i));
+    return clone;
+  }
+
+  serialize(): string {
+    return this.node.getFullText();
+  }
+}
+
+export class TypescriptInstanceMemberCode implements InstanceMember {
+  static create(node: ClassInstanceMemberTypes): TypescriptInstanceMemberCode {
+    if (node instanceof MethodDeclaration) {
+      return new TypescriptMethodCode(node);
+    }
+    if (node instanceof PropertyDeclaration) {
+      return new TypescriptPropertyCode(node);
+    }
+    if (node instanceof ParameterDeclaration) {
+      return new TypescriptParameterCode(node);
+    }
+    return new TypescriptInstanceMemberCode(node);
+  }
+
+  get name(): string {
+    return this.node.getName();
+  }
+
+  constructor(protected node: ClassInstanceMemberTypes) {}
+
+  isPrivate(): boolean {
+    return this.node.getScope() === Scope.Private;
+  }
+
+  markAsPublic(): void {
+    this.node.setScope(undefined);
+  }
+
+  getDependencyNames(): string[] {
+    return this.node.getFullText().match(/(?<=this\.)\w+/g) ?? [];
+  }
+
+  delegateTo(_field: InstanceMember): void {}
+
+  remove(): void {
+    this.node.remove();
+  }
+}
+
+export class TypescriptPropertyCode extends TypescriptInstanceMemberCode {
+  constructor(protected node: PropertyDeclaration) {
+    super(node);
+  }
+
+  delegateTo(field: InstanceMember): void {
+    const index = this.node.getChildIndex();
+    const params = {
+      name: this.name,
+      scope: this.node.getScope().replace(Scope.Public, "") as Scope,
+      returnType: this.node.getTypeNode()?.getText(),
+      statements: `return this.${field.name}.${this.name}`
+    };
+    const parent = this.node.getParentOrThrow();
+    this.node.remove();
+    this.node = parent.addGetAccessor(params) as any;
+    this.node.setOrder(index);
+  }
+}
+
+export class TypescriptParameterCode extends TypescriptInstanceMemberCode {
+  markAsPublic(): void {}
+}
+
+export class TypescriptMethodCode extends TypescriptInstanceMemberCode {
+  constructor(protected node: MethodDeclaration) {
+    super(node);
+  }
+
+  getDependencyNames(): string[] {
+    return (
+      this.node
+        .getBody()
+        ?.getFullText()
+        .match(/(?<=this\.)\w+/g) ?? []
+    );
+  }
+
+  delegateTo(field: InstanceMember): void {
+    this.node.setBodyText(
+      `return this.${field.name}.${
+        this.name
+      }(${this.node.getParameters().map((param) => param.getName())})`
+    );
+  }
+}
diff --git a/src/refactorings/extract-class/UmlNotation.ts b/src/refactorings/extract-class/UmlNotation.ts
new file mode 100644
index 00000000..216f4566
--- /dev/null
+++ b/src/refactorings/extract-class/UmlNotation.ts
@@ -0,0 +1,87 @@
+import {
+	ClassInstanceMemberTypes,
+	Scope,
+	MethodDeclaration,
+	ParameterDeclaration,
+	GetAccessorDeclaration,
+	SetAccessorDeclaration,
+} from 'ts-morph';
+
+type FieldWithReturnType = MethodDeclaration | GetAccessorDeclaration | SetAccessorDeclaration;
+
+export abstract class UmlNotation {
+	static fromInstanceMember(node: ClassInstanceMemberTypes): UmlNotation {
+		if (UmlNotation.isFieldWithReturnType(node)) {
+			return new MethodUmlNotation(node);
+		}
+		return new PropertyUmlNotation(node);
+	}
+
+	private static isFieldWithReturnType(
+		node: ClassInstanceMemberTypes,
+	): node is FieldWithReturnType {
+		return [MethodDeclaration, GetAccessorDeclaration, SetAccessorDeclaration].some(
+			(ctor) => node instanceof ctor,
+		);
+	}
+
+	static parseFieldNameFromUmlNotation(umlNotation: string): string {
+		return umlNotation.match(/\w+/)?.[0] ?? '';
+	}
+
+	constructor(protected node: ClassInstanceMemberTypes) {}
+
+	abstract serialize(): string;
+
+	protected getBody(): string {
+		return `${this.getScopeSymbol()}${this.node.getName()}`;
+	}
+
+	private getScopeSymbol(): string {
+		const scope = this.node.getScope();
+		return scope === Scope.Public ? '+' : scope === Scope.Protected ? '#' : '-';
+	}
+
+	protected getTypeNotationOrEmpty(text?: string): string {
+		return text ? `: ${text}` : '';
+	}
+}
+
+export class PropertyUmlNotation extends UmlNotation {
+	constructor(protected node: Exclude<ClassInstanceMemberTypes, FieldWithReturnType>) {
+		super(node);
+	}
+
+	serialize(): string {
+		return `${this.getBody()}${this.getType()}`;
+	}
+
+	private getType(): string {
+		return this.getTypeNotationOrEmpty(this.node.getTypeNode()?.getText());
+	}
+}
+
+export class MethodUmlNotation extends UmlNotation {
+	constructor(protected node: FieldWithReturnType) {
+		super(node);
+	}
+
+	serialize(): string {
+		return `${this.getBody()}(${this.getParamsNotation()})${this.getReturnType()}`;
+	}
+
+	private getParamsNotation(): string {
+		return this.node
+			.getParameters()
+			.map((param) => this.getParamNotation(param))
+			.join(', ');
+	}
+
+	private getParamNotation(param: ParameterDeclaration): string {
+		return `${param.getName()}${this.getTypeNotationOrEmpty(param.getTypeNode()?.getText())}`;
+	}
+
+	private getReturnType(): string {
+		return this.getTypeNotationOrEmpty(this.node.getReturnTypeNode()?.getText());
+	}
+}
diff --git a/src/refactorings/extract-class/classNameMatcher.ts b/src/refactorings/extract-class/classNameMatcher.ts
new file mode 100644
index 00000000..0f5ef76f
--- /dev/null
+++ b/src/refactorings/extract-class/classNameMatcher.ts
@@ -0,0 +1,3 @@
+import { TextMatcher } from "./TextMatcher";
+
+export const classNameMatcher = new TextMatcher().after("class").getWord();
diff --git a/src/refactorings/extract-class/extract-class.ts b/src/refactorings/extract-class/extract-class.ts
new file mode 100644
index 00000000..54c6c992
--- /dev/null
+++ b/src/refactorings/extract-class/extract-class.ts
@@ -0,0 +1,34 @@
+import { Editor, ErrorReason } from "../../editor/editor";
+import { Selection } from "../../editor/selection";
+import * as t from "../../ast";
+
+export { extractClass, createVisitor };
+
+async function extractClass(editor: Editor) {
+  const { code, selection } = editor;
+  const updatedCode = updateCode(t.parse(code), selection);
+
+  if (!updatedCode.hasCodeChanged) {
+    editor.showError(ErrorReason.CanNotExtractClass);
+    return;
+  }
+
+  await editor.write(updatedCode.code);
+}
+
+function updateCode(ast: t.AST, selection: Selection): t.Transformed {
+  return t.transformAST(
+    ast,
+    createVisitor(selection, (_path) => {
+      // TODO: implement the transformation here 🧙‍
+    })
+  );
+}
+
+function createVisitor(
+  _selection: Selection,
+  _onMatch: (path: t.NodePath) => void
+): t.Visitor {
+  // TODO: implement the check here 🧙‍
+  return {};
+}
diff --git a/src/refactorings/extract-class/formatTs.ts b/src/refactorings/extract-class/formatTs.ts
new file mode 100644
index 00000000..0a48be4d
--- /dev/null
+++ b/src/refactorings/extract-class/formatTs.ts
@@ -0,0 +1,8 @@
+import { format } from "prettier";
+
+export function formatTs(value: string): string {
+  return format(value, {
+    parser: "typescript",
+    singleQuote: true
+  }).replace(/\n+/g, "\n");
+}
diff --git a/src/refactorings/extract-class/index.ts b/src/refactorings/extract-class/index.ts
new file mode 100644
index 00000000..bd8db79b
--- /dev/null
+++ b/src/refactorings/extract-class/index.ts
@@ -0,0 +1,17 @@
+import { extractClass, createVisitor } from "./extract-class";
+import { RefactoringWithActionProvider } from "../../types";
+import { EXTRACT_CLASS_COMMAND } from "./EXTRACT_CLASS_COMMAND";
+
+const config: RefactoringWithActionProvider = {
+  command: {
+    key: EXTRACT_CLASS_COMMAND,
+    operation: extractClass,
+    title: "Extract Class"
+  },
+  actionProvider: {
+    message: "Extract class",
+    createVisitor
+  }
+};
+
+export default config;
diff --git a/src/refactorings/extract-class/parseClassDeclaration.ts b/src/refactorings/extract-class/parseClassDeclaration.ts
new file mode 100644
index 00000000..d1678d39
--- /dev/null
+++ b/src/refactorings/extract-class/parseClassDeclaration.ts
@@ -0,0 +1,6 @@
+import { ClassDeclaration, Project } from "ts-morph";
+
+export function parseClassDeclaration(source: string): ClassDeclaration {
+  const className = source.match(/class \w+/)?.[0].replace("class ", "") ?? "";
+  return new Project().createSourceFile("", source).getClassOrThrow(className);
+}
diff --git a/src/refactorings/extract-class/utils/assert.ts b/src/refactorings/extract-class/utils/assert.ts
new file mode 100644
index 00000000..204b5c05
--- /dev/null
+++ b/src/refactorings/extract-class/utils/assert.ts
@@ -0,0 +1,5 @@
+export function assert(condition: unknown, errorMessage?: string): asserts condition {
+	if (!condition) {
+		throw new Error(errorMessage);
+	}
+}
diff --git a/tsconfig.json b/tsconfig.json
index 0fb08135..7a4fa702 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,7 @@
     "module": "commonjs",
     "target": "es6",
     "outDir": "out",
-    "lib": ["es6", "es2016"],
+    "lib": ["es6", "es2016", "ESNext.Array"],
     "sourceMap": true,
     "rootDir": "src",
     "strict": true,
diff --git a/yarn.lock b/yarn.lock
index afee90fc..fc2d578f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -896,6 +896,14 @@
     exec-sh "^0.3.2"
     minimist "^1.2.0"
 
+"@dsherret/to-absolute-glob@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1f6475dc8bd974cea07a2daf3864b317b1dd332c"
+  integrity sha1-H2R13IvZdM6gei2vOGSzF7HdMyw=
+  dependencies:
+    is-absolute "^1.0.0"
+    is-negated-glob "^1.0.0"
+
 "@istanbuljs/load-nyc-config@^1.0.0":
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b"
@@ -1143,6 +1151,18 @@
   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
   integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
 
+"@ts-morph/common@~0.6.0":
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.6.0.tgz#cbd4ee57c5ef971511b9c5778e0bb8eb27de4783"
+  integrity sha512-pI35nZz5bs3tL3btSVX2cWkAE8rc80F+Fn4TwSC6bQvn7fgn9IyLXVcAfpG6X6NBY5wN9TkSWXn/QYUkBvR/Fw==
+  dependencies:
+    "@dsherret/to-absolute-glob" "^2.0.2"
+    fast-glob "^3.2.4"
+    fs-extra "^9.0.1"
+    is-negated-glob "^1.0.0"
+    multimatch "^4.0.0"
+    typescript "~4.0.2"
+
 "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
   version "7.1.9"
   resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
@@ -2366,6 +2386,11 @@ co@^4.6.0:
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
   integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
 
+code-block-writer@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-10.1.0.tgz#54fc410ebef2af836d9c2314ac40af7d7b37eee9"
+  integrity sha512-RG9hpXtWFeUWhuUav1YuP/vGcyncW+t90yJLk9fNZs1De2OuHTHKAKThVCokt29PYq5RoJ0QSZaIZ+rvPO23hA==
+
 collect-v8-coverage@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz#150ee634ac3650b71d9c985eb7f608942334feb1"
@@ -3266,6 +3291,18 @@ fast-glob@^3.0.3:
     micromatch "^4.0.2"
     picomatch "^2.2.1"
 
+fast-glob@^3.2.4:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3"
+  integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.0"
+    merge2 "^1.3.0"
+    micromatch "^4.0.2"
+    picomatch "^2.2.1"
+
 fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@@ -4008,6 +4045,14 @@ ip-regex@^2.1.0:
   resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
   integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=
 
+is-absolute@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576"
+  integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==
+  dependencies:
+    is-relative "^1.0.0"
+    is-windows "^1.0.1"
+
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
@@ -4152,6 +4197,11 @@ is-lower-case@^1.1.0:
   dependencies:
     lower-case "^1.1.0"
 
+is-negated-glob@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2"
+  integrity sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=
+
 is-number@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
@@ -4191,6 +4241,13 @@ is-promise@^2.1.0:
   resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
   integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=
 
+is-relative@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d"
+  integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==
+  dependencies:
+    is-unc-path "^1.0.0"
+
 is-stream@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
@@ -4206,6 +4263,13 @@ is-typedarray@^1.0.0, is-typedarray@~1.0.0:
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
+is-unc-path@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d"
+  integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==
+  dependencies:
+    unc-path-regex "^0.1.2"
+
 is-upper-case@^1.1.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-1.1.2.tgz#8d0b1fa7e7933a1e58483600ec7d9661cbaf756f"
@@ -7253,6 +7317,15 @@ ts-loader@8.0.5:
     micromatch "^4.0.0"
     semver "^6.0.0"
 
+ts-morph@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-8.1.0.tgz#f8534e66bda86bfd444718ea5efb533510f185f1"
+  integrity sha512-Wc3SAqdh5E1/AEr6Tuwx3EANy4RmAwzHaaG/gbsQf5OVxu5audGSYof2TcZDmCodkzH9Ke/E3d//7rCOeZ7fSw==
+  dependencies:
+    "@dsherret/to-absolute-glob" "^2.0.2"
+    "@ts-morph/common" "~0.6.0"
+    code-block-writer "^10.1.0"
+
 tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0:
   version "1.13.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
@@ -7368,6 +7441,11 @@ uc.micro@^1.0.1, uc.micro@^1.0.5:
   resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
   integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
 
+unc-path-regex@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
+  integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo=
+
 underscore@1.8.3:
   version "1.8.3"
   resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"


Test patch:
diff --git a/src/refactorings/extract-class/ClassRefactor.test.ts b/src/refactorings/extract-class/ClassRefactor.test.ts
new file mode 100644
index 00000000..5bbe070c
--- /dev/null
+++ b/src/refactorings/extract-class/ClassRefactor.test.ts
@@ -0,0 +1,438 @@
+import { ClassRefactor } from './ClassRefactor';
+import { TypescriptClassNode } from './TypescriptClassNode';
+import { formatTs } from './formatTs';
+
+describe(ClassRefactor.name, () => {
+	describe(ClassRefactor.prototype.extractClass.name, () => {
+		it('should extract new class with sended method', () => {
+			const source = `
+				class Source{
+					a(){}
+					b(){}
+				}
+			`;
+			const expected = `
+				class Extracted{
+					a(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should delegate call to new property', () => {
+			const source = `
+				class Source{
+					a(): void {}
+					b(): void {}
+				}
+			`;
+			const expected = `
+				class Source{
+					private extracted: Extracted = new Extracted();
+
+					a(): void {
+						return this.extracted.a();
+					}
+					
+					b(): void {}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should add dependency method to new class', () => {
+			const source = `
+				class Source{
+					a(){
+						this.b();
+					}
+
+					b(){}
+				}
+			`;
+			const expected = `
+				class Extracted{
+					a(){
+						this.b();
+					}
+					
+					b(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should remove private dependency method from source if not used', () => {
+			const source = `
+				class Source{
+					a(){
+						this.b();
+					}
+					
+					private	b(){}
+				}
+		`;
+			const expected = `
+				class Source{
+					private	extracted: Extracted = new Extracted();
+					
+					a(){
+						return this.extracted.a();
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should remove private dependency method from source if not used recursively', () => {
+			const source = `
+				class Source{
+					a(){
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+		`;
+			const expected = `
+				class Source{
+					private	extracted: Extracted = new Extracted();
+					
+					a(){
+						return this.extracted.a();
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should not remove private dependency method from source if not moved', () => {
+			const source = `
+				class Source{
+					a(){}
+					private	b(){}
+				}
+		`;
+			const expected = `
+				class Source{
+					private	extracted: Extracted = new Extracted();					
+					a(){
+						return this.extracted.a();
+					}
+					private	b(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should add dependency methods to extracted class recursively', () => {
+			const source = `
+				class Source{
+					a(){
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+		`;
+			const expected = `
+				class Extracted{
+					a(){
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should not add unused private methods to extracted class', () => {
+			const source = `
+				class Source{
+					a(){}
+
+					d(){
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+		`;
+			const expected = `
+				class Extracted{
+					a(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should mark moved private methods to extracted class as public', () => {
+			const source = `
+				class Source{
+					private a(){
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+
+					d(){}
+				}
+		`;
+			const expected = `
+				class Extracted{
+					a(){
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should mark moved private dependency methods to extracted class as public', () => {
+			const source = `
+				class Source{
+					private a(){
+						this.b();
+					}
+
+					d(){
+						this.a();
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+		`;
+			const expected = `
+				class Extracted{
+					a(){
+						this.b();
+					}
+					
+					b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should delegate moved private methods to extracted class prop', () => {
+			const source = `
+				class Source{
+					private a(){
+						this.b();
+					}
+
+					d(){
+						this.a();
+						this.b();
+					}
+					
+					private	b(){
+						this.c();
+					}
+
+					private c(){}
+				}
+		`;
+			const expected = `
+				class Source{
+					private extracted: Extracted = new Extracted();
+
+					private a(){
+						return this.extracted.a();
+					}
+
+					d(){
+						this.a();
+						this.b();
+					}
+					
+					private	b(){
+						return this.extracted.b();
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should create getter for moved property', () => {
+			const source = `
+				class Source{
+					prop:number = 1;
+				}
+		`;
+			const expected = `
+				class Source{
+					private extracted: Extracted = new Extracted();
+					get prop(): number{
+						return this.extracted.prop;
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['prop']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should init new class with params', () => {
+			const source = `
+				class Source{
+					constructor(private prop: number){}
+
+					a():void{
+						console.log(this.prop);
+					}
+				}
+		`;
+			const expected = `
+				class Source{
+					private extracted: Extracted = new Extracted(this.prop);
+
+					constructor(private prop: number){}
+
+					a():void{
+						return	this.extracted.a();
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('should move ctor fields to extracted class if used', () => {
+			const source = `
+				class Source{
+					constructor(private prop: number){}
+
+					a():void{
+						console.log(this.prop);
+					}
+				}
+		`;
+			const expected = `
+				class Extracted{
+					constructor(private prop: number){}
+
+					a():void{
+						console.log(this.prop);
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedClassRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedClassRefactor).toEqualString(expected);
+		});
+
+		it('should not init new class with unused params', () => {
+			const source = `
+				class Source{
+					constructor(private prop: number){}
+
+					a():void{}
+				}
+		`;
+			const expected = `
+				class Source{
+					private extracted: Extracted = new Extracted();
+
+					constructor(private prop: number){}
+
+					a():void{
+						return	this.extracted.a();
+					}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(classRefactor).toEqualString(expected);
+		});
+
+		it('extracted class should not contains source class decorators, static methods, interfaces', () => {
+			const source = `
+				@Decorator()
+				class Source implements ISource{
+					static method(){}
+
+					a():void{}
+				}
+		`;
+			const expected = `
+				class Extracted{
+					a():void{}
+				}
+			`;
+			const classRefactor = createClassRefactor(source);
+			const extractedRefactor = classRefactor.extractClass('Extracted', ['a']);
+			expectTsClassRefactor(extractedRefactor).toEqualString(expected);
+		});
+	});
+});
+
+function expectTsClassRefactor(classRefactor: ClassRefactor) {
+	return {
+		toEqualString: (expected: string) =>
+			expect(formatTs(classRefactor.serialize())).toBe(formatTs(expected)),
+	};
+}
+
+function createClassRefactor(source: string): ClassRefactor {
+	return new ClassRefactor(TypescriptClassNode.from(source));
+}
diff --git a/src/refactorings/extract-class/TextMatcher.test.ts b/src/refactorings/extract-class/TextMatcher.test.ts
new file mode 100644
index 00000000..a9bcb41c
--- /dev/null
+++ b/src/refactorings/extract-class/TextMatcher.test.ts
@@ -0,0 +1,9 @@
+import { TextMatcher } from "./TextMatcher";
+import { classNameMatcher } from "./classNameMatcher";
+
+describe(TextMatcher.name, () => {
+  it('should find class name word after "class" pattern', () => {
+    const source = "class ClassName {}";
+    expect(classNameMatcher.findAt(source)).toBe("ClassName");
+  });
+});
diff --git a/src/refactorings/extract-class/UmlNotation.test.ts b/src/refactorings/extract-class/UmlNotation.test.ts
new file mode 100644
index 00000000..ef73f220
--- /dev/null
+++ b/src/refactorings/extract-class/UmlNotation.test.ts
@@ -0,0 +1,88 @@
+import { UmlNotation, MethodUmlNotation, PropertyUmlNotation } from './UmlNotation';
+import { parseClassDeclaration } from './parseClassDeclaration';
+
+describe(PropertyUmlNotation.name, () => {
+	describe(PropertyUmlNotation.prototype.serialize.name, () => {
+		it('should parse public prop', () => {
+			const field = create(`prop: Type`);
+			expect(field.serialize()).toBe('+prop: Type');
+		});
+
+		it('should parse private prop', () => {
+			const field = create(`private prop: Type`);
+			expect(field.serialize()).toBe('-prop: Type');
+		});
+
+		it('should parse protected prop', () => {
+			const field = create(`protected prop: Type`);
+			expect(field.serialize()).toBe('#prop: Type');
+		});
+
+		it('should parse prop without type', () => {
+			const field = create(`protected prop`);
+			expect(field.serialize()).toBe('#prop');
+		});
+
+		it('should parse prop without type with initializer', () => {
+			const field = create(`prop = 1`);
+			expect(field.serialize()).toBe('+prop');
+		});
+
+		it('should parse prop with generic type', () => {
+			const field = create(`prop : Promise<void> `);
+			expect(field.serialize()).toBe('+prop: Promise<void>');
+		});
+
+		it('should parse ctor parameter type', () => {
+			const classBody = `class A { constructor(private prop: Type){} }`;
+			const field = UmlNotation.fromInstanceMember(
+				parseClassDeclaration(classBody).getInstanceMembers()[0],
+			);
+			expect(field.serialize()).toBe('-prop: Type');
+		});
+	});
+});
+
+describe(MethodUmlNotation.name, () => {
+	describe(MethodUmlNotation.prototype.serialize.name, () => {
+		it('should parse public method', () => {
+			const field = create(`method(){}`);
+			expect(field.serialize()).toBe('+method()');
+		});
+
+		it('should parse private method', () => {
+			const field = create(`private method(){}`);
+			expect(field.serialize()).toBe('-method()');
+		});
+
+		it('should parse method with return type', () => {
+			const field = create(`method():Type{}`);
+			expect(field.serialize()).toBe('+method(): Type');
+		});
+
+		it('should parse method with param', () => {
+			const field = create(`method(param){}`);
+			expect(field.serialize()).toBe('+method(param)');
+		});
+
+		it('should parse method with typed param', () => {
+			const field = create(`method(param: Param){}`);
+			expect(field.serialize()).toBe('+method(param: Param)');
+		});
+
+		it('should parse method with typed params', () => {
+			const field = create(`method(param: Param1, param2:Param2):Param3{}`);
+			expect(field.serialize()).toBe('+method(param: Param1, param2: Param2): Param3');
+		});
+
+		it('should parse method with generic type', () => {
+			const field = create(`method(): Promise<void> {}`);
+			expect(field.serialize()).toBe('+method(): Promise<void>');
+		});
+	});
+});
+
+function create(code: string): UmlNotation {
+	const classBody = `class A{${code}}`;
+	return UmlNotation.fromInstanceMember(parseClassDeclaration(classBody).getInstanceMembers()[0]);
+}
diff --git a/src/refactorings/extract-class/extract-class.test.ts b/src/refactorings/extract-class/extract-class.test.ts
new file mode 100644
index 00000000..a5d994a7
--- /dev/null
+++ b/src/refactorings/extract-class/extract-class.test.ts
@@ -0,0 +1,31 @@
+import { ErrorReason, Code } from "../../editor/editor";
+import { InMemoryEditor } from "../../editor/adapters/in-memory-editor";
+import { testEach } from "../../tests-helpers";
+
+import { extractClass } from "./extract-class";
+
+describe("Extract Class", () => {
+  testEach<{ code: Code; expected: Code }>(
+    "should extract class",
+    [
+      // TODO: write successful test cases here
+    ],
+    async ({ code, expected }) => {
+      const editor = new InMemoryEditor(code);
+
+      await extractClass(editor);
+
+      expect(editor.code).toBe(expected);
+    }
+  );
+
+  it("should show an error message if refactoring can't be made", async () => {
+    const code = `// This is a comment, can't be refactored`;
+    const editor = new InMemoryEditor(code);
+    jest.spyOn(editor, "showError");
+
+    await extractClass(editor);
+
+    expect(editor.showError).toBeCalledWith(ErrorReason.CanNotExtractClass);
+  });
+});