Escreva código para reescrever seu código: jscodeshift
Publicados: 2022-03-11Codemods com jscodeshift
Quantas vezes você usou a funcionalidade localizar e substituir em um diretório para fazer alterações nos arquivos de origem JavaScript? Se você for bom, você ficou sofisticado e usou expressões regulares com grupos de captura, porque vale a pena o esforço se sua base de código for considerável. Regex tem limites, no entanto. Para alterações não triviais, você precisa de um desenvolvedor que entenda o código no contexto e também esteja disposto a assumir o processo longo, tedioso e propenso a erros.
É aqui que entram os “codemods”.
Codemods são scripts usados para reescrever outros scripts. Pense neles como uma funcionalidade de localizar e substituir que pode ler e escrever código. Você pode usá-los para atualizar o código-fonte para se adequar às convenções de codificação de uma equipe, fazer alterações generalizadas quando uma API for modificada ou até mesmo corrigir automaticamente o código existente quando seu pacote público fizer uma alteração importante.
Neste artigo, vamos explorar um kit de ferramentas para codemods chamado “jscodeshift” enquanto criamos três codemods de complexidade crescente. No final, você terá ampla exposição aos aspectos importantes do jscodeshift e estará pronto para começar a escrever seus próprios codemods. Passaremos por três exercícios que cobrem alguns usos básicos, mas incríveis, de codemods, e você pode ver o código-fonte desses exercícios no meu projeto do github.
O que é jscodeshift?
O kit de ferramentas jscodeshift permite que você bombeie vários arquivos de origem por meio de uma transformação e os substitua pelo que sai do outro lado. Dentro da transformação, você analisa o código-fonte em uma árvore de sintaxe abstrata (AST), vasculha para fazer suas alterações e, em seguida, gera novamente o código-fonte do AST alterado.
A interface que o jscodeshift fornece é um wrapper em torno de pacotes recast
e ast-types
. recast
lida com a conversão de origem para AST e vice-versa, enquanto ast-types
lida com a interação de baixo nível com os nós AST.
Configuração
Para começar, instale o jscodeshift globalmente do npm.
npm i -g jscodeshift
Existem opções de execução que você pode usar e uma configuração de teste opinativa que facilita muito a execução de um conjunto de testes via Jest (uma estrutura de teste JavaScript de código aberto), mas vamos ignorar isso por enquanto em favor da simplicidade:
jscodeshift -t some-transform.js input-file.js -d -p
Isso executará input-file.js
por meio do transform some-transform.js
e imprimirá os resultados sem alterar o arquivo.
Antes de entrar, porém, é importante entender três tipos de objetos principais com os quais a API jscodeshift lida: nós, caminhos de nó e coleções.
Nós
Os nós são os blocos de construção básicos do AST, geralmente chamados de “nós AST”. Isso é o que você vê ao explorar seu código com o AST Explorer. Eles são objetos simples e não fornecem nenhum método.
Caminhos de nós
Node-paths são wrappers em torno de um nó AST fornecido por ast-types
como uma maneira de percorrer a árvore de sintaxe abstrata (AST, lembra?). Isoladamente, os nós não têm nenhuma informação sobre seu pai ou escopo, então os caminhos dos nós cuidam disso. Você pode acessar o nó encapsulado por meio da propriedade do node
e existem vários métodos disponíveis para alterar o nó subjacente. os caminhos dos nós são frequentemente chamados apenas de “caminhos”.
Coleções
As coleções são grupos de zero ou mais caminhos de nó que a API jscodeshift retorna quando você consulta o AST. Eles têm todos os tipos de métodos úteis, alguns dos quais exploraremos.
As coleções contêm caminhos de nó, caminhos de nó contêm nós e nós são do que o AST é feito. Tenha isso em mente e será fácil entender a API de consulta jscodeshift.
Pode ser difícil acompanhar as diferenças entre esses objetos e seus respectivos recursos de API, então existe uma ferramenta bacana chamada jscodeshift-helper que registra o tipo de objeto e fornece outras informações importantes.
Exercício 1: remover chamadas para o console
Para começarmos, vamos começar removendo chamadas para todos os métodos de console em nossa base de código. Embora você possa fazer isso com localizar e substituir e um pouco de regex, começa a ficar complicado com instruções de várias linhas, literais de modelo e chamadas mais complexas, por isso é um exemplo ideal para começar.
Primeiro, crie dois arquivos, remove-consoles.js
e remove-consoles.input.js
:
//remove-consoles.js export default (fileInfo, api) => { };
//remove-consoles.input.js export const sum = (a, b) => { console.log('calling sum with', arguments); return a + b; }; export const multiply = (a, b) => { console.warn('calling multiply with', arguments); return a * b; }; export const divide = (a, b) => { console.error(`calling divide with ${ arguments }`); return a / b; }; export const average = (a, b) => { console.log('calling average with ' + arguments); return divide(sum(a, b), 2); };
Aqui está o comando que usaremos no terminal para enviá-lo através do jscodeshift:
jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
Se tudo estiver configurado corretamente, ao executá-lo, você deverá ver algo assim.
Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 0 unmodified 1 skipped 0 ok Time elapsed: 0.514seconds
OK, isso foi um pouco anticlimático, já que nossa transformação ainda não faz nada, mas pelo menos sabemos que está tudo funcionando. Se ele não for executado, certifique-se de ter instalado o jscodeshift globalmente. Se o comando para executar a transformação estiver incorreto, você verá uma mensagem “ERROR Transform file … does not exist” ou “TypeError: path must be a string or Buffer” se o arquivo de entrada não puder ser encontrado. Se você digitou algo, deve ser fácil identificar com os erros de transformação muito descritivos.
Nosso objetivo final, porém, após uma transformação bem-sucedida, é ver esta fonte:
export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); };
Para chegar lá, precisamos converter a fonte em um AST, encontrar os consoles, removê-los e depois converter o AST alterado novamente em fonte. Os primeiros e últimos passos são fáceis, é só:
remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
Mas como encontramos os consoles e os removemos? A menos que você tenha algum conhecimento excepcional da API do Mozilla Parser, provavelmente precisará de uma ferramenta para ajudar a entender como é o AST. Para isso, você pode usar o AST Explorer. Cole o conteúdo de remove-consoles.input.js
nele e você verá o AST. Há muitos dados, mesmo no código mais simples, por isso ajuda a ocultar dados e métodos de localização. Você pode alternar a visibilidade das propriedades no AST Explorer com as caixas de seleção acima da árvore.
Podemos ver que as chamadas para os métodos do console são chamadas de CallExpressions
, então como as encontramos em nossa transformação? Usamos as consultas do jscodeshift, lembrando nossa discussão anterior sobre as diferenças entre Collections, node-paths e os próprios nodes:
//remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
A linha const root = j(fileInfo.source);
retorna uma coleção de um caminho de nó, que envolve o nó AST raiz. Podemos usar o método find
da coleção para procurar nós descendentes de um determinado tipo, assim:
const callExpressions = root.find(j.CallExpression);
Isso retorna outra coleção de caminhos de nó contendo apenas os nós que são CallExpressions. À primeira vista, isso parece o que queremos, mas é muito amplo. Podemos acabar executando centenas ou milhares de arquivos por meio de nossas transformações, portanto, precisamos ser precisos para ter certeza de que funcionará conforme o esperado. A find
ingênua acima não apenas encontraria o console CallExpressions, mas encontraria cada CallExpression na fonte, incluindo
require('foo') bar() setTimeout(() => {}, 0)
Para forçar maior especificidade, fornecemos um segundo argumento para .find
: Um objeto de parâmetros adicionais, cada nó precisa ser incluído nos resultados. Podemos olhar para o AST Explorer para ver que nossas chamadas console.* têm a forma de:
{ "type": "CallExpression", "callee": { "type": "MemberExpression", "object": { "type": "Identifier", "name": "console" } } }
Com esse conhecimento, sabemos refinar nossa consulta com um especificador que retornará apenas o tipo de CallExpressions em que estamos interessados:
const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, });
Agora que temos uma coleção precisa dos sites de chamadas, vamos removê-los do AST. Convenientemente, o tipo de objeto de coleção possui um método remove
que fará exatamente isso. Nosso arquivo remove-consoles.js
ficará assim:
//remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source) const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ); callExpressions.remove(); return root.toSource(); };
Agora, se executarmos nossa transformação a partir da linha de comando usando jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
, devemos ver:
Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); }; All done. Results: 0 errors 0 unmodified 0 skipped 1 ok Time elapsed: 0.604seconds
Isso parece bom. Agora que nossa transformação altera o AST subjacente, usar .toSource()
gera uma string diferente da original. A opção -p do nosso comando exibe o resultado, e uma contagem das disposições para cada arquivo processado é mostrada na parte inferior. A remoção da opção -d de nosso comando substituiria o conteúdo de remove-consoles.input.js pela saída da transformação.
Nosso primeiro exercício está completo... quase. O código tem uma aparência bizarra e provavelmente muito ofensivo para qualquer purista funcional por aí e, portanto, para melhorar o fluxo do código de transformação, o jscodeshift tornou a maioria das coisas encadeadas. Isso nos permite reescrever nossa transformação assim:
// remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; return j(fileInfo.source) .find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ) .remove() .toSource(); };
Muito melhor. Para recapitular o exercício 1, agrupamos a origem, consultamos uma coleção de caminhos de nó, alteramos o AST e, em seguida, regeneramos essa origem. Molhamos os pés com um exemplo bem simples e tocamos nos aspectos mais importantes. Agora, vamos fazer algo mais interessante.
Exercício 2: Substituindo Chamadas de Método Importadas
Para este cenário, temos um módulo de “geometria” com um método chamado “circleArea” que descontinuamos em favor de “getCircleArea”. Poderíamos facilmente encontrá-los e substituí-los por /geometry\.circleArea/g
, mas e se o usuário tiver importado o módulo e atribuído a ele um nome diferente? Por exemplo:
import g from 'geometry'; const area = g.circleArea(radius);
Como saberíamos substituir g.circleArea
em vez de geometry.circleArea
? Certamente não podemos assumir que todas as chamadas circleArea
são as que estamos procurando, precisamos de algum contexto. É aqui que os codemods começam a mostrar seu valor. Vamos começar criando dois arquivos, deprecated.js
e deprecated.input.js
.
//deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
deprecated.input.js import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.circleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));
Agora execute este comando para executar o codemod.

