Un IDE Flutter sur mesure avec custom_lint

le 03/10/2023 par Rémi Dormoy
Tags: Software Engineering, Mobile

Dans un soucis de maintenabilité, de performance et de lisibilité du code, on définit sur nos projets tout un nombre de bonnes pratiques, de règles de vie, ou de standards que l’on essaie d’appliquer “au mieux” que ce soit dans la phase de développement ou dans la phase de code review.
Pour tous ceux à qui le respect de toutes ces règles tient à cœur, la charge mentale, ainsi que le temps accordé à vérifier leur bonne application sont conséquents. Sans compter le temps passer à transmettre ces règles, et à convaincre de leur utilité.
Dans cette optique, Flutter nous propose un outil aussi complet que rapide dans sa boucle de feedback : le linter. Sans rentrer plus dans les détails du fonctionnement de ce Linter, nous allons simplement dire que c’est un serveur qui tourne sur notre machine, pour analyser en continu notre code, et remonter des erreurs ou des avertissements.

Structure d’une règle dans l’analyser de base

En analysant nos fichiers les uns après les autres, le linter Flutter va appliquer un set de règles conséquent, et surtout, pour chacune de ces règles, nous fournir l’explication associée à la règle, un exemple de bonne et de mauvaise utilisation, et parfois une correction automatique.

Pour expliciter chaque composant d’une règle de lint nous allons regarder plus précisément l’exemple de la règle “use_decorated_box” :

Cette règle comporte :
  - un code unique “use_decorated_box” : c’est son identifiant, on peut l’utiliser notamment si on souhaite ignorer cette règle en écrivant :


// ignore: use_decorated_box

- une description : un texte clair et concis expliquant rapidement en quoi consiste cette règle. Ce texte sera affiché à l’utilisateur quand il passera son curseur au-dessus d’un bout de code ne respectant pas cette règle.

- une documentation : cette partie ne sera pas directement visible dans notre IDE. C’est la documentation de l’analyser disponible sur internet qui nous permet  de comprendre comment appliquer la règle ainsi que le pourquoi de son existence.
Généralement, on y trouve des exemples de code respectant la règle et des exemples ne la respectant pas.

- une correction automatique (facultative) : certaines règles ont en bonus la possibilité d’être corrigées automatiquement. En tant que développeur, il ne nous reste plus qu’à faire ALT+Entrée et Android Studio nous propose le fix.

Par la suite, quand nous essaierons de créer nos propres règles, nous essaierons de garder la même structure, avec code, description, documentation et quick-fix.

Créer ses propres règles avec custom_lint

Disclaimer : les développeurs du plugin ont écrit un article (en anglais) le décrivant et dans lequel vous pourrez retrouver la majorité du contenu dont je vais vous parler, avec des exemples différents mais qui reprennent le même fonctionnement. Nous allons donc passer certains éléments assez rapidement.
Nous allons également prendre comme exemple des règles que nous avons implémentées sur notre projet “ens” et il y aura donc un certain nombre de variables suffixées avec “ens”.
Avant de rentrer plus en détails dans le “comment” utiliser custom_lint, quelques explications sur le “pourquoi” de son utilisation.

Le Linter de base de Flutter est déjà un très bon outil, mais ses règles sont “génériques” dans le sens où elles peuvent théoriquement s’appliquer à n’importe quel projet et elles ne couvrent pas des standards décidés par l’équipe de développement voire par l’équipe produit.
Elles s’appliquent également seulement au framework Flutter de base et donc pas sur des librairies externes que votre équipe ait pu rajouter. L’exemple fourni par la team de custom_lint (invertase) porte d’ailleurs sur une règle applicable aux librairies provider ou riverpod.

Custom_lint, puisque c’est le sujet de cette deuxième partie, est un plugin que l’on peut rajouter à l’analyser de base de Flutter. Pour le décrire de manière rapide, il nous permet de nous relier à un package flutter dans lequel on va pouvoir écrire des classes permettant de déclencher des warnings et/ou des erreurs quand l’analyser va parcourir chacun de nos fichiers.

Installation de base

