🔍

【Flutter】Custom Lintを使って独自のLintルールを作成する

2023/12/17に公開

本記事は、Flutter Advent Calendar 2023の17日目の記事です。

はじめに

こんにちは!Altive株式会社のFlutterアプリ開発者の小林遼太(@naipaka)です🦙

弊社では、受託案件の開発を効率化するためにFlutterFireパッケージのラッパーパッケージを作成し、利用しています。
このパッケージの開発の過程で、特定のコーディングパターンを強制するようなLintルールが必要だと感じることがありました。
特に、StreamControllerインスタンスを宣言した際にcloseメソッドの呼び出しを強制するclose_sinksのようなルールが欲しいと考えていました。

そこで、Dartのcustom_lintを使って独自のLintルールを作成してみることにしたので、この記事ではその作成過程を紹介します。

完成例

Configインスタンスを宣言した際に、disposeメソッドの呼び出しを強制するルールです。
クラスのフィールドと関数内のローカル変数に対してルールが適用されます。


Configインスタンスを宣言した際に、disposeメソッドの呼び出しを強制するルール

Custom Lintとは

https://pub.dev/packages/custom_lint

custom_lintは、Dartプロジェクトの保守性を向上させるために、パッケージ作者や開発者が独自のLintルールを簡単に作成できるツールです。
これにより、DartのデフォルトLintルールを拡張し、特定のコーディングパターンや慣習に合わせたルールを定義することが可能になります。

特徴は以下の通りです。

  • コマンドラインツール: CIでLintのリストを取得するためのコマンドラインを自分で書く必要がない。
  • 簡易セットアップ: アナライザーサーバーやエラー処理を扱う必要がなく、Lintの作成に集中できる。
  • デバッグ: Dartデバッガーを使用してLintを検査し、ブレークポイントを設定可能。
  • ホットリスタート対応: Linterプラグインのソースコードを更新すると、IDE/アナライザーサーバーを再起動せずに動的にリスタートされる。
  • // ignore: および // ignore_for_file: の内蔵サポート: 特定の行やファイル全体のLint警告を無視するコメントに対応。
  • テスト: // expect_lint を使用した組み込みテストメカニズムを提供。
  • print(...)と例外: プラグインがエラーやデバッグメッセージを出力した場合、ログファイルを生成する。

セットアップ

custom_lintのUsageセクションに従って、独自のLintルールを作成するためのセットアップを行います。

依存関係の追加

まずは、プロジェクトを作成し、custom_lint_builderを追加します。
今回はFlutterFireのラッパーパッケージに関するLintルールなので、flutterfire_lintという名前でプロジェクトを作成します。
下記は執筆時点での最新バージョンです。

flutter create -t package packages/flutterfire_lint --project-name flutterfire_lint 
pubspec.yaml
name: flutterfire_lint
environment:
  sdk: ^3.0.0

dependencies:
  analyzer: ^6.2.0
  analyzer_plugin: ^0.11.3
  custom_lint_builder: ^0.5.7

Lintルール定義ファイルの作成

プロジェクト内にlib/flutterfire_lint.dartファイルを作成し、Lintルールを定義します。

lib/flutterfire_lint.dart
import 'package:custom_lint_builder/custom_lint_builder.dart';

// Lintルールのエントリポイント。
PluginBase createPlugin() => _FlutterFirePlugin();

class _FlutterFirePlugin extends PluginBase {
  // 独自の警告/情報/エラーをリストで定義する。
  
  List<LintRule> getLintRules(CustomLintConfigs configs) => [];
}

上記のgetLintRulesメソッドのリストに、DartLintRuleを継承した独自のLintルールを定義したクラスを追加します。

ルールの作成

それでは、Lintルールを作成していきます。

ルールの定義

今回は、flutterfire_configuratorパッケージのConfigインスタンスを宣言した際に、disposeメソッドの呼び出しを強制するルールを作成したいので、dispose_configという名前でルールを定義し、Configインスタンスの宣言時にdisposeメソッドの呼び出しを強制するメッセージを定義します。
Lintルールに引っかかった場合は、ここで定義したメッセージが表示されます。

lib/src/dispose_config.dart
class DisposeConfig extends DartLintRule {
  const DisposeConfig() : super(code: _code);

