Dans cet article, je vais vous présenter JSCodeshift, une libraire qui va vous permettre d’analyser et appliquer automatiquement des modifications sur du code Javascript ou Typescript.
Cas d’école 👨🎓
Maintenir à jour les dépendances de nos projets JS est l’une des règles primordiales que nous nous efforçons de bien respecter pour ne pas avoir à jeter nos applications tous les deux ans. 🗑
Cette tâche exige souvent d’un développeur plus de travail que de simplement changer les versions des libraires dans le package.json. Si une dépendance est utilisée dans différentes parties du code et qu’un breaking-change est introduit, on peut vite se retrouver avec des centaines de fichiers à modifier manuellement.
ℹ️ Exemple d'un project Javascript qui ne respecte pas cette règle
C’est un problème de ce genre que nous avons rencontré lors de la mise à jour de notre librairie d’internationalisation sur notre web app React en JS.
Après mise à jour, l’appel à l’API de la librairie change de forme :
//Before
const t: (
translationKey: string,
// All options are passed as parameters
data?: object, // Data used for interpolation
number?: number, // Amount used for plural form
general?: boolean, // Use general plural form
renderers?: object // JSX renderers
) => string
//After
const t: (
translationKey: string,
// Object containing all options
options?: {
data?: object, // Data used for interpolation
number?: number, // Amount used for plural form
general?: boolean, // Use general plural form
renderers?: object // JSX renderers
}
) => string
Plus simplement, quelques exemples de transformations :
// Before
const title1 = t('translationKeyExample')
const title2 = t(labelKey, { someData }, aNumber);
const title3 = t('translationKeyExample', undefined, 0);
// After
const title1 = t('translationKeyExample'); // Basic usecase with only one argument, nothing changed on this one
const title2 = t(labelKey, { data: { someData }, number: aNumber });
const title3 = t('translationKeyExample', { number: 0 });
Dans le cas le plus basique sans les arguments optionnels t(‘translationKey’)
nous n’avons rien à modifier, mais dans les autres cas, il y a du changement à faire. 🧹
Les solutions que nous avons écartées ❌
- Avec un Find All, trouver toutes les utilisations de la librairie et modifier les appels problématiques à la main.
- Cette solution est la plus simple, mais peut être très répétitive, ce qui augmente la probabilité de faire une erreur. On aura du mal à uniquement filtrer les cas spécifiques qui nous intéressent.
- Utiliser des RegExp pour mieux cibler les cas spécifiques
- Cela nous a permis de faire rapidement une estimation approximative du nombre de cas qu’il nous faudrait modifier, mais nous avons eu du mal à cibler correctement tous les appels et la modification se fait toujours à la main.
- Créer un fichier de définition TypeScript pour la librairie, et laisser le Language Server Protocol ou son IDE trouver les appels problématiques
- La solution la plus rapide et la plus fiable pour la partie détection, mais qui demande toujours de faire les modifications à la main.
Mais il nous restait encore un Joker pour cette tâche. 🃏
JSCodeshift 🪄
Cette librairie permet d’exposer facilement l’Abstract Syntax Tree, autrement dit la représentation du code après le parsing des fichiers. Nous pouvons ainsi écrire des scripts qui nous permettent de parcourir cet arbre, de le modifier facilement, d’appliquer les modifications et de les formater. Ces scripts s’appellent des codemods.
Pour en savoir un peu plus sur l’Abstract Syntax Tree, je vous conseille de jeter un coup d’œil à ASTExplorer qui vous permet de visualiser l’AST d’un fichier facilement pour en comprendre le fonctionnement.
Quelques librairies ont proposé des codemods lors de leurs grosses mises à jour, par exemple React avec react-codemod.
ℹ️ Capture d'écran du site ASTExplorer
En application 💪
module.exports = function (file: FileInfo, api: API) {
const j = api.jscodeshift;
// If we don't find any "Translate" string inside our file, we can assume that it's safe to skip it
const regex = new RegExp('Translate[(]', 'i');
if (!regex.test(file.source)) {
return null;
}
return j(file.source)
.find(j.CallExpression, {
callee: {
type: 'Identifier',
name: 't',
},
})
.filter(filterOutSimpleUsages)
.map(mutatePath(j))
.toSource();
};
Dans la fonction principale du script, j’ai utilisé une expression régulière pour filtrer les fichiers qui ne possèdent pas la chaîne de caractères Translate(
.
Ceci permet de gagner un peu de temps sur l’exécution. ⌛️
Ensuite, je cherche dans le fichier une ou plusieurs variables t
. Si aucune n’est présente, on peut passer au fichier suivant, sinon on continue le raffinage.
On passe dans un filtre qui va nous permettre d’enlever les usages de la fonction t
avec un seul argument qui ne posent pas de problème.
const requiredPropertiesKeys = ['data', 'number', 'general', 'renderers'] as const;
// Filter function to ensure that we enter the mutation function only if needed
const filterOutSimpleUsages = (p: ASTPath<CallExpression>) => {
const args = p.value.arguments;
// If we only have the translation key, we don't need to refactor this usage
if (args.length === 1) {
return false;
}
// More than 2 arguments is an absolute sign of an old usage
// If second argument is not an object, we need to manually fix this case
if (args.length > 2 || args[1].type !== 'ObjectExpression') {
return true;
}
// If none of the above properties is found in second argument, we can say that this is an old usage
return requiredPropertiesKeys.every(
(requiredPropertyKey) =>
!(args[1] as ObjectExpression).properties.find(
// I needed to do some TS trickery to avoid getting warnings everywhere, sorry for that
(property) => ((property as ObjectProperty).key as Identifier).name === requiredPropertyKey,
),
);
};
Finalement, on peut passer dans la fonction de mutation, qui va nous permettre de modifier directement le code des fichiers.
// Mutation function, we apply our modification to the AST
const mutatePath = (j: JSCodeshift) => (p: ASTPath<CallExpression>) => {
const objectProperties = requiredPropertiesKeys.reduce((acc, propertyKey, index) => {
const argument = p.value.arguments[index + 1];
// If no argument or argument is a spread type, we don't take it in consideration
if (!argument || argument.type === 'SpreadElement') {
return acc;
}
// If argument is undefined, we skip it
if ((argument as Identifier).name && (argument as Identifier).name === 'undefined') {
return acc;
}
// We create a new object property with an identifier (the object key) and put our argument inside
return [...acc, j.objectProperty(j.identifier(propertyKey), argument)];
}, [] as ObjectProperty[]);
// Finally, we keep our translation key in first position and our newly created object in second argument
p.value.arguments = [p.value.arguments[0], j.objectExpression(objectProperties)];
return p;
};
On récupère les arguments déjà existants, on crée un nouvel objet et on y place nos arguments !
Résultats ✨
⏱ Pour à peu près 2900 fichiers, le script a mis moins de 5,9 secondes à s’exécuter (Macbook Pro 13” 2019).
JSCodeshift nous a permis de cibler très rapidement 99 % des cas problématiques et de les corriger automatiquement.
Le pourcentage restant concerne des cas où il était généralement difficile de cibler la fonction t
(passée en props à un autre composant sous un autre nom). Ces quelques cas ont pu être corrigés rapidement à la main et détectés grâce à nos nombreux tests (heureusement qu’on a une règle de bonne pratique pour ça 😇).
tl;dr & conclusion 🏃
Vous pouvez retrouver la source du codemod ici même.
Si vous êtes mainteneur d’une librairie, il peut être très intéressant de livrer des codemods en même temps que les breaking-changes pour faciliter l’adoption des mises à jour par exemple !
Avec une prise en main relativement facile pour un résultat très rapide, nous avons été très satisfaits de JSCodeshift et nous n’hésiterons pas à réutiliser cette librairie dans le futur. 👊
Merci à tous pour la lecture de mon premier article et JSCodeshiftez bien. 😘