Comme vu plus haut, custom_lint va nous demander de tirer une dépendance vers un autre package Dart dans notre projet. Et en dehors de l’import de la lib en elle-même, ce sera le seul import à rajouter, les deux évidement dans les dev_dependencies. On n’a donc, comme on peut le prévoir, pas d’import à rajouter dans notre appplication “en production”.
Puis dans ce package Dart à créer, il faut ensuite déclarer la liste des règles à ajouter à notre plugin. Règles que l’on écrira par la suite. Nous allons également, dans ce package, tirer les dépendances au plugin qui nous fourniront les outils pour implémenter les différentes règles. Ce qui donne :

pubspec.yaml

dev_dependencies:
  custom_lint: ^0.5.3
  ens_custom_lint_rules:
    path: ./ens_custom_lint_rules/

ens_custom_lint_rules/pubspec.yaml

dependencies:
  analyzer: ^5.11.0
  analyzer_plugin: ^0.11.2
  custom_lint: ^0.5.3
  custom_lint_builder: ^0.5.3
  custom_lint_core: ^0.5.3

ens_custom_lint_rules/lib/src/ens_custom_lint_rules.dart

library;

export 'src/ens_custom_lint_rules_base.dart';

ens_custom_lint_rules/lib/src/ens_custom_lint_rules_base.dart

PluginBase createPlugin() => _EnsLintPlugin();
class _EnsLintPlugin extends PluginBase {
  @override
  List getLintRules(CustomLintConfigs configs) => [
        const _DontUseSingleChildScrollView(), // Règle pas encore implémentée
      ];
  @override
  List getAssists() => [];
}

Et voilà ! La configuration est faite, il ne reste plus qu’à implémenter notre première règle, nommée arbitrairement _DontUseSingleChildScrollView, qui, comme son nom l’indique, indiquera un warning au développeur s’il essaie d’utiliser le Widget Flutter SingleChildScrollView (Nous avons mis cette règle en place sur le projet pour nous forcer à utiliser un Widget custom ayant les mêmes propriétés mais intégrant une scrollbar).

Création de la règle et de sa documentation

Comme votre IDE vous l’indique, si vous avez écrit le code vu plus haut, il va nous falloir implémenter une classe étendant DartLintRule.

class _DontUseSingleChildScrollView extends DartLintRule {
  @override
  void run(
    CustomLintResolver resolver,
    ErrorReporter reporter,
    CustomLintContext context,
  ) {
    // TODO
  }
  @override
  List getFixes() {
    // TODO
  }
  const _DontUseSingleChildScrollView()
      : super(
          code: const LintCode(
            name: 'dont_use_singlechildscrollview',
            problemMessage: 'Ne pas utiliser SingleChildScrollView mais ScrollViewWithScrollbar',
          ),
        );
}

Ici nous avons rajouté dans le constructeur de notre classe le code de notre warning ainsi que le message de description qui sera affiché au développeur s’il passe sa souris dessus. C’est aussi à cet endroit là que nous pouvons choisir la sévérité, mais également un URL permettant de rediriger vers la documentation de cette règle.
C’est aussi, malheureusement la dernière étape simple de notre parcours ! Courage !

Détection d’une infraction à notre règle

Pour cette partie, nous allons regarder la méthode run de la classe du dessus. Cette méthode sera appelée à chaque fois qu’un fichier sera analysé. Elle nous met également à disposition trois paramètres :

- le resolver : il recueille toutes les informations dont on peut avoir besoin sur le fichier en cours. Il peut par exemple nous retourner le chemin (“path”) du fichier. Sur le projet nous l’avons par exemple utilisé pour implémenter une règle relevant une erreur si le nom du fichier en cours d’analyse ne finit pas par “_test.dart”.

void run(
  CustomLintResolver resolver,
  ErrorReporter reporter,
  CustomLintContext context,
) {
  var path = resolver.path;
  if (path.contains('/test/') && !(path.endsWith('_test.dart'))) {
    // TODO remonter une erreur
  }
}

- le reporter : comme son nom l’indique, il va nous servir à remonter les erreurs. Il va nous mettre à disposition plusieurs méthodes permettant de reporter une erreur en la liant à différents éléments.  Le choix de la méthode utilisée est relativement important dans le sens où il va définir le code souligné par l’erreur. Il permet également de pouvoir rendre dynamique le code d’erreur si l’on veut par exemple afficher des informations spécifiques à l’erreur en cours. Ci dessous nous avons implémenté une erreur remontant les paramètres oubliés dans la méthode “props” d’une classe étendant Equatable :