  static const _code = LintCode(
    name: 'dispose_config',
    problemMessage: "Undisposed instance of 'Config'.\n"
        "Try invoking 'dispose' in the function in which the "
        "'Config' was created.",
  );

  
  void run(
    CustomLintResolver resolver,
    ErrorReporter reporter,
    CustomLintContext context,
  ) {
    // TODO: 変数定義やメソッド呼び出しを検知し、条件に応じてLintを表示する。
  }
}

変数定義やローカル変数宣言を検知

次は、runメソッド内で、Configインスタンスの宣言を検知し、disposeメソッドの呼び出しを検知する処理を実装してきます。

参考としてclose_sinksのソースコードを見てみると、registerNodeProcessorsFieldDeclaration(フィールドの宣言)とVariableDeclarationStatement(ローカル変数の宣言)のコールバックを登録していることがわかります。

https://github.com/dart-lang/linter/blob/main/lib/src/rules/close_sinks.dart#L81-L87

CustomLintContextから静的解析結果を監視できるLintRuleNodeRegistryを取得できるので、同じようにFieldDeclarationVariableDeclarationStatementのコールバックを登録します。

lib/src/dispose_config.dart

  void run(
    CustomLintResolver resolver,
    ErrorReporter reporter,
    CustomLintContext context,
  ) {
    // フィールド宣言を監視。
    context.registry.addFieldDeclaration((node) {});

    // ローカル変数宣言を監視。
    context.registry.addVariableDeclarationStatement((node) {});
  }

各nodeには、FieldDeclarationVariableDeclarationStatementと呼ばれるASTノードが渡され、これらを使用してフィールド宣言とローカル変数宣言を検知します。

フィールド宣言からConfigインスタンスの宣言を検知

まずは、フィールド宣言からConfigインスタンスの宣言を検知する処理を実装します。

Configインスタンスの宣言を検知するには、FieldDeclarationからインスタンスの型を取得し、Configインスタンスの型であることを確認する必要があります。

lib/src/dispose_config.dart
    context.registry.addFieldDeclaration((node) {
      // 1. フィールド宣言からインスタンスの型を取得。
      final targetType = node.fields.type?.type;
      if (targetType == null) {
        return;
      }
      // 2. TypeCheckerを使用してインスタンスの型がConfigであることを確認。
      final isConfig = const TypeChecker.fromName(
        'Config',
        packageName: 'flutterfire_configurator',
      ).isExactlyType(targetType);
      if (!isConfig) {
        return;
      }
      // 3. Configインスタンスの宣言を検知した場合、Lintを表示。
      reporter.reportErrorForNode(_code, node);
    });

1. フィールド宣言からインスタンスの型を取得

まず、FieldDeclarationfieldsプロパティを調べます。これにはフィールドの宣言が含まれており、例えばlate Config<int> _intConfig;の場合、fieldsにはこの宣言が含まれます。

次に、このVariableDeclarationListからフィールドの型を示すTypeAnnotationを取得します。TypeAnnotationtypeプロパティには、宣言されたフィールドの具体的な型が含まれています。たとえば、late Config<int> _intConfigの場合、typeにはConfig<int>という型情報が格納されています。

この型情報を使用して、フィールドが特定の型(この例ではConfig型)であるかを検証します。

2. TypeCheckerを使用してインスタンスの型がConfigであることを確認

TypeCheckerを使用して、DartTypeConfigであることを確認します。
TypeCheckerは、custom_lint_coreで提供されているクラスで、特定の型をチェックするためのユーティリティです。
riverpod_lintのコードでも使用されているため、参考にしてみてください。

3. Configインスタンスの宣言を検知した場合、Lintを表示

Configインスタンスの宣言を検知した場合、reportErrorForNodeメソッドを使用して、FieldDeclarationのASTノードに対してLintを表示します。
これで、下記のような形で警告が表示されることが確認できました。
disposeメソッドの呼び出しを検知する処理を実装していないため、disposeが定義されているのに警告が表示されているのは想定通りです)


Configインスタンスの宣言を検知した場合、Lintを表示

Configインスタンスの宣言からdisposeメソッドの呼び出しを検知

