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. đ