if (nomManquants.isNotEmpty) {
  reporter.reportErrorForElement(
      LintCode(
        name: 'add_all_props_in_equatable',
        problemMessage: 'Il manque les params ${nomManquants.join(', ')} dans les props',
        correctionMessage: 'Ajouter toutes les variables dans les props',
      ),
      element);
}

- le context : le paramètre selon moi le plus complexe des trois. Il contient les informations relatives à l’analyse en cours. Nous allons par la suite étudier seulement un aspect de ce paramètre : le registry. Ce registry permet de pouvoir enregistrer des callback lorsque l’analyser rencontre certains événements en parcourant le fichier, comme la déclaration d’une classe, d’une variable ou d’une méthode. Nous allons regarder plus précisément en dessous l’exemple de la règle citée tout en haut qui signale l’utilisation du Widget SingleChildScrollView :

@override
void run(
  CustomLintResolver resolver,
  ErrorReporter reporter,
  CustomLintContext context,
) {
  context.registry.addConstructorName((node) {
    var className = node.staticElement?.enclosingElement.name;
    var classDisplayName = node.staticElement?.enclosingElement.displayName;
    if ((className == 'SingleChildScrollView' || classDisplayName == 'SingleChildScrollView') &&
        node.staticElement != null) {
      reporter.reportErrorForNode(code, node);
    }
  });
}

À ce moment-là, si vous êtes comme moi, et que vous n’avez pas l’habitude de parcourir les entrailles du langage Dart, tout commence à se compliquer, et on sort de l’expérience de dev habituelle. Récapitulons un peu ce qui se passe dans la méthode ci- dessus.

context.registry.addConstructorName((node) {
    ...
});

Ici nous allons enregistrer une callback appelée lorsque le constructeur d’une classe sera appelée. En l’occurrence, le constructeur appelé qui nous intéresse sera celui de SingleChildScrollView. On voit également que dans cette callback nous allons avoir accès à un paramètre node de type ConstructorName. Si vous vous lancez dans l’aventure d’écrire votre propre règle par la suite, je vous conseille de prendre l’habitude de naviguer dans le code de ces différents objets, dans lequel vous trouverez une description bienvenue de ce qu’ils représentent et à quoi ils ressemblent.
Toujours là ? Continuons.

context.registry.addConstructorName((node) {
  var classDisplayName = node.staticElement?.enclosingElement.displayName;
  if (classDisplayName == 'SingleChildScrollView') {
    reporter.reportErrorForNode(code, node);
  }
});

A l’aide de notre paramètre node, nous allons pouvoir creuser quelles sont les informations contenues dans notre appel de constructeur. En l’occurrence, son type statique, qui est en fait la classe associée à ce constructeur. Puis à l’intérieur de ce type statique, le nom d'affichage de ce type, qui dans le cas concernant notre règle serait 'SingleChildScrollView'. Dans ce cas, comme vu précédemment, il nous suffira de reporter une erreur et le tour est joué !
Le code de cette règle n’est pas parfait, puisqu’il ne checke que le nom du type, et il remontera donc une erreur également si vous utilisez une classe appelée SingleChildScrollView, même si elle n’est pas celle venant de material. Je vous laisse libre de décider si cela serait un faux positif ou un vrai warning.

La correction automatique (quick-fix)

Votre warning de lint étant maintenant prêt à être affiché au développeur, il vous reste encore la possibilité de l’aider dans sa correction en lui suggérant un quick-fix tel que vu dans la première partie de cet article. Nous resterons sur l’exemple du dessus et de notre bannissement de la SingleChildScrollView pour comprendre comment rajouter un quick-fix. Si nous revenons dans notre classe _DontUseSingleChildScrollView nous pouvons y trouver une méthode que nous avons laissée de côté :  getFixes. Dans le cas ou nous n’avons pas de quick-fix à proposer à notre développeur, nous pouvons simplement renvoyer une liste vide, ici nous allons faire créer une nouvelle classe et la retourner :

@override
  List getFixes() {
    return [_DontUseSingleChildScrollViewFix()];
  }