次は、Configインスタンスの宣言からdisposeメソッドの呼び出しを検知する処理を実装します。

参考としてclose_sinksのソースコードを追ってみると、以下のような手順でcloseメソッドの呼び出しを検知していることがわかります。

  1. FieldDeclarationnodeから自身またはその祖先ノードの中でCompilationUnit(コードの一部分を構成する単位(たとえばファイル全体や一部のコードブロックなど)を表す)型のノードを探す
  2. CompilationUnitの子ノードからMethodDeclaration(メソッドの宣言)型やPrefixedIdentifier(プレフィックス付きの識別子(たとえば変数や関数などの名前))型のノードなどを探す
  3. 見つかったノード全てからフィールド宣言と同じフィールド名かつcloseメソッドを呼び出しているかを確認し、もしフィールド宣言と同じフィールド名でcloseが一度も呼び出されていない場合は、FieldDeclarationのASTノードに対してLintを表示する

今回は、MethodDeclaration型のノードを一通り探すだけでやりたいことはできそうなので、MethodDeclaration型のノードを探して、disposeメソッドの呼び出しを検知する処理を実装します。

lib/src/dispose_config.dart
  // `MethodDeclaration`型のノードを探す。
  Iterable<MethodInvocation> traverseMethodInvocations(AstNode node) {
    final methodInvocations = <MethodInvocation>[];
    void visitNode(AstNode node) {
      if (node is MethodInvocation) {
        methodInvocations.add(node);
      }
      if (node is FunctionExpressionInvocation) {
        final function = node.function;
        if (function is MethodInvocation) {
          methodInvocations.add(function);
        }
      }
      node.childEntities.whereType<AstNode>().forEach(visitNode);
    }

    visitNode(node);
    return methodInvocations;
  }
  // ...
    context.registry.addFieldDeclaration((node) {
      // ...
      if (!isConfig) {
        return;
      }
      // 自身またはその祖先ノードの中で`CompilationUnit`型のノードを探す。
      // `CompilationUnit`はコードの一部分を構成する単位(たとえばファイル全体や一部のコードブロックなど)を表す
      final compilationUnit = node.thisOrAncestorOfType<CompilationUnit>();
      if (compilationUnit == null) {
        return;
      }
      // `compilationUnit`の中で`MethodInvocation`型のノードを探す。
      final methodInvocations = traverseMethodInvocations(compilationUnit);

      // 1. `node.fields.variables`からフィールド名を取得。
      for (final field in node.fields.variables) {
        var isDisposed = false;

        // 2. `methodInvocations`からメソッド宣言一覧を取得、
        for (final methodInvocation in methodInvocations) {
          // 3. `methodInvocation.realTarget`からメソッドの実行対象を取得。
          final target = methodInvocation.realTarget;
          if (target is! SimpleIdentifier) {
            continue;
          }
          final methodName = methodInvocation.methodName.name;
          // 4. メソッドの実行対象がフィールド名と一致し、メソッド名が`dispose`であることを確認。
          if (target.name == field.name.lexeme && methodName == 'dispose') {
            isDisposed = true;
            break;
          }
        }

        if (!isDisposed) {
          // 5. フィールド宣言と同じフィールド名で`dispose`が一度も呼び出されていない場合は、Lintを表示。
          reporter.reportErrorForNode(_code, field);
        }
      }
    });

実装内容を説明していきます。

1. node.fields.variablesからフィールド名を取得

node.fields.variablesからフィールド名を取得します。
node.fields.variablesには、フィールド宣言の一覧が含まれており、例えばlate Config<int> _intConfig;の場合、node.fields.variablesには_intConfigというフィールド宣言が含まれます。

2. methodInvocationsからメソッド宣言一覧を取得

methodInvocationsには、MethodInvocation型のノードの一覧が含まれています。
methodInvocationsは、compilationUnitの中でMethodInvocation型のノードを探す処理で取得しているため、compilationUnitの中で呼び出されているメソッドの一覧が含まれています。

3. methodInvocation.realTargetからメソッドの実行対象を取得

methodInvocation.realTargetには、メソッドの実行対象が含まれています。
例えば、_intConfig.dispose()の場合、_intConfigがメソッドの実行対象になります。