jscodeshift -t ./deprecated.js ./deprecated.input.js -d -p
Você deve ver a saída indicando que a transformação foi executada, mas ainda não mudou nada.
Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 1 unmodified 0 skipped 0 ok Time elapsed: 0.892seconds
Precisamos saber como nosso módulo de geometry
foi importado. Vamos dar uma olhada no AST Explorer e descobrir o que estamos procurando. Nossa importação assume este formato.
{ "type": "ImportDeclaration", "specifiers": [ { "type": "ImportDefaultSpecifier", "local": { "type": "Identifier", "name": "g" } } ], "source": { "type": "Literal", "value": "geometry" } }
Podemos especificar um tipo de objeto para encontrar uma coleção de nós como este:
const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, });
Isso nos dá a ImportDeclaration usada para importar “geometria”. A partir daí, procure o nome local usado para armazenar o módulo importado. Como esta é a primeira vez que fazemos isso, vamos apontar um ponto importante e confuso ao começar.
Nota: É importante saber que root.find()
retorna uma coleção de caminhos de nós. A partir daí, o método .get(n)
retorna o caminho do nó no índice n
nessa coleção e, para obter o nó real, usamos .node
. O nó é basicamente o que vemos no AST Explorer. Lembre-se, o caminho do nó é principalmente informações sobre o escopo e os relacionamentos do nó, não o próprio nó.
// find the Identifiers const identifierCollection = importDeclaration.find(j.Identifier); // get the first NodePath from the Collection const nodePath = identifierCollection.get(0); // get the Node in the NodePath and grab its "name" const localName = nodePath.node.name;
Isso nos permite descobrir dinamicamente como nosso módulo de geometry
foi importado. Em seguida, encontramos os lugares em que está sendo usado e os alteramos. Ao olhar para o AST Explorer, podemos ver que precisamos encontrar MemberExpressions que se parecem com isso:
{ "type": "MemberExpression", "object": { "name": "geometry" }, "property": { "name": "circleArea" } }
Lembre-se, porém, que nosso módulo pode ter sido importado com um nome diferente, então temos que levar em conta isso fazendo nossa consulta ficar assim:
j.MemberExpression, { object: { name: localName, }, property: { name: "circleArea", }, })
Agora que temos uma consulta, podemos obter uma coleção de todos os sites de chamada para nosso método antigo e usar o método replaceWith()
da coleção para trocá-los. O método replaceWith()
itera pela coleção, passando cada caminho de nó para uma função de retorno de chamada. O Nó AST é então substituído por qualquer Nó que você retornar do retorno de chamada.
Assim que terminarmos a substituição, geramos a fonte como de costume. Aqui está nossa transformação finalizada:
//deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "geometry" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, }); // get the local name for the imported module const localName = // find the Identifiers importDeclaration.find(j.Identifier) // get the first NodePath from the Collection .get(0) // get the Node in the NodePath and grab its "name" .node.name; return root.find(j.MemberExpression, { object: { name: localName, }, property: { name: 'circleArea', }, }) .replaceWith(nodePath => { // get the underlying Node const { node } = nodePath; // change to our new prop node.property.name = 'getCircleArea'; // replaceWith should return a Node, not a NodePath return node; }) .toSource(); };
Quando executamos a fonte através da transformação, vemos que a chamada para o método obsoleto no módulo geometry
foi alterada, mas o restante foi deixado inalterado, assim:
import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.getCircleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));
Exercício 3: Alterando uma Assinatura de Método
Nos exercícios anteriores, abordamos a consulta de coleções para tipos específicos de nós, a remoção de nós e a alteração de nós, mas e a criação de nós completamente novos? É isso que vamos abordar neste exercício.
Nesse cenário, temos uma assinatura de método que ficou fora de controle com argumentos individuais à medida que o software cresceu e, portanto, foi decidido que seria melhor aceitar um objeto contendo esses argumentos.
Em vez de car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);
nós gostaríamos de ver
const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, });
Vamos começar fazendo a transformação e um arquivo de entrada para testar:
//signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
//signature-change.input.js import car from 'car'; const suv = car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true); const truck = car.factory('silver', 'Toyota', 'Tacoma', 2006, 100000, true, true);
Nosso comando para executar a transformação será jscodeshift -t signature-change.js signature-change.input.js -d -p
e as etapas que precisamos para realizar essa transformação são:
- Encontre o nome local para o módulo importado
- Encontre todos os sites de chamada para o método .factory
- Ler todos os argumentos que estão sendo passados
- Substitua essa chamada por um único argumento que contém um objeto com os valores originais
Usando o AST Explorer e o processo que usamos nos exercícios anteriores, as duas primeiras etapas são fáceis:
//signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .toSource(); };
Para ler todos os argumentos que estão sendo passados, usamos o método replaceWith()
em nossa coleção de CallExpressions para trocar cada um dos nós. Os novos nós substituirão node.arguments por um novo argumento único, um objeto.
Vamos tentar com um objeto simples para ter certeza de que sabemos como isso funciona antes de usarmos os valores apropriados:
.replaceWith(nodePath => { const { node } = nodePath; node.arguments = [{ foo: 'bar' }]; return node; })
Quando executamos isso ( jscodeshift -t signature-change.js signature-change.input.js -d -p
), a transformação explodirá com:
ERR signature-change.input.js Transformation error Error: {foo: bar} does not match type Printable
Acontece que não podemos simplesmente colocar objetos simples em nossos nós AST. Em vez disso, precisamos usar construtores para criar nós apropriados.
Construtores de nós
Os construtores nos permitem criar novos nós corretamente; eles são fornecidos por ast-types
e exibidos por meio de jscodeshift. Eles verificam rigidamente se os diferentes tipos de nós são criados corretamente, o que pode ser frustrante quando você está cortando em um rolo, mas no final das contas, isso é uma coisa boa. Para entender como usar construtores, há duas coisas que você deve ter em mente:
Todos os tipos de nós AST disponíveis são definidos na pasta def
do projeto do github ast-types, principalmente no core.js -caso. (Isso não é declarado explicitamente, mas você pode ver que esse é o caso na fonte ast-types
Se usarmos o AST Explorer com um exemplo do que queremos que seja o resultado, podemos juntar isso com bastante facilidade. No nosso caso, queremos que o novo argumento único seja um ObjectExpression com várias propriedades. Observando as definições de tipo mencionadas acima, podemos ver o que isso implica:
def("ObjectExpression") .bases("Expression") .build("properties") .field("properties", [def("Property")]); def("Property") .bases("Node") .build("kind", "key", "value") .field("kind", or("init", "get", "set")) .field("key", or(def("Literal"), def("Identifier"))) .field("value", def("Expression"));
Portanto, o código para criar um nó AST para { foo: 'bar' } ficaria assim:
j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]);
Pegue esse código e conecte-o em nossa transformação assim:
.replaceWith(nodePath => { const { node } = nodePath; const object = j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]); node.arguments = [object]; return node; })
Executando isso nos dá o resultado:
import car from 'car'; const suv = car.factory({ foo: "bar" }); const truck = car.factory({ foo: "bar" });
Agora que sabemos como criar um nó AST adequado, é fácil percorrer os argumentos antigos e gerar um novo objeto para usar. Veja como nosso arquivo signature-change.js
se parece agora:
//signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // current order of arguments const argKeys = [ 'color', 'make', 'model', 'year', 'miles', 'bedliner', 'alarm', ]; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .replaceWith(nodePath => { const { node } = nodePath; // use a builder to create the ObjectExpression const argumentsAsObject = j.objectExpression( // map the arguments to an Array of Property Nodes node.arguments.map((arg, i) => j.property( 'init', j.identifier(argKeys[i]), j.literal(arg.value) ) ) ); // replace the arguments with our new ObjectExpression node.arguments = [argumentsAsObject]; return node; }) // specify print options for recast .toSource({ quote: 'single', trailingComma: true }); };
Execute a transformação ( jscodeshift -t signature-change.js signature-change.input.js -d -p
) e veremos que as assinaturas foram atualizadas conforme o esperado:
import car from 'car'; const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, }); const truck = car.factory({ color: 'silver', make: 'Toyota', model: 'Tacoma', year: 2006, miles: 100000, bedliner: true, alarm: true, });
Codemods com jscodeshift Recap
Demorou um pouco de tempo e esforço para chegar a este ponto, mas os benefícios são enormes quando confrontados com a refatoração em massa. Distribuir grupos de arquivos para diferentes processos e executá-los em paralelo é algo em que o jscodeshift se destaca, permitindo que você execute transformações complexas em uma enorme base de código em segundos. À medida que você se tornar mais proficiente com codemods, você começará a redirecionar os scripts existentes (como o repositório do github react-codemod ou escrever o seu próprio para todos os tipos de tarefas, e isso tornará você, sua equipe e seus usuários de pacotes mais eficientes .