Refatoração automatizada com ts-morph

Introdução
Refatorar código é um processo de reestruturação de código sem influenciar na sua funcionalidade, fazendo pequenas mudanças e transformando-o em um código melhorado, legível e eficiente.
Mas por quê refatorar código? Martin Fowler, autor do livro Refatoração: Aperfeiçoando o Design de Códigos Existentes, identifica quatro principais razões para refatorar:
- Melhora o design do software
- Torna o software mais fácil de manter
- Ajuda a encontrar defeitos
- Ajuda a programar mais rápido
Além disso, a refatoração ajuda o desenvolvedor a perceber uma nova e simples forma de fazer aquilo que tinha feito antes, examinando a implementação da solução e em seguida visualizando a melhoria, que mesmo sendo mínima é importante.
No entanto, refatorar código pode ser repetitivo e, se em projetos grandes, tedioso. Com ts-morph, uma API que permite a navegação e manipulação de código TypeScript, é possível criar um script para refatorar automaticamente algumas partes do código. Esse é um artigo que irá mostrar como usar essa biblioteca e alguns casos de uso da mesma.
Instalando
Primeiro vamos instalar a lib ts-morph:
❯ npm install --save-dev ts-morph
E também ts-node para rodar nosso script:
❯ npm install --save-dev ts-node typescript
Importando a biblioteca e iniciando o projeto
Vamos criar um arquivo refactor.ts
e implementar o nosso script, primeiro vamos importar a biblioteca e iniciar o projeto:
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
Editando uma interface
Nesse primeiro exemplo iremos editar uma interface, a renomeando, mudando suas propriedades e adicionando novas.
Temos esse arquivo com duas interfaces IFile
e ITable
:
// IFile interface
interface IFile {
_id: string
name: string
}
// ITable interface
interface ITable {
_id: string
title: string
dataIndex: string
key: string
width: string
}
Primeiro, iremos refatorar renomeando a interface e removendo o I
:
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
// Percorre todos os arquivos
sourceFiles.forEach((sourceFile) => {
// Pega todas as interfaces em um arquivo
const interfaces = sourceFile.getInterfaces()
// Percorre as interfaces
interfaces.forEach(declaration => {
// Pega o nome de cada interface
const name = declaration.getName()
// Aqui removemos o 'I'
const newName = name.replace(/^I(\[A-Z])/, '$1')
if (name === newName) {
return;
}
})
// Salva o arquivo com as mudanças
sourceFile.save()
})
Ao rodarmos nosso script com ts-node nome-script.ts
, como ficou o arquivo:
// IFile Interface
interface File {
_id: string
name: string
}
// ITable interface
interface Table {
_id: string
title: string
dataIndex: string
key: string
width: string
}
Removendo referências antigas
E se quisermos remover as referências antigas a essa interface? Existe um método chamado rename()
onde as options definem o novo nome e onde mais deve ser renomeado:
declaration.rename(newName, {
// Qualquer comentário referenciando essa interface será renomeado
renameInComments: true,
// Qualquer string referenciando essa interface será renomeada
renameInStrings: true
})
Adicionando esse trecho, o script fica dessa forma:
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
// Percorre todos os arquivos
sourceFiles.forEach((sourceFile) => {
// Pega todas as interfaces em um arquivo
const interfaces = sourceFile.getInterfaces()
// Percorre as interfaces
interfaces.forEach(declaration => {
// Pega o nome de cada interface
const name = declaration.getName()
// Aqui removemos o 'I'
const newName = name.replace(/^I(\[A-Z])/, '$1')
if (name === newName) {
return;
}
declaration.rename(newName, {
// Qualquer comentário referenciando essa interface será renomeado
renameInComments: true,
// Qualquer string referenciando essa interface será renomeada
renameInStrings: true
})
})
// Salva o arquivo com as mudanças
sourceFile.save()
})
Assim, após o método rename()
os comentários no arquivo ficam assim:
// File Interface
interface File {
_id: string
name: string
}
// Table interface
interface Table {
_id: string
title: string
dataIndex: string
key: string
width: string
}
Editando e removendo propriedades
Agora, e se quisermos remover o _
da propriedade _id
? É possível, primeiramente utilizando o método getProperty(parâmetro)
para pegar a propriedade dentro da interface onde o parâmetro
é o nome da propriedade. Após isso, verificamos se a propriedade existe, e se existir a removemos. Então adicionamos uma nova propriedade com o método insertProperty()
:
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
sourceFiles.forEach((sourceFile) => {
// Pega todas as interfaces nos arquivos
const interfaces = sourceFile.getInterfaces()
interfaces.forEach(declaration => {
// Pega a propriedade
const oldProperty = declaration.getProperty('_id')
if (oldProperty) {
// Se existir, remove
oldProperty.remove()
// Cria uma nova propriedade na posição 0 e adiciona a estrutura que a representa
declaration.insertProperty(0, {
name: 'id',
type: 'string'
})
}
})
sourceFile.save()
})
É possível, também, mudar o tipo uma propriedade. Utilizando o mesmo fluxo de desenvolvimento anterior, pegamos a propriedade que queremos manipular, verificamos se a mesma existe e se existir a removemos e adicionamos um novo tipo a essa propriedade:
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
sourceFiles.forEach((sourceFile) => {
// Pega todas as interfaces nos arquivos
const interfaces = sourceFile.getInterfaces()
interfaces.forEach(declaration => {
// Pega a propriedade
const oldType = declaration.getProperty('key')
if (oldType) {
// Se existir, remove
oldType.remove()
// Cria uma nova propriedade na posição 3 e adiciona a estrutura que a representa
declaration.insertProperty(3, {
name: 'key',
type: 'number'
})
}
})
sourceFile.save()
})
O resultado ficou assim:
// File Interface
interface File {
id: string;
name: string
}
// Table interface
interface Table {
id: string;
title: string
dataIndex: string
key: number
width: string
}
No entanto, no resultado acima percebe-se que o código do arquivo não está formatado e a indentação não está feita de forma correta. O ts-morph disponibiliza também de métodos que formatam o arquivo, como o textFormat()
que formata o código usando a API interna do TypeScript, onde pode-se configurar a formatação.
Formatando arquivos
O método abaixo formata todos os arquivos do projeto, utilizando a configuração definida de indentações permitidas:
sourceFile.formatText({
indentSize: 2 // Indica o tamanho das indentações
})
Utilizando esse método antes do sourceFile.save()
,
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
sourceFiles.forEach((sourceFile) => {
sourceFile.formatText({
indentSize: 2
})
sourceFile.save()
})
O arquivo é formatado e retorna esse resultado:
// File Interface
interface File {
id: string;
name: string
}
// Table interface
interface Table {
id: string;
title: string
dataIndex: string
key: number
width: string
}
Pronto, tudo bem indentado!
Removendo imports desnecessários
Todos esses métodos abaixo aceitam argumentos que especificam como a formatação deve ser feita.
sourceFile.fixMissingImports()
.organizeImports()
.fixUnusedIdentifiers()
.formatText()
O método fixMissingImports()
adiciona importações de identificadores que estão referenciados no código mas não estão importadas. O método organizeImports()
, como o nome já diz, organiza as importações no arquivo. Já o método fixUnusedIdentifiers()
remove declarações que não estão sendo usadas, como de classes, interfaces, funções e variáveis dos arquivos.
Nesse trecho de código temos importações e variáveis que não estão sendo usadas:
import { useBody } from 'h3'
import s3Client from '../../../server/s3'
import prisma from '../../prisma'
export type FileData = {
folder: string
fileEncode: string
fileName: string
}
const unused = 'something'
Ao rodar esse script para formatar o arquivo:
import { Project } from 'ts-morph';
// Inicia o projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos do projeto
const sourceFiles = project.getSourceFiles()
sourceFiles.forEach((sourceFile) => {
sourceFile.fixMissingImports()
.organizeImports()
.fixUnusedIdentifiers()
.formatText({
indentSize: 2
});
sourceFile.save()
})
O script removerá todas as importações e a variável unused
pois não estavam sendo usadas, formatando assim:
export type FileData = {
folder: string
fileEncode: string
fileName: string
}
Mudando declarações de váriaveis
Existe também a possibilidade de mudar declarações de variáveis. No exemplo a seguir, todas as declarações var
serão mudadas para let
:
function printMatrix (matrix: any) {
var i
for (i = 0; i < matrix.length; i++) {
var line = matrix[i]
for (i = 0; i < line.length; i++) {
var element = line[i]
console.log(element)
}
}
var matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
printMatrix(matrix);
Para que isso aconteça, devemos utilizar o método getDescendantsOfKind(SyntaxKind.VariableStatment)
para encontrar as declarações das variáveis com sintaxe var
no arquivo, após isso verificar se é do tipo var
e mudar a sintaxe para let
, como no script a seguir:
import { Project, SyntaxKind, VariableDeclarationKind } from 'ts-morph';
// Inicia projeto
const project = new Project()
// Adiciona todos os arquivos .ts onde o script irá rodar
project.addSourceFilesAtPaths('/src/**/*.ts')
// Pega todos os arquivos
const sourceFiles = project.getSourceFiles()
sourceFiles.forEach((sourceFile) => {
// Encontra todas as declarações de variáveis
const varDeclaration = sourceFile.getDescendantsOfKind(SyntaxKind.VariableStatement)
// Percorre pelo arquivo e encontra cada declaração var
varDeclaration.forEach((varStatement) => {
const declaration = varStatement.getDeclarationKind()
// Se a declaração for igual a var
if (declaration === VariableDeclarationKind.Var) {
// A declaração var se torna let
varStatement.setDeclarationKind(VariableDeclarationKind.Let)
}
})
})
Assim, tendo o seguinte resultado:
function printMatrix (matrix: any) {
let i
for (i = 0; i < matrix.length; i++) {
let line = matrix[i]
for (i = 0; i < line.length; i++) {
let element = line[i]
console.log(element)
}
}
}
let matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
printMatrix(matrix)
Conclusão
Conforme visto, é possível refatorar de forma automatizada utilizando ts-morph, ajudando a diminuir o tempo do processo de refatorar código. Nesse sentido, o uso dessa lib pode ser eficiente em projetos grandes onde há chance da refatoração ser maçante e demorada.