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.

Caricature de projet avec ses dependencies
â„č 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
â„č 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. 😘