4. メソッドの実行対象がフィールド名と一致し、メソッド名がdisposeであることを確認

methodInvocation.realTargetSimpleIdentifier型であることを確認し、methodInvocation.methodName.namedisposeであることを確認します。
SimpleIdentifier型は、プレフィックスや追加の構造を伴わない基本的な変数、関数、またはオブジェクトの名前を指します。
これにより、_intConfig.dispose()のような形でdisposeメソッドが呼び出されているかを確認できます。

5. フィールド宣言と同じフィールド名でdisposeが一度も呼び出されていない場合は、Lintを表示

ここまでで、_intConfig.dispose()のような形でdisposeメソッドが呼び出されているかを確認できました。
最後に、フィールド宣言と同じフィールド名でdisposeが一度も呼び出されていない場合は、FieldDeclarationのASTノードに対してLintを表示します。

これで、下記のような形で警告が表示されることが確認できました!


Configインスタンスの宣言からdisposeメソッドの呼び出しがないことを検知した場合、Lintを表示

ルールのテスト

Custom Lintとはで紹介したように、custom_lintには組み込みのテストメカニズムが用意されています。

今回は、riverpod_lintを参考に、// expect_lintを使用した組み込みテストメカニズムを使用して、ルールのテストを実装します。

セットアップ

riverpod_lintでは、packages/riverpod_lint_flutter_testにテスト用のパッケージを作成しています。

同様に、packages/flutterfire_lint_testにテスト用のパッケージを作成し、flutterfire_lintパッケージやConfigが定義されているflutterfire_configuratorパッケージを依存関係に追加します。

flutter create -t package packages/flutterfire_lint_test --project-name flutterfire_lint_test
pubspec.yaml
name: flutterfire_lint_test
publish_to: none

environment:
  sdk: ^3.0.0

dependencies:
  flutter:
    sdk: flutter
  flutterfire_configurator:

dev_dependencies:
  custom_lint: ^0.5.7
  flutter_test:
    sdk: flutter
  flutterfire_lint:
    path: ../flutterfire_lint

dependency_overrides:
  flutterfire_configurator:
    path: ../flutterfire_configurator

テストの実装

今回のテスト対象である、dispose_config.dartに対するtest/src/dispose_config.dartファイルを作成し、// expect_lintを使用した組み込みテストメカニズムを使用して、ルールのテストを実装します。

test/src/dispose_config.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutterfire_configurator/configurator.dart';

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  late Config<int> _intConfig1;
  // expect_lint: dispose_config
  late Config<int> _intConfig2;

  final streamController = StreamController<int>();

  
  void initState() {
    _intConfig1 = Config(
      value: 1,
      subscription: streamController.stream.listen((event) {}),
    );
    _intConfig2 = Config(
      value: 2,
      subscription: streamController.stream.listen((event) {}),
    );
    debugPrint(_intConfig1.value.toString());
    debugPrint(_intConfig2.value.toString());
    super.initState();
  }

  
  Future<void> dispose() async {
    await _intConfig1.dispose();
    await streamController.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Container();
  }
}

上記のコードでは、// expect_lint: dispose_configを使用して、dispose_configルールが適用されることをテストしています。
_intConfig1disposeメソッドが呼び出されているため、dispose_configルールに引っかかりませんが、_intConfig2disposeメソッドが呼び出されていないため、dispose_configルールに引っかかる想定です。

では、テストを実行してみましょう。

cd packages/flutterfire_lint_test
❯ dart run custom_lint
Analyzing...                           0.0s

No issues found!

想定通り、_intConfig2に対してdispose_configルールが適用されていることが確認できました!

まとめ

今回は、custom_lintを使って独自のLintルールを作成する方法を紹介しました。
Lintルールを作成することで、特定のコーディングパターンや慣習に合わせたルールを定義することが可能になります。

独自パッケージの開発でLintルールを作成する際は、ぜひこちらの記事を参考にしてみてください!

参考

https://pub.dev/packages/custom_lint
https://invertase.io/blog/announcing-dart-custom-lint
https://pub.dev/packages/riverpod_lint
https://note.com/jigjp_engineer/n/nd5c80713b39d

GitHubで編集を提案
Altiveエンジニアリングブログ

Discussion