...
class _DontUseSingleChildScrollViewFix extends DartFix {
  @override
  void run(
    CustomLintResolver resolver,
    ChangeReporter reporter,
    CustomLintContext context,
    AnalysisError analysisError,
    List others,
  ) {
    TODO
  }
}

Nous allons découvrir un nouveau type de classe : DartFix. Comme avec la classe DartLintRule nous allons nous occuper de la méthode run, qui reprend les mêmes paramètres et en ajoute deux nouveaux :
- analysisError : qui contient les informations liées à l’erreur (ou le warning) remontées par la règle écrite dans la classe précédente.
- others : contenant les autres erreurs du même type remontées dans le même fichier.
Nous allons notamment nous servir ici de l’analysisError pour trouver où appliquer notre correctif :

@override
void run(
  CustomLintResolver resolver,
  ChangeReporter reporter,
  CustomLintContext context,
  AnalysisError analysisError,
  List<AnalysisError> others,
) {
  final changeBuilder = reporter.createChangeBuilder(
    message: 'Remplacer par ScrollviewWithScrollbar',
    priority: 1,
  );
  changeBuilder.addDartFileEdit((builder) {
    builder.addSimpleReplacement(
      SourceRange(analysisError.offset, analysisError.length),
      'ScrollviewWithScrollbar',
    );
    builder.importLibraryElement(Uri.parse('package:my_project/ui/widgets/scrollview_with_scrollbar.dart'));
  });
}

Nous réutilisons ici le reporter vu précédemment, à qui on va rajouter cette fois un changeBuilder au lieu d’une erreur, et ensuite attacher à ce change builder deux changements :

builder.addSimpleReplacement(
      SourceRange(analysisError.offset, analysisError.length),
      'ScrollviewWithScrollbar',
    );

On commence tout d’abord par remplacer le constructeur de SingleChildScrollView, qui doit normalement correspondre aux positions délimitées par l’analysisError, par la classe que l’on veut utiliser soit ScrollviewWithScrollbar.

builder.importLibraryElement(Uri.parse('package:fr_cnamts_ens/ui/widgets/scrollview_with_scrollbar.dart');

Puis, pour pouvoir l’utiliser, on rajoute un import vers cette librairie. À savoir que si cet import existe déjà, il ne sera pas fait en double. Et si il n’existe pas, il sera ajouté, au bon endroit, trié de manière alphabétique.

Et voilà !  On a fait le tour de tout ce qu’il y a à écrire pour avoir nos nouveaux sets de règles prêts à analyser notre projet !

Petit outil pratique si vous voulez vous lancer dans l’écriture de votre règle : le débugueur !

En théorie, vous avez maintenant tout ce qu’il vous faut pour vous lancer dans l’écriture de votre première règle. En pratique, naviguer dans les différents objets qui vous seront proposés notamment dans les nodes qui apparaîtront quand l’analyser parcourera vos fichiers est loin d’être instinctif au début, et il est souvent très pratique de pouvoir jeter un œil aux données à votre disposition au moment de l’analyse du fichier.
Heureusement, il est possible de rapidement et facilement attacher un débugueur à votre process de lint, d’y poser des points d’arrêts, etc… bref tout ce que vous avez déjà l’habitude de trouver dans votre débugueur flutter classique !
Pour ça, il suffit de lancer la commande suivante :


dart run custom_lint --watch

Après que votre projet ait compilé, vous verrez apparaître des informations similaires à


The Dart VM service is listening on http://127.0.0.1:62818/Z0UAb7PkQ5U=/
The Dart DevTools debugger and profiler is available at: http://127.0.0.1:62818/Z0UAb7PkQ5U=/devtools?uri=ws://127.0.0.1:62818/Z0UAb7PkQ5U=/ws

Si vous cliquez sur le deuxième lien, vous serez redirigé sur une console, avec plusieurs onglets (que je n’ai pas encore eu le temps de tous creuser, désolé), dont celui du débugueur. Une fois dedans, vous pouvez naviguer jusqu’au fichier où vous voulez poser un breakpoint.

Vous aurez également accès à une console, avec même un peu d’autocomplétion, pour y tester toutes les opérations que vous voulez. Personnellement c’est comme ça que j’ai réussi à avancer, petit pas par petit pas, pour comprendre les différents objets à manipuler, et enfin à réussir à écrire mes différentes règles, puis à les améliorer.